Compare commits
334
Commits
@@ -0,0 +1,18 @@
|
|||||||
|
# Dagger context ignore file.
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
.git/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
build/
|
||||||
|
.dart_tool/
|
||||||
|
coverage/
|
||||||
|
linux/flutter/ephemeral/
|
||||||
|
website/public/
|
||||||
|
website/resources/
|
||||||
|
.task/
|
||||||
|
.fvm/
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
.env*
|
||||||
|
.envrc
|
||||||
@@ -14,5 +14,7 @@ PATH_add .fvm/flutter_sdk/bin
|
|||||||
|
|
||||||
PATH_add "$HOME/Android/Sdk/platform-tools"
|
PATH_add "$HOME/Android/Sdk/platform-tools"
|
||||||
|
|
||||||
|
export DAGGER_NO_NAG=1
|
||||||
|
|
||||||
# Load variables from .env
|
# Load variables from .env
|
||||||
dotenv_if_exists
|
dotenv_if_exists
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Source: https://codeberg.org/guettli/sharedinbox/src/branch/main/.forgejo/Dockerfile
|
||||||
|
# Install at on the act-runner host on: /etc/forgejo/runner/Dockerfile
|
||||||
|
#
|
||||||
|
# In systemd service:
|
||||||
|
# ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner
|
||||||
|
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
|
||||||
|
FROM ghcr.io/catthehacker/ubuntu:go-24.04
|
||||||
|
|
||||||
|
# Infrastructure tools required by CI workflows
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
stunnel4 \
|
||||||
|
netcat-openbsd \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Dagger CLI — pinned to match the engine version on the runner host
|
||||||
|
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
||||||
|
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
||||||
|
|
||||||
|
# Task runner
|
||||||
|
RUN curl -fsSL https://taskfile.dev/install.sh \
|
||||||
|
| sh -s -- -b /usr/local/bin v3.48.0
|
||||||
+22
-24
@@ -8,35 +8,33 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
name: Full Project Check
|
name: Full Project Check
|
||||||
# Match the label of your self-hosted runner
|
runs-on: ubuntu-latest
|
||||||
runs-on: self-hosted
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 50
|
||||||
|
|
||||||
- name: Enable Nix flakes
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.config/nix
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
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 Full Check Suite
|
- name: Run Full Check Suite
|
||||||
# Using nix develop ensures the runner doesn't need flutter/dart/stalwart installed globally.
|
env:
|
||||||
# 'task check' runs analyze, unit tests, widget tests, and integration tests.
|
DAGGER_NO_NAG: "1"
|
||||||
run: nix develop --command task check
|
run: task check-dagger
|
||||||
|
|
||||||
build-linux:
|
- name: Cleanup TLS credentials
|
||||||
name: Build Linux Release
|
if: always()
|
||||||
runs-on: self-hosted
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
needs: check
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Enable Nix flakes
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.config/nix
|
|
||||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
|
||||||
|
|
||||||
- name: Build Linux
|
|
||||||
run: nix develop --command task build-linux-release
|
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 * * * *' # every hour on the hour
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-android-firebase:
|
||||||
|
name: Android Instrumented Tests (Firebase Test Lab)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 50
|
||||||
|
|
||||||
|
- name: Check runner tools
|
||||||
|
run: |
|
||||||
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
|
env:
|
||||||
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
|
- name: Run Android Tests on Firebase Test Lab
|
||||||
|
env:
|
||||||
|
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
|
||||||
|
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: task test-android-firebase
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|
||||||
|
deploy-playstore:
|
||||||
|
name: Build & Deploy to Play Store
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 50
|
||||||
|
|
||||||
|
- name: Check runner tools
|
||||||
|
run: |
|
||||||
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
|
env:
|
||||||
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
|
- name: Publish Android to Play Store
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: task publish-android
|
||||||
|
|
||||||
|
- name: Build & Deploy APK to server
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: task deploy-apk
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
name: Build Linux Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 50
|
||||||
|
|
||||||
|
- name: Check runner tools
|
||||||
|
run: |
|
||||||
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
|
env:
|
||||||
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
|
- name: Build & Deploy Linux to server
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: task deploy-linux
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|
||||||
|
publish-website:
|
||||||
|
name: Publish Website Build History
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build-linux, deploy-playstore]
|
||||||
|
if: |
|
||||||
|
always() &&
|
||||||
|
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success')
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 50
|
||||||
|
|
||||||
|
- name: Check runner tools
|
||||||
|
run: |
|
||||||
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
|
env:
|
||||||
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
|
- name: Generate build history and deploy website
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: task publish-website
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|
||||||
|
label-deploy-health:
|
||||||
|
name: Update Deploy Health Label
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [test-android-firebase, deploy-playstore, build-linux]
|
||||||
|
if: always() && vars.DEPLOY_HEALTH_ISSUE != ''
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Set CI/Full-Pass or CI/Full-Fail label on tracking issue
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
FORGEJO_URL: ${{ github.server_url }}
|
||||||
|
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
|
||||||
|
ALL_SUCCEEDED: ${{ needs.test-android-firebase.result == 'success' && needs.deploy-playstore.result == 'success' && needs.build-linux.result == 'success' }}
|
||||||
|
run: |
|
||||||
|
python3 - << 'PYEOF'
|
||||||
|
import os, json, urllib.request, urllib.error
|
||||||
|
|
||||||
|
issue = os.environ.get("DEPLOY_HEALTH_ISSUE", "").strip()
|
||||||
|
if not issue:
|
||||||
|
print("DEPLOY_HEALTH_ISSUE not set; skipping")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
token = os.environ["FORGEJO_TOKEN"]
|
||||||
|
url_base = os.environ["FORGEJO_URL"].rstrip("/")
|
||||||
|
succeeded = os.environ.get("ALL_SUCCEEDED", "false").lower() == "true"
|
||||||
|
add_label = "CI/Full-Pass" if succeeded else "CI/Full-Fail"
|
||||||
|
remove_label = "CI/Full-Fail" if succeeded else "CI/Full-Pass"
|
||||||
|
|
||||||
|
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||||
|
api = f"{url_base}/api/v1/repos/guettli/sharedinbox"
|
||||||
|
|
||||||
|
def api_get(path):
|
||||||
|
req = urllib.request.Request(f"{api}{path}", headers=headers)
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
def api_put(path, body):
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
req = urllib.request.Request(f"{api}{path}", data=data, headers=headers, method="PUT")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(f"PUT {path} failed: {e.read().decode()}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
repo_labels = api_get("/labels")
|
||||||
|
label_map = {l["name"]: l["id"] for l in repo_labels}
|
||||||
|
|
||||||
|
if add_label not in label_map:
|
||||||
|
print(f"Label '{add_label}' not found in repo — create it first")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
current = api_get(f"/issues/{issue}/labels")
|
||||||
|
keep_ids = [l["id"] for l in current if l["name"] not in ("CI/Full-Pass", "CI/Full-Fail")]
|
||||||
|
keep_ids.append(label_map[add_label])
|
||||||
|
|
||||||
|
api_put(f"/issues/{issue}/labels", {"labels": keep_ids})
|
||||||
|
print(f"Set '{add_label}' on issue #{issue}")
|
||||||
|
PYEOF
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy-playstore:
|
|
||||||
name: Build & Deploy to Play Store
|
|
||||||
runs-on: self-hosted
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Enable Nix flakes
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.config/nix
|
|
||||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
|
||||||
|
|
||||||
- name: Install Android SDK (cached on runner between runs)
|
|
||||||
run: |
|
|
||||||
SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
|
|
||||||
if [ ! -d "$SDK/platforms/android-34" ]; then
|
|
||||||
echo "Android SDK not found, installing..."
|
|
||||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
|
|
||||||
mkdir -p "$SDK/cmdline-tools"
|
|
||||||
unzip -q /tmp/cmdtools.zip -d "$SDK/cmdline-tools"
|
|
||||||
[ -d "$SDK/cmdline-tools/cmdline-tools" ] && mv "$SDK/cmdline-tools/cmdline-tools" "$SDK/cmdline-tools/latest"
|
|
||||||
yes | "$SDK/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true
|
|
||||||
"$SDK/cmdline-tools/latest/bin/sdkmanager" "platform-tools" "build-tools;34.0.0" "platforms;android-34"
|
|
||||||
else
|
|
||||||
echo "Android SDK cached, skipping install."
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Prepare Keystore
|
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
run: |
|
|
||||||
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
|
|
||||||
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks
|
|
||||||
else
|
|
||||||
echo "Error: ANDROID_KEYSTORE_BASE64 secret is not set."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build & Deploy to Play Store
|
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
|
|
||||||
run: nix develop --command task deploy-android-bundle
|
|
||||||
@@ -12,36 +12,21 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
name: Build & Deploy Website
|
name: Build & Deploy Website
|
||||||
runs-on: self-hosted
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Enable Nix flakes
|
- name: Build & Deploy Website
|
||||||
run: |
|
|
||||||
mkdir -p ~/.config/nix
|
|
||||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
|
||||||
|
|
||||||
- name: Setup SSH
|
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.WEBSITE_SSH_PRIVATE_KEY }}
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
run: |
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
if [ -n "$SSH_PRIVATE_KEY" ]; then
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
mkdir -p ~/.ssh
|
run: task website-deploy
|
||||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
|
||||||
chmod 600 ~/.ssh/id_rsa
|
|
||||||
else
|
|
||||||
echo "Error: WEBSITE_SSH_PRIVATE_KEY secret is not set."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Deploy
|
- name: Verify Website
|
||||||
env:
|
env:
|
||||||
SSH_USER: ${{ secrets.WEBSITE_SSH_USER }}
|
|
||||||
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
||||||
run: nix develop --command task website-deploy
|
run: scripts/website-verify.sh
|
||||||
|
|
||||||
- name: Verify
|
|
||||||
run: nix develop --command task website-verify
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
name: Windows Nightly
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 2 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
windows-nightly:
|
||||||
|
# Disabled until a self-hosted runner with label "windows-runner" is registered.
|
||||||
|
name: Build & Deploy Windows (Nightly)
|
||||||
|
runs-on: windows-runner
|
||||||
|
if: false
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Check for recent changes on main
|
||||||
|
run: |
|
||||||
|
$changes = git log --oneline --since "24 hours ago" origin/main
|
||||||
|
if (-not $changes) {
|
||||||
|
Write-Output "No changes in last 24 hours, skipping build."
|
||||||
|
Add-Content -Path $env:GITHUB_ENV -Value "SKIP_BUILD=true"
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Build Windows
|
||||||
|
if: env.SKIP_BUILD != 'true'
|
||||||
|
run: task build-windows-release
|
||||||
|
|
||||||
|
- name: Set up SSH key
|
||||||
|
if: env.SKIP_BUILD != 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p $env:USERPROFILE\.ssh
|
||||||
|
$env:SSH_PRIVATE_KEY | Out-File -FilePath "$env:USERPROFILE\.ssh\id_rsa" -Encoding ascii
|
||||||
|
icacls "$env:USERPROFILE\.ssh\id_rsa" /inheritance:r /grant:r "$env:USERNAME:F"
|
||||||
|
|
||||||
|
- name: Deploy Windows to server
|
||||||
|
if: env.SKIP_BUILD != 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
|
run: task deploy-windows-to-server
|
||||||
+100
-4
@@ -8,7 +8,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
analyze-and-test:
|
analyze-and-test:
|
||||||
name: Analyze & unit test
|
name: Analyze & unit test
|
||||||
runs-on: ubuntu-latest
|
runs-on: sharedinbox-runner
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
|
|
||||||
integration:
|
integration:
|
||||||
name: Integration tests (Stalwart)
|
name: Integration tests (Stalwart)
|
||||||
runs-on: ubuntu-latest
|
runs-on: sharedinbox-runner
|
||||||
# Run integration tests only on push to main, not on every PR.
|
# Run integration tests only on push to main, not on every PR.
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
|
|
||||||
integration-ui:
|
integration-ui:
|
||||||
name: UI Integration tests (Stalwart + Xvfb)
|
name: UI Integration tests (Stalwart + Xvfb)
|
||||||
runs-on: ubuntu-latest
|
runs-on: sharedinbox-runner
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -124,7 +124,7 @@ jobs:
|
|||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
name: Build Linux desktop
|
name: Build Linux desktop
|
||||||
runs-on: ubuntu-latest
|
runs-on: sharedinbox-runner
|
||||||
needs: analyze-and-test
|
needs: analyze-and-test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -151,3 +151,99 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Linux release
|
- name: Build Linux release
|
||||||
run: flutter build linux --release
|
run: flutter build linux --release
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy Linux build & publish website
|
||||||
|
runs-on: sharedinbox-runner
|
||||||
|
needs: build-linux
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
env:
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install build & deploy dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -q
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libgtk-3-dev pkg-config cmake ninja-build clang \
|
||||||
|
libsecret-1-dev hugo rsync
|
||||||
|
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: "3.41.6"
|
||||||
|
channel: stable
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Cache pub packages
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.pub-cache
|
||||||
|
key: pub-${{ hashFiles('pubspec.lock') }}
|
||||||
|
restore-keys: pub-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Generate Drift code
|
||||||
|
run: flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
|
- name: Generate changelog
|
||||||
|
run: |
|
||||||
|
mkdir -p assets
|
||||||
|
git log -n 50 \
|
||||||
|
--pretty=format:'* %ad [%h](https://codeberg.org/guettli/sharedinbox/commit/%H): %s' \
|
||||||
|
--date=short > assets/changelog.txt
|
||||||
|
|
||||||
|
- name: Setup SSH
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
|
||||||
|
- name: Build Linux release
|
||||||
|
run: |
|
||||||
|
HASH=$(git rev-parse --short HEAD)
|
||||||
|
flutter build linux --release --no-pub --dart-define=GIT_HASH=$HASH
|
||||||
|
|
||||||
|
- name: Deploy Linux build to server
|
||||||
|
run: |
|
||||||
|
HASH=$(git rev-parse --short HEAD)
|
||||||
|
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||||
|
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||||
|
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
|
||||||
|
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
|
||||||
|
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||||
|
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
||||||
|
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
|
||||||
|
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" \
|
||||||
|
"cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||||
|
WINDOWS_URL=$(echo "$EXISTING" | \
|
||||||
|
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" \
|
||||||
|
2>/dev/null || true)
|
||||||
|
if [ -n "$WINDOWS_URL" ]; then
|
||||||
|
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
|
||||||
|
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
|
else
|
||||||
|
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
||||||
|
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Generate build history pages
|
||||||
|
run: python3 scripts/generate_build_history.py
|
||||||
|
|
||||||
|
- name: Build website
|
||||||
|
env:
|
||||||
|
HUGO_PARAMS_GITVERSION: ${{ github.sha }}
|
||||||
|
run: hugo --source website --minify
|
||||||
|
|
||||||
|
- name: Deploy website
|
||||||
|
run: |
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude='*.apk' \
|
||||||
|
--exclude='*.tar.gz' \
|
||||||
|
-e "ssh -o StrictHostKeyChecking=no" \
|
||||||
|
website/public/ \
|
||||||
|
"$SSH_USER@$SSH_HOST:public_html/"
|
||||||
|
|||||||
+16
-1
@@ -3,7 +3,6 @@ coverage/
|
|||||||
.dart_tool/
|
.dart_tool/
|
||||||
.dart-tool/
|
.dart-tool/
|
||||||
.packages
|
.packages
|
||||||
pubspec.lock
|
|
||||||
build/
|
build/
|
||||||
*.g.dart
|
*.g.dart
|
||||||
*.freezed.dart
|
*.freezed.dart
|
||||||
@@ -58,6 +57,10 @@ linux/flutter/generated_plugins.cmake
|
|||||||
.flutter-plugins-dependencies
|
.flutter-plugins-dependencies
|
||||||
.metadata
|
.metadata
|
||||||
|
|
||||||
|
# --- Python ---
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
# --- Tools & Cache ---
|
# --- Tools & Cache ---
|
||||||
.fvm/
|
.fvm/
|
||||||
fvm/
|
fvm/
|
||||||
@@ -98,6 +101,8 @@ sharedinbox-runner/runner-data/
|
|||||||
website/public/
|
website/public/
|
||||||
website/resources/
|
website/resources/
|
||||||
website/.hugo_build.lock
|
website/.hugo_build.lock
|
||||||
|
website/content/builds/_index.md
|
||||||
|
website/content/builds/[0-9]*/
|
||||||
|
|
||||||
.copilot/
|
.copilot/
|
||||||
.dotnet/
|
.dotnet/
|
||||||
@@ -105,4 +110,14 @@ website/.hugo_build.lock
|
|||||||
.wget-hsts
|
.wget-hsts
|
||||||
|
|
||||||
tmp/
|
tmp/
|
||||||
|
test/widget/failures/
|
||||||
.claude*
|
.claude*
|
||||||
|
|
||||||
|
dagger-certs
|
||||||
|
.Xauthority
|
||||||
|
.sharedinbox-agent-state.json
|
||||||
|
|
||||||
|
.viminfo
|
||||||
|
/go
|
||||||
|
.last_deployed_sha
|
||||||
|
.fail_count
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
[submodule "website/themes/PaperMod"]
|
|
||||||
path = website/themes/PaperMod
|
|
||||||
url = https://github.com/adityatelange/hugo-PaperMod.git
|
|
||||||
@@ -12,6 +12,12 @@ repos:
|
|||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
|
- id: check-no-binary
|
||||||
|
name: check for binary files (build artifacts, databases)
|
||||||
|
language: system
|
||||||
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && scripts/check_no_binary.sh'
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
- id: forbidden-files-hook
|
- id: forbidden-files-hook
|
||||||
name: check for forbidden home-directory files
|
name: check for forbidden home-directory files
|
||||||
language: system
|
language: system
|
||||||
@@ -24,3 +30,15 @@ repos:
|
|||||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command scripts/pre_commit_check.sh'
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command scripts/pre_commit_check.sh'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
|
- id: ci-no-direct-dagger
|
||||||
|
name: check for direct dagger calls in workflows (use Task instead)
|
||||||
|
language: system
|
||||||
|
entry: "bash -c 'git grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'"
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
- id: dagger-progress-plain
|
||||||
|
name: ensure all dagger calls use --progress=plain
|
||||||
|
language: system
|
||||||
|
entry: "bash -c 'git grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
|||||||
@@ -1,5 +1,40 @@
|
|||||||
# SharedInbox — Development Guide
|
# SharedInbox — Development Guide
|
||||||
|
|
||||||
|
## Codeberg
|
||||||
|
|
||||||
|
We use Codeberg: https://codeberg.org/guettli/sharedinbox/
|
||||||
|
|
||||||
|
CLI tool `fgj` is available to query issues/PRs/actions.
|
||||||
|
|
||||||
|
## Issue Label Workflow
|
||||||
|
|
||||||
|
We use issues, follow this label state machine:
|
||||||
|
|
||||||
|
- **State/Ready** — Issue is available to pick up
|
||||||
|
- **State/InProgress** — Set this when you start working on an issue
|
||||||
|
- **State/Question** — Set this when you hit a blocker or need clarification
|
||||||
|
|
||||||
|
List open issues ready to pick up:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/Ready")] | .[] | {number, title, html_url}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Never start work on an issue without `State/Ready`
|
||||||
|
- When working via the agent loop: `State/Ready` → `State/InProgress` is set automatically
|
||||||
|
by `agent_loop.py` before the agent starts — do **not** set it yourself.
|
||||||
|
- When working manually: switch to `State/InProgress` as your **first action**:
|
||||||
|
```bash
|
||||||
|
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
|
||||||
|
```
|
||||||
|
- If blocked, replace current state label with `State/Question` and leave a comment explaining the blocker
|
||||||
|
- When done and CI is green, close the issue:
|
||||||
|
```bash
|
||||||
|
fgj issue close <NUMBER>
|
||||||
|
```
|
||||||
|
|
||||||
## Code conventions
|
## Code conventions
|
||||||
|
|
||||||
- Avoid `else`, use "early return".
|
- Avoid `else`, use "early return".
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Dagger CI/CD Setup
|
||||||
|
|
||||||
|
This project has migrated from Taskfile-based CI to **Dagger**. This document explains the infrastructure setup for the shared Dagger Server.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
We use a **Shared Dagger Server** approach for both local development and CI. This allows multiple users to share a single Dagger Engine and its cache, significantly speeding up builds.
|
||||||
|
|
||||||
|
- **Container Engine:** Rootless Podman (managed by the `dagger-svc` user).
|
||||||
|
- **Orchestration:** System-wide `systemd` service.
|
||||||
|
- **Access:** Users connect via TCP (localhost) or Unix Socket.
|
||||||
|
|
||||||
|
## Server Setup (Admin)
|
||||||
|
|
||||||
|
### 1. Dedicated Service User
|
||||||
|
A dedicated user `dagger-svc` owns the Dagger Engine and its cache.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd -m -s /bin/bash dagger-svc
|
||||||
|
sudo loginctl enable-linger dagger-svc
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why Lingering?**
|
||||||
|
Lingering is required for rootless users to maintain a persistent background session. It ensures that `/run/user/<UID>` and the user-level Dagger/Podman namespaces are initialized at boot and remain active even when the user is not logged in.
|
||||||
|
|
||||||
|
### 2. Systemd Service
|
||||||
|
The engine is managed by a system-wide systemd service located at `/etc/systemd/system/dagger-engine.service`.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Dagger Engine (Shared Server)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=dagger-svc
|
||||||
|
Group=dagger-svc
|
||||||
|
WorkingDirectory=/home/dagger-svc
|
||||||
|
# Replace 1003 with the actual UID of dagger-svc
|
||||||
|
Environment=DOCKER_HOST=unix:///run/user/1003/podman/podman.sock
|
||||||
|
Environment=XDG_RUNTIME_DIR=/run/user/1003
|
||||||
|
ExecStart=/usr/bin/nix run github:dagger/nix/v0.11.4#dagger -- engine --addr tcp://0.0.0.0:8080
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Configuration
|
||||||
|
|
||||||
|
To connect to the shared engine, users should set the `_DAGGER_RUNNER_HOST` environment variable.
|
||||||
|
|
||||||
|
### Local Development (.env)
|
||||||
|
The project uses a `.env` file to manage the connection string. Ensure your `.env` contains:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
_DAGGER_RUNNER_HOST=tcp://127.0.0.1:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
Once the environment is set up, you can run the Dagger pipeline. For non-interactive environments (CI, LLMs), use `--progress=plain` for readable logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop --command dagger call --progress=plain -q -m ci --source=. check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
All sensitive credentials are passed as `dagger.Secret` (never as plain strings).
|
||||||
|
This prevents values from appearing in Dagger logs or being cached in the engine.
|
||||||
|
|
||||||
|
| Parameter | Functions |
|
||||||
|
|---|---|
|
||||||
|
| `sshKey *dagger.Secret` | `Deployer`, `GenerateBuildHistory`, `BuildWebsite`, `PublishWebsite`, `DeployLinux`, `DeployApk` |
|
||||||
|
| `keystoreBase64 *dagger.Secret` | `setupKeystore`, `BuildAndroidApk`, `DeployApk`, `SignAndroidBundle`, `PublishAndroid` |
|
||||||
|
| `keystorePassword *dagger.Secret` | same as above |
|
||||||
|
| `playStoreConfig *dagger.Secret` | `UploadToPlayStore`, `PublishAndroid` |
|
||||||
|
| `serviceAccountKey *dagger.Secret` | `TestAndroidFirebase` |
|
||||||
|
|
||||||
|
Secrets are injected via `WithMountedSecret` (file-based, e.g. SSH key) or
|
||||||
|
`WithSecretVariable` (env-var-based, e.g. keystore data, Play Store JSON).
|
||||||
|
|
||||||
|
The only credentials not typed as `dagger.Secret` are the test passwords
|
||||||
|
(`STALWART_PASS_B`, `STALWART_PASS_C`) in `WithStalwart`. These are hardcoded
|
||||||
|
development values defined in `stalwart-dev/` — not production secrets.
|
||||||
|
|
||||||
|
## CI Integration (Codeberg/Forgejo)
|
||||||
|
|
||||||
|
The CI workflow in `.forgejo/workflows/ci.yml` is configured to use the Dagger module located in the `ci/` directory.
|
||||||
|
|
||||||
|
- **Check Suite:** Runs analysis and tests in parallel.
|
||||||
|
- **Builds:** Produces Linux and Android artifacts.
|
||||||
|
- **Caching:** When using the shared engine, CI runners benefit from the persistent cache on the host.
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
# Development Environment Setup
|
||||||
|
|
||||||
|
This document explains how to set up a development environment for SharedInbox.
|
||||||
|
|
||||||
|
## ⚠️ Security Recommendation: Use a Dedicated Linux User
|
||||||
|
|
||||||
|
For enhanced security, especially when working with autonomous coding agents (like Gemini CLI in YOLO mode), we **strongly recommend** using a dedicated Linux user for this project. This isolates the project environment and prevents any potential accidental damage to your main system.
|
||||||
|
|
||||||
|
### 1. Create a Dedicated User
|
||||||
|
|
||||||
|
Set the user name variable (default is `si` for SharedInbox):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DEV_USER=si
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the user and add them to the `sudo` group:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo adduser --disabled-password newuser $DEV_USER
|
||||||
|
```
|
||||||
|
|
||||||
|
Set up SSH public key login (replace with your actual public key):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /home/$DEV_USER/.ssh
|
||||||
|
sudo chmod 700 /home/$DEV_USER/.ssh
|
||||||
|
echo "ssh-ed25519 AAAA... your-key-comment" | sudo tee /home/$DEV_USER/.ssh/authorized_keys
|
||||||
|
sudo chmod 600 /home/$DEV_USER/.ssh/authorized_keys
|
||||||
|
sudo chown -R $DEV_USER:$DEV_USER /home/$DEV_USER/.ssh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Switch to the Dedicated User
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh $DEV_USER@localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create ssh-keypair
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh-keygen
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Clone the Repository
|
||||||
|
|
||||||
|
Clone the project into your new user's home directory:
|
||||||
|
|
||||||
|
```bash^
|
||||||
|
git clone ssh://git@codeberg.org/guettli/sharedinbox.git
|
||||||
|
|
||||||
|
# Move git directory into $HOME
|
||||||
|
# This user only works on the git repo. Avoid "cd sharedinbox" after each login...
|
||||||
|
mv sharedinbox/* .
|
||||||
|
mv sharedinbox/.??* .
|
||||||
|
rmdir sharedinbox/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3b. Configure Git Identity
|
||||||
|
|
||||||
|
The new user needs a Git identity for commits and some scripts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config --global user.name "Your Name"
|
||||||
|
git config --global user.email "your.email@example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Install System Dependencies
|
||||||
|
|
||||||
|
This project uses **Nix** with flakes to manage its toolchain (Flutter, Dart, Stalwart, etc.).
|
||||||
|
|
||||||
|
```
|
||||||
|
mkdir -p .config/nix
|
||||||
|
|
||||||
|
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||||
|
|
||||||
|
nix profile add nixpkgs#direnv
|
||||||
|
|
||||||
|
nix profile add nixpkgs#nix-direnv
|
||||||
|
|
||||||
|
echo 'eval "$(direnv hook bash)"' >> ~/.bashrc
|
||||||
|
source ~/.bashrc
|
||||||
|
|
||||||
|
.config/direnv/direnv.toml
|
||||||
|
```
|
||||||
|
[global]
|
||||||
|
hide_env_diff = true
|
||||||
|
#log_filter = "^$"
|
||||||
|
|
||||||
|
[whitelist]
|
||||||
|
prefix = [ "/home/DEV_USER-CHANGE_THAT" ]
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 4b. Additional Permissions (GUI & Android)
|
||||||
|
|
||||||
|
1. **GUI Access**: To run the Linux app (`task run`) from the `si` user, you must allow it to access your X server. Run this **from your main user terminal**:
|
||||||
|
```bash
|
||||||
|
xhost +local:$DEV_USER
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Android Emulator (KVM)**: If you plan to use the Android emulator, add the user to the `kvm` group:
|
||||||
|
```bash
|
||||||
|
sudo usermod -aG kvm $DEV_USER
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Project Setup
|
||||||
|
|
||||||
|
Once you are in the project directory and have the dependencies installed:
|
||||||
|
|
||||||
|
1. **Initialize Environment**:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Allow direnv**:
|
||||||
|
```bash
|
||||||
|
direnv allow
|
||||||
|
```
|
||||||
|
*This will trigger Nix to download and set up the environment (Flutter, Android SDK, etc.). It might take some time on the first run.*
|
||||||
|
|
||||||
|
3. **Install Flutter (via FVM)**:
|
||||||
|
Nix provides FVM, which manages the pinned Flutter version.
|
||||||
|
```bash
|
||||||
|
fvm install
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Initial Setup**:
|
||||||
|
Run the comprehensive setup command which handles `pub get`, code generation, and git hooks:
|
||||||
|
```bash
|
||||||
|
task setup
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Verify the Setup
|
||||||
|
|
||||||
|
Run the full check suite to ensure everything is working correctly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task check
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Running the App
|
||||||
|
|
||||||
|
To run the app on your Linux desktop:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task run
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Working with VS Code
|
||||||
|
|
||||||
|
To maintain isolation, it is recommended to run VS Code "remotely" on the dedicated development user.
|
||||||
|
|
||||||
|
### Preferred Method: VS Code Remote - SSH
|
||||||
|
|
||||||
|
The most robust way to work with a separate user is using the **VS Code Remote - SSH** extension. This allows you to run the VS Code Server as the `si` user while using your main user's GUI.
|
||||||
|
|
||||||
|
1. **Install the Extension**: Install "Remote - SSH" from the VS Code Marketplace.
|
||||||
|
2. **Enable SSH for the Dev User**:
|
||||||
|
From your main user, copy your SSH public key to the dev user:
|
||||||
|
```bash
|
||||||
|
# As your main user:
|
||||||
|
sudo mkdir -p /home/$DEV_USER/.ssh
|
||||||
|
sudo cp ~/.ssh/id_rsa.pub /home/$DEV_USER/.ssh/authorized_keys
|
||||||
|
sudo chown -R $DEV_USER:$DEV_USER /home/$DEV_USER/.ssh
|
||||||
|
sudo chmod 700 /home/$DEV_USER/.ssh
|
||||||
|
sudo chmod 600 /home/$DEV_USER/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
3. **Connect**:
|
||||||
|
In VS Code, open the Command Palette (`Ctrl+Shift+P`) and select `Remote-SSH: Connect to Host...`.
|
||||||
|
Enter: `si@localhost` (or `$DEV_USER@localhost`).
|
||||||
|
4. **Install Extensions in the Remote**:
|
||||||
|
Once connected, you will need to install the following extensions *on the remote user*:
|
||||||
|
* **Dart** / **Flutter**
|
||||||
|
* **direnv**: (by mkhl) Highly recommended to automatically load the Nix environment inside VS Code.
|
||||||
|
* **Nix IDE**: For syntax highlighting.
|
||||||
|
|
||||||
|
### Why SSH?
|
||||||
|
Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks. It provides a clean boundary for the VS Code process and any integrated terminal or coding agents, ensuring they cannot access your personal files in `/home/$YOUR_USER`.
|
||||||
|
|
||||||
|
> **Note on Security:** While these instructions add the user to the `sudo` group for convenience during setup, you can remove it later with `sudo gpasswd -d $DEV_USER sudo` to further restrict the user and any coding agents.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Daily Workflow
|
||||||
|
|
||||||
|
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Implementation Plan: Secure WebView for HTML Emails (#21)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Replace the current `flutter_html` based rendering with a hardened WebView-based approach to improve rendering fidelity while strictly enforcing security and privacy.
|
||||||
|
|
||||||
|
## 1. Dependency Management
|
||||||
|
- **Core**: `webview_flutter` (v4+)
|
||||||
|
- **Linux Platform**: `webview_flutter_linux` (Official community-supported or WebKitGTK based implementation). *Note: I will verify the exact package name during implementation.*
|
||||||
|
- **Utilities**: `url_launcher` (existing) for opening links in the system browser.
|
||||||
|
|
||||||
|
## 2. Secure WebView Component (`lib/ui/widgets/secure_email_webview.dart`)
|
||||||
|
Create a new widget `SecureEmailWebView` that encapsulates the `WebViewWidget` and its controller.
|
||||||
|
|
||||||
|
### Configuration & Hardening
|
||||||
|
- **Disable JavaScript**: `controller.setJavaScriptMode(JavaScriptMode.disabled)`.
|
||||||
|
- **Background**: Match the application theme (e.g., transparent or surface color).
|
||||||
|
- **Security Headers/CSP**: Inject a Content Security Policy via `<meta>` tag in the HTML wrapper:
|
||||||
|
- `default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:;` (Blocks all external assets by default).
|
||||||
|
|
||||||
|
### Image Blocking Logic
|
||||||
|
- **Initial State**: Block remote images by injecting a CSP that restricts `img-src` to `data:` and local schemes.
|
||||||
|
- **Toggle Mechanism**:
|
||||||
|
- Provide a "Load Remote Images" button in the Flutter UI.
|
||||||
|
- When triggered, re-render the HTML with an updated CSP: `img-src * data:;`.
|
||||||
|
|
||||||
|
### Link Interception & Phishing Protection
|
||||||
|
- Implement `NavigationDelegate.onNavigationRequest`.
|
||||||
|
- **Process**:
|
||||||
|
1. Intercept any URL that doesn't start with `about:blank` or `data:`.
|
||||||
|
2. Block the navigation in the WebView.
|
||||||
|
3. Trigger a Flutter `showDialog` for confirmation.
|
||||||
|
- **Phishing Protection Dialog**:
|
||||||
|
- Show the full URL.
|
||||||
|
- **Bold the FQDN**: Parse the URL using `Uri.parse`.
|
||||||
|
- Example: `https://`**`important-bank.com`**`/login`
|
||||||
|
- "Open in Browser" button uses `url_launcher`.
|
||||||
|
|
||||||
|
## 3. Integration Plan
|
||||||
|
### Step 1: Initialization
|
||||||
|
Modify `lib/main.dart` to initialize the Linux WebView platform (using `webview_flutter_linux` or similar) during app startup.
|
||||||
|
|
||||||
|
### Step 2: Replace Renderer in Screens
|
||||||
|
- **EmailDetailScreen**: Replace `Html(...)` with `SecureEmailWebView(html: body.htmlBody!)`.
|
||||||
|
- **ThreadDetailScreen**: Replace `Html(...)` with `SecureEmailWebView(html: body.htmlBody!)`.
|
||||||
|
- Remove `flutter_html` imports and dependencies once migration is complete.
|
||||||
|
|
||||||
|
## 4. Verification & Security Audit
|
||||||
|
- **Manual Tests**:
|
||||||
|
- Open emails with complex HTML layouts.
|
||||||
|
- Verify images are blocked initially.
|
||||||
|
- Verify "Load images" works.
|
||||||
|
- Click various links (http, https, mailto) and verify the confirmation dialog and FQDN bolding.
|
||||||
|
- **Security Check**:
|
||||||
|
- Verify that `<script>` tags are not executed.
|
||||||
|
- Verify no network requests for external images occur before user consent (via DevTools or proxy).
|
||||||
|
|
||||||
|
## 5. Potential Challenges
|
||||||
|
- **Linux WebView Stability**: WebKitGTK on Linux can sometimes have rendering or sizing issues in Flutter.
|
||||||
|
- **Scrolling**: Ensuring the WebView integrates smoothly into the `ListView` of the email detail screen (might require fixed height or `SizedBox`).
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# Email Sync Architecture
|
||||||
|
|
||||||
|
This document describes the full lifecycle of an email action — from the moment the user taps
|
||||||
|
a button to server confirmation — covering the IMAP IDLE loop, JMAP push/poll, the pending-change
|
||||||
|
queue, exponential backoff, and the undo/cancel mechanism.
|
||||||
|
|
||||||
|
For the database schema and protocol-level implementation details see [DB-SYNC.md](DB-SYNC.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Components
|
||||||
|
|
||||||
|
| Component | File | Role |
|
||||||
|
|-----------|------|------|
|
||||||
|
| `AccountSyncManager` | `lib/core/sync/account_sync_manager.dart` | Owns one `_SyncLoop` per account; starts, stops, and wakes sync loops |
|
||||||
|
| `_AccountSync` | same file | IMAP sync loop (IDLE + incremental fetch) |
|
||||||
|
| `_JmapAccountSync` | same file | JMAP sync loop (SSE push + poll fallback) |
|
||||||
|
| `EmailRepositoryImpl` | `lib/data/repositories/email_repository_impl.dart` | All DB reads/writes and network calls |
|
||||||
|
| `pending_changes` table | `lib/data/db/database.dart` | Protocol-agnostic outbound mutation queue |
|
||||||
|
| `UndoService` | `lib/core/services/undo_service.dart` | Persisted undo history; cancel-or-reverse logic |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Lifecycle of an email mutation (e.g. "Mark as read")
|
||||||
|
|
||||||
|
```
|
||||||
|
User taps "Mark as read"
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
EmailRepository.setFlag(id, seen: true)
|
||||||
|
│
|
||||||
|
├─ 1. Write optimistic update to local DB
|
||||||
|
│ emails.is_seen = true
|
||||||
|
│
|
||||||
|
└─ 2. Insert row into pending_changes
|
||||||
|
{ type: 'flag_seen', email_id: id, payload: {seen: true} }
|
||||||
|
(IMAP: includes uid + mailboxPath for the STORE command)
|
||||||
|
(JMAP: includes just the flag map for Email/set)
|
||||||
|
|
||||||
|
[UI immediately reflects the change via Drift's reactive streams]
|
||||||
|
|
||||||
|
│
|
||||||
|
▼ (next sync cycle, triggered by IMAP IDLE / JMAP push / wakeUp)
|
||||||
|
_SyncLoop._flush() / flushPendingChanges()
|
||||||
|
│
|
||||||
|
├─ IMAP: open connection → STORE uid +FLAGS (\Seen) → close
|
||||||
|
│
|
||||||
|
└─ JMAP: Email/set { update: { id: { keywords: { "$seen": true } } } }
|
||||||
|
If stateMismatch → clear checkpoint → full re-sync
|
||||||
|
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
pending_changes row deleted on success
|
||||||
|
(on permanent error: retry count incremented; evicted after 5 failures)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. IMAP sync loop
|
||||||
|
|
||||||
|
The IMAP loop runs one coroutine per account (`_AccountSync`):
|
||||||
|
|
||||||
|
```
|
||||||
|
start()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[forever loop]
|
||||||
|
├─ flushPendingChanges() ← drain outbound queue first
|
||||||
|
├─ syncMailboxes() ← detect new/removed mailboxes
|
||||||
|
├─ for each mailbox:
|
||||||
|
│ syncEmails() ← incremental: fetch only UIDs > lastUid
|
||||||
|
│ deletion reconciliation: remove rows
|
||||||
|
│ whose UID is absent from the server
|
||||||
|
└─ _idle() ← IMAP IDLE for up to 25 min (RFC 2177)
|
||||||
|
│ Wakes on: server EXISTS/EXPUNGE/FLAGS
|
||||||
|
│ or syncNow() signal from UI
|
||||||
|
└─ repeat
|
||||||
|
```
|
||||||
|
|
||||||
|
**Incremental sync checkpoint** — `sync_state` table stores `(accountId, mailbox, lastUid, uidValidity)`.
|
||||||
|
On each run, only UIDs greater than `lastUid` are fetched. If `uidValidity` changes the full
|
||||||
|
folder is re-scanned and the checkpoint is reset.
|
||||||
|
|
||||||
|
**IDLE cap** — IDLE sessions are limited to 25 minutes per the RFC. The loop also wakes
|
||||||
|
immediately if `syncNow()` is called (e.g. user pulls-to-refresh).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. JMAP sync loop
|
||||||
|
|
||||||
|
The JMAP loop (`_JmapAccountSync`) follows a similar structure but uses HTTP:
|
||||||
|
|
||||||
|
```
|
||||||
|
start()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[forever loop]
|
||||||
|
├─ flushPendingChanges() ← Email/set for queued mutations
|
||||||
|
├─ syncMailboxes() ← Mailbox/get or Mailbox/changes
|
||||||
|
├─ for each mailbox:
|
||||||
|
│ syncEmails() ← Email/query + Email/get (first run)
|
||||||
|
│ Email/changes (subsequent runs, state token)
|
||||||
|
└─ _wait()
|
||||||
|
├─ If server advertises eventSourceUrl: subscribe to SSE push
|
||||||
|
│ wake on "Email" change event
|
||||||
|
└─ Otherwise: sleep 30 s (poll fallback)
|
||||||
|
```
|
||||||
|
|
||||||
|
**State tokens** — each `Mailbox/changes` / `Email/changes` call uses the server-provided
|
||||||
|
`state` token stored in `sync_state`. A `stateMismatch` error clears the token and triggers
|
||||||
|
a full re-fetch.
|
||||||
|
|
||||||
|
**JMAP send** — outgoing mail uses `EmailSubmission/set` when the server advertises the
|
||||||
|
`urn:ietf:params:jmap:submission` capability; falls back to SMTP otherwise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Exponential backoff
|
||||||
|
|
||||||
|
Both loops share the same backoff policy:
|
||||||
|
|
||||||
|
| Outcome | Backoff |
|
||||||
|
|---------|---------|
|
||||||
|
| Sync succeeded | Reset to 5 s |
|
||||||
|
| Network / server error | Double previous backoff, capped at 900 s (15 min) |
|
||||||
|
|
||||||
|
The backoff counter (`_backoffSeconds`) is per-account and per-process; it resets to 5 s
|
||||||
|
on the next successful cycle.
|
||||||
|
|
||||||
|
The last error message is written to `sync_log` and surfaced in the UI via
|
||||||
|
`syncLastErrorProvider` (the red `MaterialBanner` in the email list).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Pending-change queue
|
||||||
|
|
||||||
|
`pending_changes` is a protocol-agnostic table that stores every outbound mutation before it
|
||||||
|
reaches the server:
|
||||||
|
|
||||||
|
| Column | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `id` | Auto-increment primary key |
|
||||||
|
| `email_id` | The email being mutated |
|
||||||
|
| `type` | `flag_seen`, `flag_flagged`, `move`, `delete`, `snooze` |
|
||||||
|
| `payload` | JSON-encoded protocol-specific arguments |
|
||||||
|
| `retry_count` | Incremented on each failed flush attempt |
|
||||||
|
| `created_at` | For ordering and debug |
|
||||||
|
|
||||||
|
**Optimistic UI** — every mutation writes the local change first, then inserts into
|
||||||
|
`pending_changes`. The Drift reactive stream delivers the update to the UI before
|
||||||
|
the network round-trip completes.
|
||||||
|
|
||||||
|
**Conflict resolution** — the server always wins. On the next sync cycle the server's
|
||||||
|
state overwrites local rows. Outbound mutations are retried up to 5 times; after that
|
||||||
|
they are evicted and a `FailedMutation` record is created. Permanent per-item JMAP
|
||||||
|
errors (`notFound`, `forbidden`) skip the retry counter and evict immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Undo and cancel
|
||||||
|
|
||||||
|
When the user triggers an undoable action the UI calls:
|
||||||
|
```
|
||||||
|
ref.read(undoServiceProvider.notifier).pushAction(UndoAction(...))
|
||||||
|
```
|
||||||
|
|
||||||
|
`UndoService` persists the action to the `undo_actions` table (max 10 entries, FIFO).
|
||||||
|
A `SnackBar` with an **Undo** button appears for a few seconds.
|
||||||
|
|
||||||
|
When the user taps Undo, `UndoService.undo()` executes this sequence for each affected email:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. cancelPendingChange(id, originalType)
|
||||||
|
└─ Deletes the pending_changes row if it has not been flushed yet.
|
||||||
|
Returns true if cancelled, false if the server already processed it.
|
||||||
|
|
||||||
|
2. If the email row was hard-deleted (DELETE action):
|
||||||
|
restoreEmails([original])
|
||||||
|
└─ Re-inserts the row with its pre-deletion state,
|
||||||
|
placed in the correct mailbox (source if cancelled, dest otherwise).
|
||||||
|
|
||||||
|
3. moveEmail(id, sourceMailboxPath)
|
||||||
|
└─ Optimistic local move back to the original folder.
|
||||||
|
If step 1 returned false (already sent to server), this enqueues
|
||||||
|
a reverse-move in pending_changes so the server move is undone too.
|
||||||
|
|
||||||
|
4. If step 1 returned true (cancelled before flush):
|
||||||
|
cancelPendingChange(id, 'move')
|
||||||
|
└─ The reverse-move from step 3 is redundant; remove it.
|
||||||
|
```
|
||||||
|
|
||||||
|
The net result is: if the mutation was still in the queue it is silently cancelled with no
|
||||||
|
server round-trip; if it had already been flushed, a compensating move is queued.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Key invariants
|
||||||
|
|
||||||
|
- **Order**: pending changes are flushed before syncing. This prevents the server from
|
||||||
|
overwriting an optimistic local state that the server hasn't seen yet.
|
||||||
|
- **Idempotency**: `flushPendingChanges` is safe to call multiple times. Each row is
|
||||||
|
deleted only after the server acknowledges the change.
|
||||||
|
- **No silent data loss**: permanent server errors surface as `FailedMutation` records
|
||||||
|
visible in the UI (Settings → Failed mutations).
|
||||||
|
- **UI layer isolation**: `lib/ui/` never imports `lib/data/`; all interaction goes
|
||||||
|
through `core/` interfaces. The `check-layers` Taskfile task enforces this.
|
||||||
+291
-23
@@ -1,6 +1,9 @@
|
|||||||
version: "3"
|
version: "3"
|
||||||
silent: true
|
silent: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
default:
|
default:
|
||||||
desc: Run all checks (analyze + unit tests + widget tests + integration, in parallel)
|
desc: Run all checks (analyze + unit tests + widget tests + integration, in parallel)
|
||||||
@@ -122,6 +125,16 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- fvm dart format lib test
|
- fvm dart format lib test
|
||||||
|
|
||||||
|
check-mocks:
|
||||||
|
desc: Fail if any *.mocks.dart file is out of date (re-runs build_runner)
|
||||||
|
deps: [_preflight, _pub-get]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- test/**/*.dart
|
||||||
|
- pubspec.yaml
|
||||||
|
cmds:
|
||||||
|
- scripts/check_mocks_fresh.sh
|
||||||
|
|
||||||
analyze-fix:
|
analyze-fix:
|
||||||
desc: Auto-fix lint issues with dart fix --apply
|
desc: Auto-fix lint issues with dart fix --apply
|
||||||
deps: [_preflight]
|
deps: [_preflight]
|
||||||
@@ -161,23 +174,146 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- fvm flutter test
|
- fvm flutter test
|
||||||
|
|
||||||
integration:
|
test-backend:
|
||||||
desc: Integration tests against a local Stalwart mail server
|
desc: Backend tests against a local Stalwart mail server (via Dagger)
|
||||||
deps: [_flutter-check]
|
|
||||||
sources:
|
|
||||||
- lib/**/*.dart
|
|
||||||
- test/integration/**/*.dart
|
|
||||||
cmds:
|
cmds:
|
||||||
- stalwart-dev/test.sh
|
- dagger call --progress=plain -q -m ci --source=. test-backend
|
||||||
|
|
||||||
integration-ui:
|
integration-ui:
|
||||||
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed
|
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
|
||||||
deps: [_preflight, _linux-deps-check, _pub-get]
|
|
||||||
sources:
|
|
||||||
- lib/**/*.dart
|
|
||||||
- integration_test/app_e2e_test.dart
|
|
||||||
cmds:
|
cmds:
|
||||||
- stalwart-dev/integration_ui_test.sh
|
- dagger call --progress=plain -q -m ci --source=. test-integration
|
||||||
|
|
||||||
|
sync-reliability:
|
||||||
|
desc: Run sync reliability runner (via Dagger)
|
||||||
|
cmds:
|
||||||
|
- dagger call --progress=plain -q -m ci --source=. test-sync-reliability
|
||||||
|
|
||||||
|
test-android-firebase:
|
||||||
|
desc: Build Android debug APKs and run instrumented tests on Firebase Test Lab (via Dagger)
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY"
|
||||||
|
msg: "FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY is not set"
|
||||||
|
- sh: test -n "$FIREBASE_PROJECT_ID"
|
||||||
|
msg: "FIREBASE_PROJECT_ID is not set"
|
||||||
|
cmds:
|
||||||
|
- scripts/run_firebase_test.sh
|
||||||
|
|
||||||
|
ci-graph:
|
||||||
|
desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer
|
||||||
|
cmds:
|
||||||
|
- dagger call --progress=plain -q -m ci --source=. graph
|
||||||
|
|
||||||
|
stalwart:
|
||||||
|
desc: Start a Stalwart instance for local development (via Dagger)
|
||||||
|
cmds:
|
||||||
|
- echo "Starting Stalwart on default ports (JMAP=8080, IMAP=1430, SMTP=1025, SIEVE=4190)"
|
||||||
|
- dagger call --progress=plain -q -m ci --source=. stalwart up --ports 8080:8080 --ports 1430:1430 --ports 1025:1025 --ports 4190:4190
|
||||||
|
|
||||||
|
deploy-linux:
|
||||||
|
desc: Build and deploy Linux release via Dagger
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||||
|
msg: "SSH_PRIVATE_KEY is not set"
|
||||||
|
cmds:
|
||||||
|
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||||
|
|
||||||
|
build-android-bundle:
|
||||||
|
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
||||||
|
cmds:
|
||||||
|
- mkdir -p build/app/outputs/bundle/release
|
||||||
|
- dagger call --progress=plain -q -m ci --source=. build-android-release -o build/app/outputs/bundle/release/app-release.aab
|
||||||
|
|
||||||
|
upload-android-bundle:
|
||||||
|
desc: Upload AAB from build/ to Play Store via Dagger
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
||||||
|
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
||||||
|
- sh: test -f build/app/outputs/bundle/release/app-release.aab
|
||||||
|
msg: "AAB not found — run build-android-bundle first"
|
||||||
|
cmds:
|
||||||
|
- dagger call --progress=plain -q -m ci --source=. upload-to-play-store --aab build/app/outputs/bundle/release/app-release.aab --play-store-config env:PLAY_STORE_CONFIG_JSON
|
||||||
|
|
||||||
|
publish-android:
|
||||||
|
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
||||||
|
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
||||||
|
- sh: test -n "$ANDROID_KEYSTORE_BASE64"
|
||||||
|
msg: "ANDROID_KEYSTORE_BASE64 is not set"
|
||||||
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
|
cmds:
|
||||||
|
- dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD
|
||||||
|
|
||||||
|
deploy-apk:
|
||||||
|
desc: Build and deploy Android APK via Dagger
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||||
|
msg: "SSH_PRIVATE_KEY is not set"
|
||||||
|
- sh: test -n "$ANDROID_KEYSTORE_BASE64"
|
||||||
|
msg: "ANDROID_KEYSTORE_BASE64 is not set"
|
||||||
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
|
cmds:
|
||||||
|
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
|
||||||
|
|
||||||
|
publish-website:
|
||||||
|
desc: Build and publish website via Dagger
|
||||||
|
cmds:
|
||||||
|
- dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key file:$HOME/.ssh/id_ed25519 --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST"
|
||||||
|
|
||||||
|
check-dagger:
|
||||||
|
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
DAGGER_OUT=$(mktemp)
|
||||||
|
RC_FILE=$(mktemp)
|
||||||
|
_ts() { date -u '+[%H:%M:%S]'; }
|
||||||
|
run_dagger() {
|
||||||
|
: > "$DAGGER_OUT"; : > "$RC_FILE"
|
||||||
|
{ timeout --kill-after=10 600 "$@"; echo $? > "$RC_FILE"; } 2>&1 | tee "$DAGGER_OUT"
|
||||||
|
RC=$(cat "$RC_FILE" 2>/dev/null || echo 1)
|
||||||
|
if [ "$RC" -eq 124 ] && grep -q "All tests passed" "$DAGGER_OUT"; then
|
||||||
|
echo "$(_ts) dagger: hung in teardown after success; treating as exit 0." >&2
|
||||||
|
RC=0
|
||||||
|
fi
|
||||||
|
return "$RC"
|
||||||
|
}
|
||||||
|
retry_dagger() {
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
run_dagger "$@" && return 0
|
||||||
|
RC=$?
|
||||||
|
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused" "$DAGGER_OUT"; then
|
||||||
|
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
||||||
|
else
|
||||||
|
return "$RC"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
retry_dagger dagger call --progress=plain -q -m ci --source=. check
|
||||||
|
RC=$?
|
||||||
|
rm -f "$DAGGER_OUT" "$RC_FILE"
|
||||||
|
exit $RC
|
||||||
|
fi
|
||||||
|
PORTFILE=$(mktemp)
|
||||||
|
python3 ci/otel-receiver.py --port-file="$PORTFILE" &
|
||||||
|
RECV_PID=$!
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
until [ -s "$PORTFILE" ]; do sleep 0.05; done
|
||||||
|
PORT=$(cat "$PORTFILE")
|
||||||
|
retry_dagger env \
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
|
||||||
|
OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" \
|
||||||
|
dagger call --progress=plain -q -m ci --source=. check
|
||||||
|
RC=$?
|
||||||
|
curl -sf "http://127.0.0.1:$PORT/shutdown" >/dev/null 2>&1 || true
|
||||||
|
wait "$RECV_PID" 2>/dev/null || true
|
||||||
|
exit $RC
|
||||||
|
|
||||||
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)
|
||||||
@@ -216,7 +352,79 @@ tasks:
|
|||||||
generates:
|
generates:
|
||||||
- build/linux/x64/release/bundle/sharedinbox
|
- build/linux/x64/release/bundle/sharedinbox
|
||||||
cmds:
|
cmds:
|
||||||
- scripts/silent_on_success.sh fvm flutter build linux --release --no-pub
|
- scripts/silent_on_success.sh fvm flutter build linux --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD)
|
||||||
|
|
||||||
|
deploy-linux-to-server:
|
||||||
|
desc: Package and deploy the Linux release bundle to the server, update latest.json
|
||||||
|
deps: [build-linux-release]
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_USER"
|
||||||
|
msg: "SSH_USER is not set"
|
||||||
|
- sh: test -n "$SSH_HOST"
|
||||||
|
msg: "SSH_HOST is not set"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
HASH=$(git rev-parse --short HEAD)
|
||||||
|
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||||
|
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||||
|
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
|
||||||
|
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
|
||||||
|
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||||
|
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
||||||
|
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
|
||||||
|
# Merge with any existing latest.json so we don't overwrite the windows key
|
||||||
|
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||||
|
WINDOWS_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" 2>/dev/null || true)
|
||||||
|
if [ -n "$WINDOWS_URL" ]; then
|
||||||
|
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
|
||||||
|
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
|
else
|
||||||
|
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
||||||
|
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
|
fi
|
||||||
|
echo "Uploaded $TARBALL and updated latest.json"
|
||||||
|
|
||||||
|
build-windows-release:
|
||||||
|
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
|
||||||
|
deps: [_pub-get, generate-changelog]
|
||||||
|
method: timestamp
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- windows/**/*
|
||||||
|
- pubspec.yaml
|
||||||
|
generates:
|
||||||
|
- build/windows/x64/runner/Release/sharedinbox.exe
|
||||||
|
cmds:
|
||||||
|
- fvm flutter build windows --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD)
|
||||||
|
|
||||||
|
deploy-windows-to-server:
|
||||||
|
desc: Package and deploy the Windows release bundle to the server, update latest.json
|
||||||
|
deps: [build-windows-release]
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_USER"
|
||||||
|
msg: "SSH_USER is not set"
|
||||||
|
- sh: test -n "$SSH_HOST"
|
||||||
|
msg: "SSH_HOST is not set"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
HASH=$(git rev-parse --short HEAD)
|
||||||
|
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||||
|
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||||
|
ZIPFILE="sharedinbox-windows-x64-$HASH.zip"
|
||||||
|
cd build/windows/x64/runner && zip -r /tmp/$ZIPFILE Release/ && cd -
|
||||||
|
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||||
|
scp -o StrictHostKeyChecking=no /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
|
||||||
|
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$ZIPFILE"
|
||||||
|
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||||
|
LINUX_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('linux',''))" 2>/dev/null || true)
|
||||||
|
if [ -n "$LINUX_URL" ]; then
|
||||||
|
echo "{\"version\":\"$HASH\",\"linux\":\"$LINUX_URL\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
||||||
|
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
|
else
|
||||||
|
echo "{\"version\":\"$HASH\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
||||||
|
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
|
fi
|
||||||
|
echo "Uploaded $ZIPFILE and updated latest.json"
|
||||||
|
|
||||||
|
|
||||||
_android-avd-setup:
|
_android-avd-setup:
|
||||||
@@ -266,19 +474,19 @@ tasks:
|
|||||||
generates:
|
generates:
|
||||||
- build/app/outputs/flutter-apk/app-release.apk
|
- build/app/outputs/flutter-apk/app-release.apk
|
||||||
cmds:
|
cmds:
|
||||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||||
|
|
||||||
deploy-android-bundle:
|
deploy-android-bundle:
|
||||||
desc: Build release AAB and upload to Play Store internal track
|
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
||||||
deps: [build-android-bundle]
|
deps: [build-android-bundle-local]
|
||||||
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"
|
||||||
cmds:
|
cmds:
|
||||||
- python3 scripts/deploy_playstore.py
|
- python3 scripts/deploy_playstore.py
|
||||||
|
|
||||||
build-android-bundle:
|
build-android-bundle-local:
|
||||||
desc: Build a release App Bundle (AAB)
|
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
||||||
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
||||||
method: timestamp
|
method: timestamp
|
||||||
sources:
|
sources:
|
||||||
@@ -288,7 +496,7 @@ tasks:
|
|||||||
generates:
|
generates:
|
||||||
- build/app/outputs/bundle/release/app-release.aab
|
- build/app/outputs/bundle/release/app-release.aab
|
||||||
cmds:
|
cmds:
|
||||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build appbundle --release --no-pub --build-number $(date +%s) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build appbundle --release --no-pub --build-number $(date +%s) --build-name $(date +%y%m%d-%H%M) --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||||
|
|
||||||
deploy-android:
|
deploy-android:
|
||||||
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
||||||
@@ -331,6 +539,12 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- fvm dart run scripts/check_coverage.dart
|
- fvm dart run scripts/check_coverage.dart
|
||||||
|
|
||||||
|
check-coverage:
|
||||||
|
desc: Run unit+widget tests with coverage, then fail if the gate is not met
|
||||||
|
deps: [test]
|
||||||
|
cmds:
|
||||||
|
- task: coverage
|
||||||
|
|
||||||
website-dev:
|
website-dev:
|
||||||
desc: Run Hugo development server
|
desc: Run Hugo development server
|
||||||
cmds:
|
cmds:
|
||||||
@@ -350,19 +564,73 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- scripts/website-verify.sh
|
- scripts/website-verify.sh
|
||||||
|
|
||||||
|
deploy-apk-to-server:
|
||||||
|
desc: SCP the release APK to the server at public_html/builds/YYYY/MM/DD/
|
||||||
|
deps: [build-android]
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_USER"
|
||||||
|
msg: "SSH_USER is not set"
|
||||||
|
- sh: test -n "$SSH_HOST"
|
||||||
|
msg: "SSH_HOST is not set"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
HASH=$(git rev-parse --short HEAD)
|
||||||
|
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||||
|
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||||
|
APK_NAME="sharedinbox-mua-$HASH.apk"
|
||||||
|
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||||
|
scp -o StrictHostKeyChecking=no \
|
||||||
|
build/app/outputs/flutter-apk/app-release.apk \
|
||||||
|
"$SSH_USER@$SSH_HOST:$REMOTE_DIR/$APK_NAME"
|
||||||
|
echo "Uploaded $APK_NAME to $REMOTE_DIR"
|
||||||
|
|
||||||
|
generate-build-history:
|
||||||
|
desc: Generate Hugo build-history pages from Linux and Android builds on the server
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_USER"
|
||||||
|
msg: "SSH_USER is not set"
|
||||||
|
- sh: test -n "$SSH_HOST"
|
||||||
|
msg: "SSH_HOST is not set"
|
||||||
|
cmds:
|
||||||
|
- python3 scripts/generate_build_history.py
|
||||||
|
|
||||||
|
website-publish:
|
||||||
|
desc: Generate build history, build Hugo site, and rsync to server (requires SSH_USER + SSH_HOST)
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_USER"
|
||||||
|
msg: "SSH_USER is not set"
|
||||||
|
- sh: test -n "$SSH_HOST"
|
||||||
|
msg: "SSH_HOST is not set"
|
||||||
|
cmds:
|
||||||
|
- task: generate-build-history
|
||||||
|
- task: website-deploy
|
||||||
|
|
||||||
website-deploy:
|
website-deploy:
|
||||||
desc: Deploy the website via rsync to public_html
|
desc: Deploy the website via rsync to public_html
|
||||||
deps: [website-build]
|
deps: [website-build]
|
||||||
cmds:
|
cmds:
|
||||||
- |
|
- |
|
||||||
rsync -avz --delete \
|
rsync -avz --delete \
|
||||||
|
--exclude='*.apk' \
|
||||||
|
--exclude='*.tar.gz' \
|
||||||
-e "ssh -o StrictHostKeyChecking=no" \
|
-e "ssh -o StrictHostKeyChecking=no" \
|
||||||
website/public/ \
|
website/public/ \
|
||||||
${SSH_USER}@${SSH_HOST}:public_html/
|
${SSH_USER}@${SSH_HOST}:public_html/
|
||||||
|
|
||||||
check-fast:
|
check-fast:
|
||||||
desc: Pre-commit checks — analyze + unit tests + widget tests (no build, no integration)
|
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
|
||||||
deps: [analyze, test, check-hygiene]
|
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks]
|
||||||
|
|
||||||
|
check-layers:
|
||||||
|
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
VIOLATIONS=$(grep -rn "package:sharedinbox/data/" lib/ui/ 2>/dev/null || true)
|
||||||
|
if [ -n "$VIOLATIONS" ]; then
|
||||||
|
echo "ERROR: UI layer imports data layer (only core/ interfaces are allowed from ui/):"
|
||||||
|
echo "$VIOLATIONS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
check-hygiene:
|
check-hygiene:
|
||||||
desc: Verify that no forbidden files (like home dir config) are tracked
|
desc: Verify that no forbidden files (like home dir config) are tracked
|
||||||
@@ -382,7 +650,7 @@ tasks:
|
|||||||
internal: true
|
internal: true
|
||||||
run: once
|
run: once
|
||||||
cmds:
|
cmds:
|
||||||
- task: integration
|
- task: test-backend
|
||||||
- task: integration-ui
|
- task: integration-ui
|
||||||
|
|
||||||
ci-logs:
|
ci-logs:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ android {
|
|||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
@@ -35,7 +36,7 @@ android {
|
|||||||
applicationId = "de.sharedinbox.mua"
|
applicationId = "de.sharedinbox.mua"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = 23
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
@@ -65,6 +66,8 @@ flutter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26.
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.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,5 +1,10 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
<application
|
<application
|
||||||
android:label="sharedinbox"
|
android:label="sharedinbox"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
org.gradle.welcome=never
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/dagger.gen.go linguist-generated
|
||||||
|
/internal/dagger/** linguist-generated
|
||||||
|
/internal/querybuilder/** linguist-generated
|
||||||
|
/internal/telemetry/** linguist-generated
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/dagger.gen.go
|
||||||
|
/internal/dagger
|
||||||
|
/internal/querybuilder
|
||||||
|
/internal/telemetry
|
||||||
|
/.env
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "ci",
|
||||||
|
"engineVersion": "v0.20.8",
|
||||||
|
"sdk": {
|
||||||
|
"source": "go"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
module dagger/ci
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72
|
||||||
|
github.com/Khan/genqlient v0.8.1
|
||||||
|
github.com/dagger/otel-go v1.43.0
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.33
|
||||||
|
go.opentelemetry.io/otel v1.43.0
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/99designs/gqlgen v0.17.90 // indirect
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||||
|
github.com/sosodev/duration v1.4.0 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.17.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.17.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/log v0.17.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.17.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||||
|
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||||
|
golang.org/x/net v0.52.0 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
|
golang.org/x/text v0.35.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||||
|
google.golang.org/grpc v1.79.3 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0
|
||||||
|
|
||||||
|
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0
|
||||||
|
|
||||||
|
replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.16.0
|
||||||
|
|
||||||
|
replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.16.0
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72 h1:s39e07WvaUU6tLhpojK8ZEIoIbOSn5hHOJra0waenxQ=
|
||||||
|
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72/go.mod h1:ZXg8+pQZaZUC8rAw4V/gPP8aKvKARIJZ+pfcV+RC1es=
|
||||||
|
github.com/99designs/gqlgen v0.17.90 h1:wSv6blm/PoplU6QoNw83EcQpNtC0HX3/+44vITJOzpk=
|
||||||
|
github.com/99designs/gqlgen v0.17.90/go.mod h1:GqYrEwYsqCG8VaOsq2kJUCUKwAE1T+u2i+Nj7NtXiVI=
|
||||||
|
github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs=
|
||||||
|
github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU=
|
||||||
|
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||||
|
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||||
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||||
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/dagger/otel-go v1.43.0 h1:AYCnAamWmxtSxigWPTgC+8EWqiWPcDZEegh8y05gdJ8=
|
||||||
|
github.com/dagger/otel-go v1.43.0/go.mod h1:83CTuXi70zcx1kaym5buqmb7RNzg1E9dEiQSFyLbLdU=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
|
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||||
|
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
|
||||||
|
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||||
|
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 h1:VO3BL6OZXRQ1yQc8W6EVfJzINeJ35BkiHx4MYfoQf44=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0/go.mod h1:qRDnJ2nv3CQXMK2HUd9K9VtvedsPAce3S+/4LZHjX/s=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 h1:MMrOAN8H1FrvDyq9UJ4lu5/+ss49Qgfgb7Zpm0m8ABo=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0/go.mod h1:Na+2NNASJtF+uT4NxDe0G+NQb+bUgdPDfwxY/6JmS/c=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
|
||||||
|
go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
|
||||||
|
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
|
||||||
|
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||||
|
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
|
||||||
|
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
|
||||||
|
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
|
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||||
|
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
+848
@@ -0,0 +1,848 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"dagger/ci/internal/dagger"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
)
|
||||||
|
|
||||||
|
// patchAabScript patches android:versionCode in an AAB's compiled manifest proto.
|
||||||
|
// It strips META-INF/ (old signature) and repacks the ZIP. No external dependencies.
|
||||||
|
const patchAabScript = `#!/usr/bin/env python3
|
||||||
|
import sys, zipfile
|
||||||
|
|
||||||
|
MANIFEST = "base/manifest/AndroidManifest.xml"
|
||||||
|
VERSION_CODE_RID = 0x0101021b
|
||||||
|
|
||||||
|
def _vr(b, p):
|
||||||
|
n = s = 0
|
||||||
|
while True:
|
||||||
|
c = b[p]; p += 1; n |= (c & 127) << s
|
||||||
|
if not (c & 128): return n, p
|
||||||
|
s += 7
|
||||||
|
|
||||||
|
def _ve(n):
|
||||||
|
r = []
|
||||||
|
while n > 127: r.append((n & 127) | 128); n >>= 7
|
||||||
|
return bytes(r + [n])
|
||||||
|
|
||||||
|
def _parse(d):
|
||||||
|
p = 0
|
||||||
|
while p < len(d):
|
||||||
|
tag, p = _vr(d, p); fn, wt = tag >> 3, tag & 7
|
||||||
|
if wt == 0: v, p = _vr(d, p); yield fn, 0, v
|
||||||
|
elif wt == 2: ln, p = _vr(d, p); yield fn, 2, d[p:p+ln]; p += ln
|
||||||
|
elif wt == 5: yield fn, 5, d[p:p+4]; p += 4 # fixed32
|
||||||
|
elif wt == 1: yield fn, 1, d[p:p+8]; p += 8 # fixed64
|
||||||
|
else: raise ValueError(f"wire type {wt}")
|
||||||
|
|
||||||
|
def _enc(fn, wt, v):
|
||||||
|
t = _ve((fn << 3) | wt)
|
||||||
|
if wt == 0: return t + _ve(v)
|
||||||
|
if wt in (1, 5): return t + v # fixed-width, pass bytes as-is
|
||||||
|
return t + _ve(len(v)) + v
|
||||||
|
|
||||||
|
def _patch_prim(d, vc):
|
||||||
|
# Patch int_decimal_value (field 6) or int_hexadecimal_value (field 7),
|
||||||
|
# whichever is present — AAPT2 may use either.
|
||||||
|
out = bytearray()
|
||||||
|
for fn, wt, v in _parse(d):
|
||||||
|
out += _enc(fn, 0, vc) if (fn in (6, 7) and wt == 0) else _enc(fn, wt, v)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
def _patch_item(d, vc):
|
||||||
|
out = bytearray()
|
||||||
|
for fn, wt, v in _parse(d):
|
||||||
|
out += _enc(7, 2, _patch_prim(v, vc)) if fn == 7 else _enc(fn, wt, v)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
def _has_rid(d):
|
||||||
|
return any(fn == 5 and wt == 0 and v == VERSION_CODE_RID for fn, wt, v in _parse(d))
|
||||||
|
|
||||||
|
def _patch_attr(d, vc):
|
||||||
|
out = bytearray()
|
||||||
|
for fn, wt, v in _parse(d):
|
||||||
|
if fn == 3 and wt == 2: out += _enc(3, 2, str(vc).encode())
|
||||||
|
elif fn == 6 and wt == 2: out += _enc(6, 2, _patch_item(v, vc))
|
||||||
|
else: out += _enc(fn, wt, v)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
def _patch_elem(d, vc):
|
||||||
|
out = bytearray()
|
||||||
|
for fn, wt, v in _parse(d):
|
||||||
|
out += _enc(4, 2, _patch_attr(v, vc)) if (fn == 4 and _has_rid(v)) else _enc(fn, wt, v)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
def _patch_node(d, vc):
|
||||||
|
out = bytearray()
|
||||||
|
for fn, wt, v in _parse(d):
|
||||||
|
out += _enc(1, 2, _patch_elem(v, vc)) if fn == 1 else _enc(fn, wt, v)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
def _dump_proto(d, depth=0, limit=3):
|
||||||
|
"""Print proto field structure for debugging."""
|
||||||
|
pad = " " * depth
|
||||||
|
for fn, wt, v in _parse(d):
|
||||||
|
if wt == 0:
|
||||||
|
print(f"{pad}[{fn}] varint={v} (0x{v:x})")
|
||||||
|
elif wt == 2:
|
||||||
|
print(f"{pad}[{fn}] bytes len={len(v)}")
|
||||||
|
if depth < limit:
|
||||||
|
_dump_proto(v, depth + 1, limit)
|
||||||
|
elif wt == 5:
|
||||||
|
print(f"{pad}[{fn}] fixed32={v.hex()}")
|
||||||
|
elif wt == 1:
|
||||||
|
print(f"{pad}[{fn}] fixed64={v.hex()}")
|
||||||
|
|
||||||
|
def _read_vc_from_node(d):
|
||||||
|
"""Read versionCode from XmlNode proto bytes. Returns int or None."""
|
||||||
|
for fn, wt, v in _parse(d):
|
||||||
|
if fn == 1 and wt == 2: # XmlElement
|
||||||
|
for efn, ewt, attr in _parse(v):
|
||||||
|
if efn == 4 and ewt == 2 and _has_rid(attr): # XmlAttribute with versionCode RID
|
||||||
|
for afn, awt, item in _parse(attr):
|
||||||
|
if afn == 6 and awt == 2: # compiled_value (Item)
|
||||||
|
for ifn, iwt, prim in _parse(item):
|
||||||
|
if ifn == 7 and iwt == 2: # prim (Primitive)
|
||||||
|
for pfn, pwt, pv in _parse(prim):
|
||||||
|
if pfn in (6, 7) and pwt == 0:
|
||||||
|
return pv
|
||||||
|
return None
|
||||||
|
|
||||||
|
def patch(src, dst, vc):
|
||||||
|
with zipfile.ZipFile(src) as z:
|
||||||
|
mf = z.read(MANIFEST)
|
||||||
|
|
||||||
|
orig_vc = _read_vc_from_node(mf)
|
||||||
|
if orig_vc is None:
|
||||||
|
print("DEBUG: could not find versionCode — dumping manifest proto structure:")
|
||||||
|
_dump_proto(mf, limit=4)
|
||||||
|
sys.exit(f"ERROR: versionCode not found in {MANIFEST}")
|
||||||
|
print(f"Original versionCode in manifest: {orig_vc}")
|
||||||
|
|
||||||
|
patched = _patch_node(mf, vc)
|
||||||
|
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, 'w') as zout:
|
||||||
|
for info in zin.infolist():
|
||||||
|
if info.filename.startswith('META-INF/'):
|
||||||
|
continue # strip old signature; jarsigner re-signs after
|
||||||
|
d = patched if info.filename == MANIFEST else zin.read(info.filename)
|
||||||
|
zi = zipfile.ZipInfo(info.filename, info.date_time)
|
||||||
|
zi.compress_type = info.compress_type
|
||||||
|
zi.external_attr = info.external_attr
|
||||||
|
zout.writestr(zi, d)
|
||||||
|
|
||||||
|
# Verify the patch actually took effect
|
||||||
|
with zipfile.ZipFile(dst) as z:
|
||||||
|
actual = _read_vc_from_node(z.read(MANIFEST))
|
||||||
|
if actual != vc:
|
||||||
|
sys.exit(f"ERROR: versionCode patch failed — wrote {vc} but read back {actual} (original was {orig_vc})")
|
||||||
|
print(f"versionCode={actual} -> {dst}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 4:
|
||||||
|
sys.exit(f"usage: {sys.argv[0]} in.aab out.aab versionCode")
|
||||||
|
patch(sys.argv[1], sys.argv[2], int(sys.argv[3]))
|
||||||
|
`
|
||||||
|
|
||||||
|
type Ci struct {
|
||||||
|
Source *dagger.Directory
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(
|
||||||
|
// +defaultPath=".."
|
||||||
|
source *dagger.Directory,
|
||||||
|
) *Ci {
|
||||||
|
return &Ci{
|
||||||
|
Source: source.Filter(dagger.DirectoryFilterOpts{
|
||||||
|
Include: []string{
|
||||||
|
"lib/",
|
||||||
|
"test/",
|
||||||
|
"assets/",
|
||||||
|
"scripts/",
|
||||||
|
"pubspec.yaml",
|
||||||
|
"pubspec.lock",
|
||||||
|
"analysis_options.yaml",
|
||||||
|
"linux/",
|
||||||
|
"android/",
|
||||||
|
"integration_test/",
|
||||||
|
"drift_schemas/",
|
||||||
|
"stalwart-dev/",
|
||||||
|
"website/",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolchain returns the Flutter+Android toolchain without any mutable cache mounts.
|
||||||
|
// Its execution cache key is stable until the image, apt packages, or SDK versions change.
|
||||||
|
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
|
||||||
|
func (m *Ci) toolchain() *dagger.Container {
|
||||||
|
return dag.Container().
|
||||||
|
From("ghcr.io/cirruslabs/flutter:3.41.6").
|
||||||
|
WithExec([]string{"apt-get", "-qq", "update"}).
|
||||||
|
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
|
||||||
|
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
||||||
|
WithExec([]string{"/bin/sh", "-c",
|
||||||
|
`flutter_dir=$(dirname $(dirname $(which flutter))); ` +
|
||||||
|
`chown -R ci:ci "$flutter_dir"; ` +
|
||||||
|
`[ -n "$ANDROID_HOME" ] && chown -R ci:ci "$ANDROID_HOME" || true; ` +
|
||||||
|
`mkdir -p /src && chown ci:ci /src`}).
|
||||||
|
WithEnvVariable("PUB_CACHE", "/home/ci/.pub-cache").
|
||||||
|
WithEnvVariable("HOME", "/home/ci").
|
||||||
|
WithUser("ci").
|
||||||
|
WithExec([]string{"/bin/sh", "-c",
|
||||||
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
|
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base is the Flutter toolchain container with mutable cache mounts attached.
|
||||||
|
// Use for Android/Gradle builds that need the Gradle cache.
|
||||||
|
func (m *Ci) Base() *dagger.Container {
|
||||||
|
return m.toolchain().
|
||||||
|
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// pubGetLayer runs flutter pub get with only pubspec.yaml + pubspec.lock as
|
||||||
|
// inputs, then removes non-deterministic fields from both package_config.json
|
||||||
|
// and .flutter-plugins-dependencies so the snapshot is byte-for-byte stable
|
||||||
|
// across runs. Re-executes only when pubspec.yaml or pubspec.lock changes.
|
||||||
|
// Packages land in the execution-cache snapshot (not a named volume) so that
|
||||||
|
// dagger prune can reclaim space from stale pubspec.lock snapshots.
|
||||||
|
func (m *Ci) pubGetLayer() *dagger.Container {
|
||||||
|
pubspecOnly := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
|
Include: []string{"pubspec.yaml", "pubspec.lock"},
|
||||||
|
})
|
||||||
|
return m.toolchain().
|
||||||
|
WithDirectory("/src", pubspecOnly, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||||
|
WithWorkdir("/src").
|
||||||
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
|
`flutter pub get >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
|
`grep -vE '^[+~><] ' "$tmp" || true`}).
|
||||||
|
WithExec([]string{"python3", "-c",
|
||||||
|
"import json, os\n" +
|
||||||
|
"f='.dart_tool/package_config.json'; d=json.load(open(f)); [d.pop(k,None) for k in ('generated','generatorVersion')]; json.dump(d,open(f,'w'))\n" +
|
||||||
|
"g='.flutter-plugins-dependencies'\n" +
|
||||||
|
"if os.path.exists(g):\n" +
|
||||||
|
" d=json.load(open(g)); d.pop('date_created',None); json.dump(d,open(g,'w'))\n"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// codegenBase runs build_runner on the source subset common to all build
|
||||||
|
// variants (lib/, test/, assets/, pubspec.*), excluding committed generated
|
||||||
|
// files so the cache key is stable. All setup() calls share this single
|
||||||
|
// Dagger cache entry, so build_runner compiles only once per pipeline run.
|
||||||
|
func (m *Ci) codegenBase() *dagger.Container {
|
||||||
|
codegenSrc := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
|
Include: []string{"lib/", "test/", "assets/", "pubspec.yaml", "pubspec.lock"},
|
||||||
|
Exclude: []string{"**/*.g.dart", "**/*.mocks.dart"},
|
||||||
|
})
|
||||||
|
return m.pubGetLayer().
|
||||||
|
WithDirectory("/src", codegenSrc, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||||
|
WithWorkdir("/src").
|
||||||
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
|
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
|
`grep -vE '^\[' "$tmp" || true`})
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup overlays platform-specific source files onto the shared codegen base.
|
||||||
|
// Generated files (*.g.dart, *.mocks.dart) are excluded from the overlay so
|
||||||
|
// the freshly built output from codegenBase() is not overwritten by stale
|
||||||
|
// committed copies.
|
||||||
|
func (m *Ci) setup(src *dagger.Directory) *dagger.Container {
|
||||||
|
return m.codegenBase().
|
||||||
|
WithDirectory("/src", src.Filter(dagger.DirectoryFilterOpts{
|
||||||
|
Exclude: []string{"**/*.g.dart", "**/*.mocks.dart"},
|
||||||
|
}), dagger.ContainerWithDirectoryOpts{Owner: "ci"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup is the exported variant (CLI / Taskfile). Uses the full check source.
|
||||||
|
func (m *Ci) Setup() *dagger.Container {
|
||||||
|
return m.setup(m.checkSrc())
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkSrc is the source subset for static checks and unit tests.
|
||||||
|
func (m *Ci) checkSrc() *dagger.Directory {
|
||||||
|
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
|
Include: []string{"lib/", "test/", "assets/", "pubspec.yaml", "pubspec.lock", "analysis_options.yaml", "scripts/"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// androidSrc is the source subset for Android builds.
|
||||||
|
func (m *Ci) androidSrc() *dagger.Directory {
|
||||||
|
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
|
Include: []string{"lib/", "android/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// firebaseSrc is the source subset for Firebase Test Lab builds (app + instrumented tests).
|
||||||
|
func (m *Ci) firebaseSrc() *dagger.Directory {
|
||||||
|
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
|
Include: []string{"lib/", "android/", "integration_test/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// linuxSrc is the source subset for Linux builds and integration tests.
|
||||||
|
func (m *Ci) linuxSrc() *dagger.Directory {
|
||||||
|
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
|
Include: []string{"lib/", "linux/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// backendSrc is the source subset for IMAP/JMAP backend tests.
|
||||||
|
func (m *Ci) backendSrc() *dagger.Directory {
|
||||||
|
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
|
Include: []string{"lib/", "test/", "assets/", "scripts/", "stalwart-dev/", "pubspec.yaml", "pubspec.lock"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// integrationSrc is the source subset for UI integration tests (runs on Linux desktop).
|
||||||
|
func (m *Ci) integrationSrc() *dagger.Directory {
|
||||||
|
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
|
Include: []string{"lib/", "linux/", "integration_test/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hugo container for website builds
|
||||||
|
func (m *Ci) Hugo() *dagger.Container {
|
||||||
|
return dag.Container().
|
||||||
|
From("alpine:3.21").
|
||||||
|
WithExec([]string{"apk", "--no-cache", "add", "curl", "tar", "libc6-compat", "libstdc++", "gcompat"}).
|
||||||
|
WithExec([]string{"curl", "-sL", "https://github.com/gohugoio/hugo/releases/download/v0.152.2/hugo_extended_0.152.2_linux-amd64.tar.gz", "-o", "/tmp/hugo.tar.gz"}).
|
||||||
|
WithExec([]string{"tar", "-xzf", "/tmp/hugo.tar.gz", "-C", "/usr/local/bin", "hugo"}).
|
||||||
|
WithExec([]string{"rm", "/tmp/hugo.tar.gz"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deploy container for rsync/ssh
|
||||||
|
func (m *Ci) Deployer(sshKey *dagger.Secret) *dagger.Container {
|
||||||
|
return dag.Container().
|
||||||
|
From("alpine:3.21").
|
||||||
|
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
|
||||||
|
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||||
|
WithEnvVariable("RSYNC_RSH", "ssh -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stalwart mail server service for backend and integration tests.
|
||||||
|
func (m *Ci) Stalwart() *dagger.Service {
|
||||||
|
stalwartSrc := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
|
Include: []string{"stalwart-dev/"},
|
||||||
|
})
|
||||||
|
config := stalwartSrc.Directory("stalwart-dev").File("config.toml")
|
||||||
|
|
||||||
|
dataDir := dag.Container().
|
||||||
|
From("alpine:3.21").
|
||||||
|
WithExec([]string{"apk", "add", "--no-cache", "sqlite"}).
|
||||||
|
WithExec([]string{"/bin/sh", "-c", "mkdir -p /tmp/stalwart && chmod 777 /tmp/stalwart"}).
|
||||||
|
WithExec([]string{"sqlite3", "/tmp/stalwart/data.sqlite", "CREATE TABLE IF NOT EXISTS s (k BLOB PRIMARY KEY, v BLOB NOT NULL); INSERT OR REPLACE INTO s VALUES ('version.spam-filter', 'dev');"}).
|
||||||
|
Directory("/tmp/stalwart")
|
||||||
|
|
||||||
|
return dag.Container().
|
||||||
|
From("stalwartlabs/stalwart:v0.14.1").
|
||||||
|
WithFile("/etc/stalwart/config.toml.orig", config).
|
||||||
|
WithExec([]string{"/bin/sh", "-c", "sed -e 's/hostname = \"localhost\"/hostname = \"stalwart\"/' -e 's/bind = \\[\"0.0.0.0:\\([0-9]*\\)\"\\]/bind = [\"0.0.0.0:\\1\", \"[::]:\\1\"]/g' /etc/stalwart/config.toml.orig > /etc/stalwart/config.toml"}).
|
||||||
|
WithDirectory("/tmp/stalwart", dataDir).
|
||||||
|
WithExposedPort(8080). // JMAP
|
||||||
|
WithExposedPort(1430). // IMAP
|
||||||
|
WithExposedPort(1025). // SMTP
|
||||||
|
WithExposedPort(4190). // ManageSieve
|
||||||
|
WithEntrypoint([]string{"stalwart", "--config", "/etc/stalwart/config.toml"}).
|
||||||
|
AsService()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithStalwart binds the Stalwart service and sets test environment variables.
|
||||||
|
func (m *Ci) WithStalwart(container *dagger.Container) *dagger.Container {
|
||||||
|
stalwart := m.Stalwart()
|
||||||
|
return container.
|
||||||
|
WithServiceBinding("stalwart", stalwart).
|
||||||
|
WithEnvVariable("STALWART_IMAP_HOST", "stalwart").
|
||||||
|
WithEnvVariable("STALWART_SMTP_HOST", "stalwart").
|
||||||
|
WithEnvVariable("STALWART_URL", "http://stalwart:8080").
|
||||||
|
WithEnvVariable("STALWART_IMAP_PORT", "1430").
|
||||||
|
WithEnvVariable("STALWART_SMTP_PORT", "1025").
|
||||||
|
WithEnvVariable("STALWART_SIEVE_PORT", "4190").
|
||||||
|
WithEnvVariable("STALWART_USER_B", "alice@example.com").
|
||||||
|
WithEnvVariable("STALWART_PASS_B", "secret").
|
||||||
|
WithEnvVariable("STALWART_USER_C", "bob@example.com").
|
||||||
|
WithEnvVariable("STALWART_PASS_C", "secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckHygiene checks that no forbidden home-directory files are in the source.
|
||||||
|
func (m *Ci) CheckHygiene(ctx context.Context) (string, error) {
|
||||||
|
return m.Base().
|
||||||
|
WithDirectory("/src", m.Source, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||||
|
WithWorkdir("/src").
|
||||||
|
WithExec([]string{"/bin/bash", "-c", "FORBIDDEN=\".ssh .bashrc .config .local .cache .gitconfig .android Android .gradle .pub-cache .dartServer .flutter .dart-cli-completion .atuin .bash_logout .profile .zcompdump .zshrc snap .emulator_console_auth_token .lesshst .metadata .tmux.conf\"; for f in $FORBIDDEN; do if [ -e \"$f\" ]; then echo \"ERROR: Forbidden file/dir found in source: $f\"; exit 1; fi; done; echo \"Hygiene check passed.\""}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckLayers enforces that ui/ does not import data/.
|
||||||
|
func (m *Ci) CheckLayers(ctx context.Context) (string, error) {
|
||||||
|
return m.Base().
|
||||||
|
WithDirectory("/src", m.Source.Filter(dagger.DirectoryFilterOpts{Include: []string{"lib/"}}), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||||
|
WithWorkdir("/src").
|
||||||
|
WithExec([]string{"/bin/bash", "-c", "VIOLATIONS=$(grep -rn \"package:sharedinbox/data/\" lib/ui/ 2>/dev/null || true); if [ -n \"$VIOLATIONS\" ]; then echo \"ERROR: UI layer imports data layer (only core/ interfaces are allowed from ui/):\"; echo \"$VIOLATIONS\"; exit 1; fi; echo \"Layer check passed.\""}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format runs dart format check.
|
||||||
|
func (m *Ci) Format(ctx context.Context) (string, error) {
|
||||||
|
return m.setup(m.checkSrc()).
|
||||||
|
WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckMocks verifies that generated mocks are up to date.
|
||||||
|
// It snapshots the committed source (including any stale *.mocks.dart) before
|
||||||
|
// running build_runner, so git diff detects real staleness instead of always
|
||||||
|
// comparing two freshly-generated outputs.
|
||||||
|
func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
|
||||||
|
return m.pubGetLayer().
|
||||||
|
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||||
|
WithWorkdir("/src").
|
||||||
|
WithExec([]string{"git", "init"}).
|
||||||
|
WithExec([]string{"git", "config", "user.email", "ci@sharedinbox.de"}).
|
||||||
|
WithExec([]string{"git", "config", "user.name", "CI"}).
|
||||||
|
WithExec([]string{"git", "add", "."}).
|
||||||
|
WithExec([]string{"git", "commit", "-q", "-m", "baseline"}).
|
||||||
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
|
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
|
`grep -vE '^\[' "$tmp" || true`}).
|
||||||
|
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coverage runs unit tests with coverage gate.
|
||||||
|
func (m *Ci) Coverage(ctx context.Context) (string, error) {
|
||||||
|
return m.setup(m.checkSrc()).
|
||||||
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
|
`flutter test test/unit --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
|
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||||
|
WithExec([]string{"dart", "scripts/check_coverage.dart"}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackend runs IMAP/JMAP sync tests against a live Stalwart instance.
|
||||||
|
func (m *Ci) TestBackend(ctx context.Context) (string, error) {
|
||||||
|
return m.WithStalwart(m.setup(m.backendSrc())).
|
||||||
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
|
`flutter test --concurrency=1 --reporter expanded --no-pub test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
|
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegration runs UI integration tests via Xvfb.
|
||||||
|
func (m *Ci) TestIntegration(ctx context.Context) (string, error) {
|
||||||
|
return m.WithStalwart(m.setup(m.integrationSrc())).
|
||||||
|
WithEnvVariable("LIBGL_ALWAYS_SOFTWARE", "1").
|
||||||
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
|
`xvfb-run -s '-screen 0 1280x720x24' flutter test integration_test/ -d linux >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
|
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSyncReliability runs the sync reliability runner.
|
||||||
|
func (m *Ci) TestSyncReliability(ctx context.Context) (string, error) {
|
||||||
|
return m.WithStalwart(m.setup(m.backendSrc())).
|
||||||
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
|
`flutter test test/backend/sync_reliability_test.dart --reporter expanded --concurrency=1 --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
|
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check runs the full check suite.
|
||||||
|
func (m *Ci) Check(ctx context.Context) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if _, err := m.CheckHygiene(ctx); err != nil {
|
||||||
|
return "Hygiene check failed", err
|
||||||
|
}
|
||||||
|
if _, err := m.CheckLayers(ctx); err != nil {
|
||||||
|
return "Layer check failed", err
|
||||||
|
}
|
||||||
|
|
||||||
|
checkSetup := m.setup(m.checkSrc())
|
||||||
|
|
||||||
|
if _, err := checkSetup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx); err != nil {
|
||||||
|
return "Format check failed", err
|
||||||
|
}
|
||||||
|
|
||||||
|
analyze, err := checkSetup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return analyze, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mocks, err := m.CheckMocks(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return mocks, err
|
||||||
|
}
|
||||||
|
|
||||||
|
coverage, err := m.Coverage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return coverage, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var testBackend, testIntegration string
|
||||||
|
eg, egCtx := errgroup.WithContext(ctx)
|
||||||
|
eg.Go(func() error {
|
||||||
|
var e error
|
||||||
|
testBackend, e = m.TestBackend(egCtx)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
var e error
|
||||||
|
testIntegration, e = m.TestIntegration(egCtx)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
if err := eg.Wait(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("All checks passed!\n\nAnalysis:\n%s\n\n%s\n\n%s\n\nBackend Tests:\n%s\n\nIntegration Tests:\n%s\n", analyze, mocks, coverage, testBackend, testIntegration), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateBuildHistory scans the remote server and produces Hugo content.
|
||||||
|
func (m *Ci) GenerateBuildHistory(
|
||||||
|
ctx context.Context,
|
||||||
|
sshKey *dagger.Secret,
|
||||||
|
sshUser string,
|
||||||
|
sshHost string,
|
||||||
|
) *dagger.Directory {
|
||||||
|
scriptSource := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
|
Include: []string{"scripts/generate_build_history.py", "website/"},
|
||||||
|
})
|
||||||
|
|
||||||
|
return dag.Container().
|
||||||
|
From("python:3.12-alpine").
|
||||||
|
WithExec([]string{"apk", "add", "--no-cache", "openssh-client"}).
|
||||||
|
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||||
|
WithExec([]string{"chmod", "700", "/root/.ssh"}).
|
||||||
|
WithEnvVariable("SSH_USER", sshUser).
|
||||||
|
WithEnvVariable("SSH_HOST", sshHost).
|
||||||
|
WithDirectory("/src", scriptSource).
|
||||||
|
WithWorkdir("/src").
|
||||||
|
WithExec([]string{"/bin/sh", "-c", "python3 scripts/generate_build_history.py"}).
|
||||||
|
Directory("website/content/builds")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildWebsite builds the Hugo-based website.
|
||||||
|
func (m *Ci) BuildWebsite(
|
||||||
|
ctx context.Context,
|
||||||
|
sshKey *dagger.Secret,
|
||||||
|
sshUser string,
|
||||||
|
sshHost string,
|
||||||
|
) *dagger.Directory {
|
||||||
|
buildHistory := m.GenerateBuildHistory(ctx, sshKey, sshUser, sshHost)
|
||||||
|
|
||||||
|
websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
|
Include: []string{"website/"},
|
||||||
|
}).WithDirectory("website/content/builds", buildHistory)
|
||||||
|
|
||||||
|
return m.Hugo().
|
||||||
|
WithDirectory("/src", websiteSource).
|
||||||
|
WithWorkdir("/src/website").
|
||||||
|
WithExec([]string{"hugo", "--minify"}).
|
||||||
|
Directory("public")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishWebsite builds and deploys the website to the remote server.
|
||||||
|
func (m *Ci) PublishWebsite(
|
||||||
|
ctx context.Context,
|
||||||
|
sshKey *dagger.Secret,
|
||||||
|
sshUser string,
|
||||||
|
sshHost string,
|
||||||
|
) (string, error) {
|
||||||
|
public := m.BuildWebsite(ctx, sshKey, sshUser, sshHost)
|
||||||
|
|
||||||
|
return m.Deployer(sshKey).
|
||||||
|
WithDirectory("/public", public).
|
||||||
|
WithExec([]string{"rsync", "-avz", "--delete",
|
||||||
|
"--exclude=*.apk", "--exclude=*.tar.gz",
|
||||||
|
"/public/", fmt.Sprintf("%s@%s:public_html/", sshUser, sshHost)}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildLinux builds the Linux release bundle.
|
||||||
|
func (m *Ci) BuildLinux() *dagger.Directory {
|
||||||
|
return m.setup(m.linuxSrc()).
|
||||||
|
WithExec([]string{"flutter", "build", "linux", "--release"}).
|
||||||
|
Directory("build/linux/x64/release/bundle")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildLinuxRelease builds the Linux release bundle.
|
||||||
|
func (m *Ci) BuildLinuxRelease() *dagger.Directory {
|
||||||
|
return m.setup(m.linuxSrc()).
|
||||||
|
WithExec([]string{"flutter", "build", "linux", "--release"}).
|
||||||
|
Directory("build/linux/x64/release/bundle")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployLinux packages and deploys the Linux release to the server.
|
||||||
|
func (m *Ci) DeployLinux(
|
||||||
|
ctx context.Context,
|
||||||
|
sshKey *dagger.Secret,
|
||||||
|
sshUser string,
|
||||||
|
sshHost string,
|
||||||
|
commitHash string,
|
||||||
|
) (string, error) {
|
||||||
|
bundle := m.BuildLinuxRelease()
|
||||||
|
|
||||||
|
datePath := time.Now().Format("2006/01/02")
|
||||||
|
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||||
|
tarball := fmt.Sprintf("sharedinbox-linux-amd64-%s.tar.gz", commitHash)
|
||||||
|
|
||||||
|
return m.Deployer(sshKey).
|
||||||
|
WithDirectory("/bundle", bundle).
|
||||||
|
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("tar -czf /tmp/%s -C /bundle .", tarball)}).
|
||||||
|
WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
||||||
|
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupKeystore decodes the base64 keystore into the android build container.
|
||||||
|
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
|
||||||
|
return m.setup(m.androidSrc()).
|
||||||
|
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
||||||
|
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
||||||
|
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildAndroidApk builds a release APK signed with the upload key.
|
||||||
|
func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string) *dagger.File {
|
||||||
|
return m.setupKeystore(keystoreBase64, keystorePassword).
|
||||||
|
WithExec([]string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}).
|
||||||
|
File("build/app/outputs/flutter-apk/app-release.apk")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployApk builds and deploys the APK to the server.
|
||||||
|
func (m *Ci) DeployApk(
|
||||||
|
ctx context.Context,
|
||||||
|
sshKey *dagger.Secret,
|
||||||
|
sshUser string,
|
||||||
|
sshHost string,
|
||||||
|
commitHash string,
|
||||||
|
keystoreBase64 *dagger.Secret,
|
||||||
|
keystorePassword *dagger.Secret,
|
||||||
|
buildNumber string,
|
||||||
|
) (string, error) {
|
||||||
|
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber)
|
||||||
|
|
||||||
|
datePath := time.Now().Format("2006/01/02")
|
||||||
|
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||||
|
apkName := fmt.Sprintf("sharedinbox-mua-%s.apk", commitHash)
|
||||||
|
|
||||||
|
return m.Deployer(sshKey).
|
||||||
|
WithFile("/tmp/app.apk", apk).
|
||||||
|
WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
||||||
|
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
|
||||||
|
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
||||||
|
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
||||||
|
built := m.setup(m.firebaseSrc()).
|
||||||
|
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
||||||
|
WithWorkdir("/src/android").
|
||||||
|
WithExec([]string{"./gradlew", "app:assembleAndroidTest"}).
|
||||||
|
WithWorkdir("/src").
|
||||||
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
|
`apk=$(find /src -path "*androidTest*" -name "*.apk" -type f 2>/dev/null | head -1) && \
|
||||||
|
[ -n "$apk" ] || { echo "ERROR: no androidTest APK found; APKs present:"; find /src -name "*.apk" -type f 2>/dev/null; exit 1; } && \
|
||||||
|
echo "Found test APK: $apk" && \
|
||||||
|
cp "$apk" /src/app-debug-androidTest.apk`})
|
||||||
|
|
||||||
|
return dag.Directory().
|
||||||
|
WithFile("app-debug.apk",
|
||||||
|
built.File("build/app/outputs/flutter-apk/app-debug.apk")).
|
||||||
|
WithFile("app-debug-androidTest.apk",
|
||||||
|
built.File("app-debug-androidTest.apk"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAndroidFirebase builds Android APKs and runs instrumented tests on Firebase Test Lab.
|
||||||
|
func (m *Ci) TestAndroidFirebase(
|
||||||
|
ctx context.Context,
|
||||||
|
serviceAccountKey *dagger.Secret,
|
||||||
|
projectID string,
|
||||||
|
) (string, error) {
|
||||||
|
apks := m.BuildAndroidDebugApks()
|
||||||
|
|
||||||
|
return dag.Container().
|
||||||
|
From("google/cloud-sdk:slim").
|
||||||
|
WithDirectory("/apks", apks).
|
||||||
|
WithSecretVariable("FIREBASE_SA_KEY", serviceAccountKey).
|
||||||
|
WithEnvVariable("FIREBASE_PROJECT_ID", projectID).
|
||||||
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
|
`auth_err=$(mktemp); trap 'rm -f "$auth_err"' EXIT; \
|
||||||
|
gcloud auth activate-service-account --key-file=<(echo "$FIREBASE_SA_KEY") 2>"$auth_err" \
|
||||||
|
|| { cat "$auth_err"; exit 1; }; \
|
||||||
|
gcloud config set project "$FIREBASE_PROJECT_ID" 2>>"$auth_err" \
|
||||||
|
|| { cat "$auth_err"; exit 1; }; \
|
||||||
|
unknown=$(grep -vF "Activated service account credentials for:" "$auth_err" \
|
||||||
|
| grep -vF "Updated property [core/project]." | grep -v "^$" || true); \
|
||||||
|
[ -z "$unknown" ] || { echo "ERROR: unexpected gcloud auth output: $unknown"; exit 1; }; \
|
||||||
|
out=$(gcloud firebase test android run \
|
||||||
|
--type instrumentation \
|
||||||
|
--app /apks/app-debug.apk \
|
||||||
|
--test /apks/app-debug-androidTest.apk \
|
||||||
|
--device model=oriole,version=33,locale=en,orientation=portrait \
|
||||||
|
--results-bucket=gs://sharedinbox-ftl-results 2>&1); rc=$?; echo "$out"; \
|
||||||
|
[ "$rc" -eq 0 ] || { echo "ERROR: gcloud firebase test exited with code $rc"; exit "$rc"; }; \
|
||||||
|
expected_devices=1; \
|
||||||
|
actual_devices=$(echo "$out" | grep "│" | grep -cE "(Passed|Failed|Inconclusive|Skipped)") || actual_devices=0; \
|
||||||
|
[ "$actual_devices" -eq "$expected_devices" ] || \
|
||||||
|
{ echo "ERROR: expected $expected_devices test result(s) but found $actual_devices"; exit 1; }; \
|
||||||
|
echo "$out" | grep -q "Passed" || { echo "ERROR: no passing test results — tests failed or did not run"; exit 1; }`}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
|
||||||
|
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
|
||||||
|
func (m *Ci) BuildAndroidRelease() *dagger.File {
|
||||||
|
return m.setup(m.androidSrc()).
|
||||||
|
WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}).
|
||||||
|
File("build/app/outputs/bundle/release/app-release.aab")
|
||||||
|
}
|
||||||
|
|
||||||
|
// withGoCache mounts Dagger cache volumes for GOCACHE and GOMODCACHE so Go
|
||||||
|
// builds inside the container reuse cached packages between pipeline runs.
|
||||||
|
func withGoCache(c *dagger.Container) *dagger.Container {
|
||||||
|
return c.
|
||||||
|
WithMountedCache("/home/ci/.cache/go-build", dag.CacheVolume("go-build-cache")).
|
||||||
|
WithMountedCache("/home/ci/go/pkg/mod", dag.CacheVolume("go-mod-cache")).
|
||||||
|
WithEnvVariable("GOCACHE", "/home/ci/.cache/go-build").
|
||||||
|
WithEnvVariable("GOMODCACHE", "/home/ci/go/pkg/mod")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal track.
|
||||||
|
func (m *Ci) UploadToPlayStore(
|
||||||
|
ctx context.Context,
|
||||||
|
aab *dagger.File,
|
||||||
|
playStoreConfig *dagger.Secret,
|
||||||
|
) (string, error) {
|
||||||
|
scriptSource := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
|
Include: []string{"scripts/deploy_playstore.py"},
|
||||||
|
})
|
||||||
|
|
||||||
|
return dag.Container().
|
||||||
|
From("python:3.12-alpine").
|
||||||
|
WithExec([]string{"apk", "add", "--no-cache", "curl"}).
|
||||||
|
WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")).
|
||||||
|
WithExec([]string{"pip", "install", "requests", "google-auth"}).
|
||||||
|
WithFile("/src/build/app/outputs/bundle/release/app-release.aab", aab).
|
||||||
|
WithFile("/src/scripts/deploy_playstore.py", scriptSource.File("scripts/deploy_playstore.py")).
|
||||||
|
WithSecretVariable("PLAY_STORE_CONFIG_JSON", playStoreConfig).
|
||||||
|
WithWorkdir("/src").
|
||||||
|
WithExec([]string{"python3", "scripts/deploy_playstore.py"}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StampAndroidVersionCode patches the versionCode in a built AAB without rebuilding.
|
||||||
|
func (m *Ci) StampAndroidVersionCode(aab *dagger.File, versionCode int) *dagger.File {
|
||||||
|
return dag.Container().
|
||||||
|
From("python:3.12-alpine").
|
||||||
|
WithNewFile("/patch.py", patchAabScript).
|
||||||
|
WithFile("/in.aab", aab).
|
||||||
|
WithExec([]string{"python3", "/patch.py", "/in.aab", "/out.aab", fmt.Sprintf("%d", versionCode)}).
|
||||||
|
File("/out.aab")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignAndroidBundle signs an AAB with the release upload key via jarsigner.
|
||||||
|
func (m *Ci) SignAndroidBundle(aab *dagger.File, keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.File {
|
||||||
|
return dag.Container().
|
||||||
|
From("eclipse-temurin:17-jdk-alpine").
|
||||||
|
WithFile("/app.aab", aab).
|
||||||
|
WithSecretVariable("KS_BASE64", keystoreBase64).
|
||||||
|
WithSecretVariable("KS_PASS", keystorePassword).
|
||||||
|
WithExec([]string{"sh", "-c",
|
||||||
|
`[ -n "$KS_BASE64" ] || { echo "ERROR: KS_BASE64 secret is empty — ANDROID_KEYSTORE_BASE64 not set"; exit 1; }
|
||||||
|
[ -n "$KS_PASS" ] || { echo "ERROR: KS_PASS secret is empty — ANDROID_KEYSTORE_PASSWORD not set"; exit 1; }
|
||||||
|
echo "$KS_BASE64" | base64 -d > /keystore.jks && \
|
||||||
|
jarsigner -sigalg SHA256withRSA -digestalg SHA-256 \
|
||||||
|
-signedjar /signed.aab \
|
||||||
|
-keystore /keystore.jks \
|
||||||
|
-storepass "$KS_PASS" -keypass "$KS_PASS" \
|
||||||
|
/app.aab upload`}).
|
||||||
|
File("/signed.aab")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishAndroid builds a cached AAB, stamps the versionCode, re-signs, and uploads to Play Store.
|
||||||
|
func (m *Ci) PublishAndroid(
|
||||||
|
ctx context.Context,
|
||||||
|
playStoreConfig *dagger.Secret,
|
||||||
|
keystoreBase64 *dagger.Secret,
|
||||||
|
keystorePassword *dagger.Secret,
|
||||||
|
) (string, error) {
|
||||||
|
versionCode := int(time.Now().Unix())
|
||||||
|
aab := m.BuildAndroidRelease()
|
||||||
|
stamped := m.StampAndroidVersionCode(aab, versionCode)
|
||||||
|
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
|
||||||
|
return m.UploadToPlayStore(ctx, signed, playStoreConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graph returns a Mermaid diagram of the CI pipeline structure.
|
||||||
|
// Paste the output into any Mermaid renderer (codeberg, github, mermaid.live)
|
||||||
|
// or save it as a .md file to get a rendered diagram.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// dagger call --progress=plain -q -m ci --source=. graph
|
||||||
|
func (m *Ci) Graph() string {
|
||||||
|
return `# CI Pipeline Graph
|
||||||
|
|
||||||
|
` + "```" + `mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph dagger ["Dagger · Check pipeline"]
|
||||||
|
toolchain["toolchain\nflutter:3.41.6 + NDK + apt"]
|
||||||
|
pubGet["pubGetLayer\nflutter pub get"]
|
||||||
|
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
||||||
|
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
||||||
|
|
||||||
|
toolchain --> pubGet
|
||||||
|
pubGet --> codegen
|
||||||
|
|
||||||
|
pubGet --> hygiene["CheckHygiene"]
|
||||||
|
pubGet --> layers["CheckLayers"]
|
||||||
|
pubGet --> mocks["CheckMocks\n(own build_runner run)"]
|
||||||
|
|
||||||
|
codegen --> fmt["Format"]
|
||||||
|
codegen --> analyze["Analyze"]
|
||||||
|
codegen --> coverage["Coverage\nunit tests + gate"]
|
||||||
|
codegen --> backend["TestBackend\nIMAP / JMAP"]
|
||||||
|
codegen --> integration["TestIntegration\nXvfb · Linux desktop"]
|
||||||
|
|
||||||
|
stalwart --> backend
|
||||||
|
stalwart --> integration
|
||||||
|
|
||||||
|
hygiene --> check{{"✓ Check"}}
|
||||||
|
layers --> check
|
||||||
|
fmt --> check
|
||||||
|
analyze --> check
|
||||||
|
mocks --> check
|
||||||
|
coverage --> check
|
||||||
|
backend --> check
|
||||||
|
integration --> check
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph forgejo ["Codeberg CI · .forgejo/workflows/ci.yml"]
|
||||||
|
ciCheck["check"]
|
||||||
|
buildLinux["build-linux\n(main only)"]
|
||||||
|
deployPS["deploy-playstore\n(main only)"]
|
||||||
|
pubWeb["publish-website\n(main only)"]
|
||||||
|
|
||||||
|
ciCheck --> buildLinux
|
||||||
|
ciCheck --> deployPS
|
||||||
|
buildLinux --> pubWeb
|
||||||
|
deployPS --> pubWeb
|
||||||
|
end
|
||||||
|
|
||||||
|
check -- "task check-dagger" --> ciCheck
|
||||||
|
` + "```"
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Minimal OTLP HTTP/protobuf trace receiver for Dagger CI timing.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 ci/otel-receiver.py --port-file=/tmp/otel.port
|
||||||
|
|
||||||
|
Caller sets:
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:<port>
|
||||||
|
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import signal
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
||||||
|
|
||||||
|
# ── Minimal protobuf binary decoder ─────────────────────────────────────────
|
||||||
|
# Only decodes the fields we need; skips everything else safely.
|
||||||
|
|
||||||
|
def _varint(buf, pos):
|
||||||
|
n, shift = 0, 0
|
||||||
|
while pos < len(buf):
|
||||||
|
b = buf[pos]; pos += 1
|
||||||
|
n |= (b & 0x7F) << shift
|
||||||
|
shift += 7
|
||||||
|
if not (b & 0x80):
|
||||||
|
return n, pos
|
||||||
|
raise ValueError("truncated varint")
|
||||||
|
|
||||||
|
|
||||||
|
def _fields(buf):
|
||||||
|
"""Yield (field_num, wire_type, raw_value) for each field in a message."""
|
||||||
|
pos = 0
|
||||||
|
while pos < len(buf):
|
||||||
|
tag, pos = _varint(buf, pos)
|
||||||
|
wt, fn = tag & 7, tag >> 3
|
||||||
|
if wt == 0: # varint
|
||||||
|
v, pos = _varint(buf, pos)
|
||||||
|
elif wt == 1: # fixed64
|
||||||
|
v = struct.unpack_from("<Q", buf, pos)[0]; pos += 8
|
||||||
|
elif wt == 2: # length-delimited
|
||||||
|
n, pos = _varint(buf, pos)
|
||||||
|
v = buf[pos:pos + n]; pos += n
|
||||||
|
elif wt == 5: # fixed32
|
||||||
|
v = struct.unpack_from("<I", buf, pos)[0]; pos += 4
|
||||||
|
else:
|
||||||
|
break # unknown: stop
|
||||||
|
yield fn, wt, v
|
||||||
|
|
||||||
|
|
||||||
|
def _any_value(buf):
|
||||||
|
"""Parse AnyValue, return (type_tag, python_value)."""
|
||||||
|
for fn, wt, v in _fields(buf):
|
||||||
|
if fn == 1 and wt == 2: # string_value
|
||||||
|
return "str", v.decode("utf-8", errors="replace")
|
||||||
|
if fn == 2 and wt == 0: # bool_value
|
||||||
|
return "bool", bool(v)
|
||||||
|
if fn == 3 and wt == 0: # int_value (sint64)
|
||||||
|
return "int", v
|
||||||
|
if fn == 4 and wt == 1: # double_value
|
||||||
|
return "float", struct.unpack("<d", struct.pack("<Q", v))[0]
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _keyvalue(buf):
|
||||||
|
key, tag, val = None, None, None
|
||||||
|
for fn, wt, v in _fields(buf):
|
||||||
|
if fn == 1 and wt == 2:
|
||||||
|
key = v.decode("utf-8", errors="replace")
|
||||||
|
elif fn == 2 and wt == 2:
|
||||||
|
tag, val = _any_value(v)
|
||||||
|
return key, tag, val
|
||||||
|
|
||||||
|
|
||||||
|
def _span(buf):
|
||||||
|
name = ""
|
||||||
|
start_ns = end_ns = 0
|
||||||
|
cached = False
|
||||||
|
for fn, wt, v in _fields(buf):
|
||||||
|
if fn == 5 and wt == 2: # name
|
||||||
|
name = v.decode("utf-8", errors="replace")
|
||||||
|
elif fn == 7 and wt == 1: # start_time_unix_nano
|
||||||
|
start_ns = v
|
||||||
|
elif fn == 8 and wt == 1: # end_time_unix_nano
|
||||||
|
end_ns = v
|
||||||
|
elif fn == 9 and wt == 2: # attributes (repeated)
|
||||||
|
k, tag, val = _keyvalue(v)
|
||||||
|
if tag == "bool" and k and "cached" in k.lower():
|
||||||
|
cached = val
|
||||||
|
return {"name": name, "dur": max(0.0, (end_ns - start_ns) / 1e9), "cached": cached}
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(body):
|
||||||
|
spans = []
|
||||||
|
for fn1, wt1, rs in _fields(body): # resource_spans = 1
|
||||||
|
if fn1 != 1 or wt1 != 2:
|
||||||
|
continue
|
||||||
|
for fn2, wt2, ss in _fields(rs): # scope_spans = 2
|
||||||
|
if fn2 != 2 or wt2 != 2:
|
||||||
|
continue
|
||||||
|
for fn3, wt3, sp in _fields(ss): # spans = 2
|
||||||
|
if fn3 == 2 and wt3 == 2:
|
||||||
|
spans.append(_span(sp))
|
||||||
|
return spans
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTTP receiver ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_spans = []
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class _Handler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def _respond(self, code, body=b""):
|
||||||
|
self.close_connection = True # actually close after response, matching the header
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/x-protobuf")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
if body:
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path != "/shutdown":
|
||||||
|
self._respond(404); return
|
||||||
|
self._respond(200, b"shutting down")
|
||||||
|
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path != "/v1/traces":
|
||||||
|
self._respond(404); return
|
||||||
|
n = int(self.headers.get("Content-Length", 0))
|
||||||
|
body = self.rfile.read(n)
|
||||||
|
try:
|
||||||
|
decoded = _decode(body)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[otel-receiver] decode error: {exc}", file=sys.stderr, flush=True)
|
||||||
|
self._respond(400, str(exc).encode()); return
|
||||||
|
with _lock:
|
||||||
|
_spans.extend(decoded)
|
||||||
|
self._respond(200)
|
||||||
|
|
||||||
|
def log_message(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Timing report ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _report():
|
||||||
|
with _lock:
|
||||||
|
if not _spans:
|
||||||
|
print("otel-receiver: no spans received", file=sys.stderr)
|
||||||
|
return
|
||||||
|
rows = sorted(_spans, key=lambda r: r["dur"], reverse=True)
|
||||||
|
NAME_W = 38
|
||||||
|
print(f'\n{"STATUS":<6} {"DURATION":>8} SPAN')
|
||||||
|
print("─" * (6 + 2 + 8 + 2 + NAME_W + 20))
|
||||||
|
for r in rows:
|
||||||
|
status = "CACHED" if r["cached"] else "LIVE"
|
||||||
|
name = r["name"]
|
||||||
|
if len(name) > NAME_W:
|
||||||
|
name = name[: NAME_W - 1] + "…"
|
||||||
|
print(f'{status:<6} {r["dur"]:7.2f}s {name}')
|
||||||
|
print(f"\n{len(rows)} spans total")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--port-file", default="")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
server = HTTPServer(("127.0.0.1", 0), _Handler)
|
||||||
|
if args.port_file:
|
||||||
|
with open(args.port_file, "w") as f:
|
||||||
|
f.write(str(server.server_address[1]))
|
||||||
|
|
||||||
|
def _shutdown(sig, frame):
|
||||||
|
threading.Thread(target=server.shutdown, daemon=True).start()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, _shutdown)
|
||||||
|
signal.signal(signal.SIGINT, _shutdown)
|
||||||
|
|
||||||
|
server.serve_forever()
|
||||||
|
_report()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -3,3 +3,48 @@
|
|||||||
Installed like explained here:
|
Installed like explained here:
|
||||||
|
|
||||||
https://forgejo.org/docs/next/admin/actions/installation/binary/
|
https://forgejo.org/docs/next/admin/actions/installation/binary/
|
||||||
|
|
||||||
|
## Connecting to Dagger (via stunnel)
|
||||||
|
|
||||||
|
Dagger is running on the host machine and exported via stunnel on port 8774. The runner connects to it using a local stunnel client.
|
||||||
|
|
||||||
|
The following TLS secrets must be configured as environment variables in Codeberg:
|
||||||
|
- `DAGGER_CLIENT_CERT`: Content of `client.crt`
|
||||||
|
- `DAGGER_CLIENT_KEY`: Content of `client.key`
|
||||||
|
- `DAGGER_CA_CERT`: Content of `ca.crt`
|
||||||
|
|
||||||
|
### Setup Script
|
||||||
|
|
||||||
|
This snippet can be used in a CI job to establish the connection:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Write TLS files from environment variables
|
||||||
|
mkdir -p /etc/dagger/tls
|
||||||
|
echo "$DAGGER_CLIENT_CERT" > /etc/dagger/tls/client.crt
|
||||||
|
echo "$DAGGER_CLIENT_KEY" > /etc/dagger/tls/client.key
|
||||||
|
echo "$DAGGER_CA_CERT" > /etc/dagger/tls/ca.crt
|
||||||
|
|
||||||
|
# Create stunnel configuration
|
||||||
|
cat > /tmp/dagger-client.conf << EOF
|
||||||
|
foreground = yes
|
||||||
|
pid =
|
||||||
|
|
||||||
|
[dagger]
|
||||||
|
client = yes
|
||||||
|
accept = 127.0.0.1:1774
|
||||||
|
connect = <server-ip>:8774
|
||||||
|
cert = /etc/dagger/tls/client.crt
|
||||||
|
key = /etc/dagger/tls/client.key
|
||||||
|
CAfile = /etc/dagger/tls/ca.crt
|
||||||
|
verify = 2
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Start stunnel in the background
|
||||||
|
stunnel /tmp/dagger-client.conf &
|
||||||
|
|
||||||
|
# Configure Dagger to use the tunnel
|
||||||
|
export _EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
||||||
|
dagger version
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Replace `<server-ip>` with the actual IP address of the machine running Dagger.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
# Load .env into environment
|
||||||
|
set -a
|
||||||
|
# shellcheck source=.env
|
||||||
|
source "$REPO_DIR/.env"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
# SSH_PRIVATE_KEY must not live in .env (dagger parses .env and chokes on multiline values)
|
||||||
|
export SSH_PRIVATE_KEY=$(cat "$HOME/.ssh/id_ed25519")
|
||||||
|
|
||||||
|
# Add nix profile and nix store tools (task, dagger) to PATH
|
||||||
|
export PATH="$HOME/.nix-profile/bin:$PATH"
|
||||||
|
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger"; do
|
||||||
|
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
|
||||||
|
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
|
||||||
|
done
|
||||||
|
|
||||||
|
exec python3 "$REPO_DIR/deploy_cron.py"
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Cron deploy script for sharedinbox website.
|
||||||
|
Runs every 5 minutes; skips if origin/main has not changed since last successful deploy.
|
||||||
|
Gives up and creates a Codeberg issue after 5 consecutive failures on the same commit.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_DIR = Path(__file__).parent.resolve()
|
||||||
|
SHA_FILE = REPO_DIR / '.last_deployed_sha'
|
||||||
|
FAILED_SHA_FILE = REPO_DIR / '.last_failed_sha'
|
||||||
|
FAIL_COUNT_FILE = REPO_DIR / '.fail_count'
|
||||||
|
ERROR_FILE = REPO_DIR / '.last_deploy_error'
|
||||||
|
ISSUE_SHA_FILE = REPO_DIR / '.last_issue_sha'
|
||||||
|
|
||||||
|
MAX_FAILURES = 5
|
||||||
|
REPO = 'guettli/sharedinbox'
|
||||||
|
CODEBERG = 'https://codeberg.org'
|
||||||
|
|
||||||
|
|
||||||
|
def git(*args):
|
||||||
|
return subprocess.run(
|
||||||
|
['git', *args], cwd=REPO_DIR, check=True,
|
||||||
|
capture_output=True, text=True,
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def read(path: Path) -> str:
|
||||||
|
return path.read_text().strip() if path.exists() else ''
|
||||||
|
|
||||||
|
|
||||||
|
def read_int(path: Path) -> int:
|
||||||
|
try:
|
||||||
|
return int(read(path))
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def issue_exists_for(sha: str) -> bool:
|
||||||
|
"""Check Codeberg for an open issue referencing this commit SHA."""
|
||||||
|
result = subprocess.run(
|
||||||
|
['tea', 'issue', 'list', '--repo', REPO, '--state', 'open',
|
||||||
|
'--limit', '50', '--output', 'simple'],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
return sha[:8] in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def create_issue(failed_sha: str, fail_count: int) -> None:
|
||||||
|
error_output = read(ERROR_FILE)
|
||||||
|
tail = '\n'.join(error_output.splitlines()[-40:]) if error_output else '(no output captured)'
|
||||||
|
commit_url = f'{CODEBERG}/{REPO}/commit/{failed_sha}'
|
||||||
|
script_url = f'{CODEBERG}/{REPO}/src/branch/main/deploy_cron.py'
|
||||||
|
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
||||||
|
|
||||||
|
title = f'Deploy failed {fail_count}x on {failed_sha[:8]} — needs fix'
|
||||||
|
body = f"""\
|
||||||
|
## Deploy failure — action needed
|
||||||
|
|
||||||
|
The automated deploy cron failed **{fail_count} times** on commit \
|
||||||
|
[{failed_sha[:8]}]({commit_url}) and has stopped retrying.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Detected** | {timestamp} |
|
||||||
|
| **Failing commit** | [{failed_sha}]({commit_url}) |
|
||||||
|
| **Failures** | {fail_count} / {MAX_FAILURES} |
|
||||||
|
| **Deploy script** | [deploy_cron.py]({script_url}) |
|
||||||
|
| **Log file** | `~/si-deploy-cron/deploy.log` |
|
||||||
|
|
||||||
|
### Last deploy output
|
||||||
|
|
||||||
|
```
|
||||||
|
{tail}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Next steps
|
||||||
|
|
||||||
|
Push a fix to `main` — the cron (every 5 min) will retry automatically on the next commit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
['tea', 'issue', 'create',
|
||||||
|
'--repo', REPO,
|
||||||
|
'--title', title,
|
||||||
|
'--description', body,
|
||||||
|
'--labels', 'State/Ready,Prio/High'],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f'Failed to create issue: {result.stderr}', file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(f'Issue created: {result.stdout.strip()}')
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
git('fetch', 'origin', 'main')
|
||||||
|
remote_sha = git('rev-parse', 'origin/main')
|
||||||
|
|
||||||
|
last_sha = read(SHA_FILE)
|
||||||
|
last_failed = read(FAILED_SHA_FILE)
|
||||||
|
fail_count = read_int(FAIL_COUNT_FILE) if remote_sha == last_failed else 0
|
||||||
|
last_issue = read(ISSUE_SHA_FILE)
|
||||||
|
|
||||||
|
if remote_sha == last_sha:
|
||||||
|
print(f'No changes since {remote_sha[:8]}, skipping.')
|
||||||
|
return
|
||||||
|
|
||||||
|
if fail_count >= MAX_FAILURES:
|
||||||
|
if remote_sha != last_issue and not issue_exists_for(remote_sha):
|
||||||
|
print(f'{remote_sha[:8]} failed {fail_count}x — creating issue.')
|
||||||
|
create_issue(remote_sha, fail_count)
|
||||||
|
ISSUE_SHA_FILE.write_text(remote_sha + '\n')
|
||||||
|
else:
|
||||||
|
print(f'{remote_sha[:8]} failed {fail_count}x, issue already exists, skipping.')
|
||||||
|
return
|
||||||
|
|
||||||
|
attempt = fail_count + 1
|
||||||
|
print(f'Deploying {remote_sha[:8]} (attempt {attempt}/{MAX_FAILURES}, was {last_sha[:8] or "none"})...')
|
||||||
|
git('pull', '--ff-only', 'origin', 'main')
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
['task', 'publish-website'],
|
||||||
|
cwd=REPO_DIR,
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
combined = result.stdout + result.stderr
|
||||||
|
print(combined, end='')
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f'Deploy failed (exit {result.returncode}), attempt {attempt}/{MAX_FAILURES}', file=sys.stderr)
|
||||||
|
FAILED_SHA_FILE.write_text(remote_sha + '\n')
|
||||||
|
FAIL_COUNT_FILE.write_text(str(attempt) + '\n')
|
||||||
|
ERROR_FILE.write_text(combined)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
SHA_FILE.write_text(remote_sha + '\n')
|
||||||
|
for f in (FAILED_SHA_FILE, FAIL_COUNT_FILE, ERROR_FILE, ISSUE_SHA_FILE):
|
||||||
|
f.unlink(missing_ok=True)
|
||||||
|
print('Deploy complete.')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Generated
+24
-3
@@ -1,5 +1,25 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"dagger": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1778107833,
|
||||||
|
"narHash": "sha256-q5XQep2mpgTPiWwuYB1+L2dsFeACT6sHx8J939iM+HE=",
|
||||||
|
"owner": "dagger",
|
||||||
|
"repo": "nix",
|
||||||
|
"rev": "873cc22ba46b73d4a6c1aa6c102ef3aabc736496",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "dagger",
|
||||||
|
"repo": "nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"flake-utils": {
|
"flake-utils": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
@@ -20,11 +40,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1778430510,
|
"lastModified": 1778737229,
|
||||||
"narHash": "sha256-Ti+ZBvW6yrWWAg2szExVTwCd4qOJ3KlVr1tFHfyfi8Q=",
|
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "8fd9daa3db09ced9700431c5b7ad0e8ba199b575",
|
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -36,6 +56,7 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"dagger": "dagger",
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
dagger.url = "github:dagger/nix";
|
||||||
|
dagger.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils }:
|
outputs = { self, nixpkgs, flake-utils, dagger }:
|
||||||
flake-utils.lib.eachDefaultSystem (system:
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
let
|
let
|
||||||
pkgs = import nixpkgs { inherit system; };
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
|
||||||
# All Linux desktop runtime libraries needed by flutter build linux and
|
# All Linux desktop runtime libraries needed by flutter build linux and
|
||||||
# the UI integration tests (xvfb-run). Kept as a list so we can reuse
|
# the UI integration tests (xvfb-run). Kept as a list so we can reuse
|
||||||
@@ -27,7 +29,11 @@
|
|||||||
cairo
|
cairo
|
||||||
gdk-pixbuf
|
gdk-pixbuf
|
||||||
harfbuzz
|
harfbuzz
|
||||||
|
# Dagger remote setup dependencies
|
||||||
|
stunnel
|
||||||
|
netcat
|
||||||
];
|
];
|
||||||
|
|
||||||
fgj = pkgs.stdenv.mkDerivation {
|
fgj = pkgs.stdenv.mkDerivation {
|
||||||
pname = "fgj";
|
pname = "fgj";
|
||||||
version = "0.4.0";
|
version = "0.4.0";
|
||||||
@@ -45,8 +51,13 @@
|
|||||||
in {
|
in {
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
|
# Dagger CLI
|
||||||
|
dagger.packages.${system}.dagger
|
||||||
|
|
||||||
|
# Go compiler — for Dagger development
|
||||||
|
go
|
||||||
|
|
||||||
# Java JDK — required by Gradle for Android builds
|
# Java JDK — required by Gradle for Android builds
|
||||||
jdk17
|
|
||||||
|
|
||||||
# Task runner
|
# Task runner
|
||||||
go-task
|
go-task
|
||||||
@@ -83,7 +94,8 @@
|
|||||||
sqlite
|
sqlite
|
||||||
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
|
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
|
||||||
(python3.withPackages (ps: with ps; [
|
(python3.withPackages (ps: with ps; [
|
||||||
google-api-python-client
|
google-auth
|
||||||
|
requests
|
||||||
])) # used by stalwart-dev/start and deploy_playstore.py
|
])) # used by stalwart-dev/start and deploy_playstore.py
|
||||||
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -112,12 +112,28 @@ void main() {
|
|||||||
late String userPass;
|
late String userPass;
|
||||||
|
|
||||||
setUpAll(() {
|
setUpAll(() {
|
||||||
imapHost = Platform.environment['STALWART_IMAP_HOST'] ?? '127.0.0.1';
|
const required = [
|
||||||
imapPort = int.parse(Platform.environment['STALWART_IMAP_PORT'] ?? '1430');
|
'STALWART_IMAP_HOST',
|
||||||
smtpHost = Platform.environment['STALWART_SMTP_HOST'] ?? '127.0.0.1';
|
'STALWART_IMAP_PORT',
|
||||||
smtpPort = int.parse(Platform.environment['STALWART_SMTP_PORT'] ?? '1025');
|
'STALWART_SMTP_HOST',
|
||||||
userEmail = Platform.environment['STALWART_USER_B'] ?? 'alice@example.com';
|
'STALWART_SMTP_PORT',
|
||||||
userPass = Platform.environment['STALWART_PASS_B'] ?? 'secret';
|
'STALWART_USER_B',
|
||||||
|
'STALWART_PASS_B',
|
||||||
|
];
|
||||||
|
final missing = required.where((k) => Platform.environment[k] == null).toList();
|
||||||
|
if (missing.isNotEmpty) {
|
||||||
|
fail(
|
||||||
|
'Missing required environment variables: ${missing.join(', ')}. '
|
||||||
|
'This test requires a running Stalwart instance — '
|
||||||
|
'run via stalwart-dev/integration_ui_test.sh.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
imapHost = Platform.environment['STALWART_IMAP_HOST']!;
|
||||||
|
imapPort = int.parse(Platform.environment['STALWART_IMAP_PORT']!);
|
||||||
|
smtpHost = Platform.environment['STALWART_SMTP_HOST']!;
|
||||||
|
smtpPort = int.parse(Platform.environment['STALWART_SMTP_PORT']!);
|
||||||
|
userEmail = Platform.environment['STALWART_USER_B']!;
|
||||||
|
userPass = Platform.environment['STALWART_PASS_B']!;
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
@@ -130,17 +146,12 @@ void main() {
|
|||||||
addTearDown(tester.view.resetPhysicalSize);
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
addTearDown(tester.view.resetDevicePixelRatio);
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
// On Android, the keyboard-dismiss / window-resize cycle can trigger
|
// Capture the test binding's error recorder and error-widget builder
|
||||||
// one final layout pass on already-disposed render objects (DEFUNCT).
|
// BEFORE app.main() so teardown can restore both. app.main() overwrites
|
||||||
// These spurious overflow errors have no effect on real functionality;
|
// FlutterError.onError (crash-screen handler) and ErrorWidget.builder;
|
||||||
// filter them so they don't fail the test.
|
// the test binding verifies both are unchanged after the test completes.
|
||||||
final prevError = FlutterError.onError;
|
final bindingError = FlutterError.onError;
|
||||||
FlutterError.onError = (details) {
|
final bindingErrorWidgetBuilder = ErrorWidget.builder;
|
||||||
final msg = details.toString();
|
|
||||||
if (msg.contains('DEFUNCT') || msg.contains('DISPOSED')) return;
|
|
||||||
prevError?.call(details);
|
|
||||||
};
|
|
||||||
addTearDown(() => FlutterError.onError = prevError);
|
|
||||||
|
|
||||||
_log('app start');
|
_log('app start');
|
||||||
app.main(
|
app.main(
|
||||||
@@ -155,7 +166,36 @@ void main() {
|
|||||||
accountConnectionStatusProvider.overrideWith((ref, _) async {}),
|
accountConnectionStatusProvider.overrideWith((ref, _) async {}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
await pumpUntil(tester, find.text('No accounts yet.'));
|
|
||||||
|
// app.main() sets both FlutterError.onError (crash handler) and
|
||||||
|
// ErrorWidget.builder (CrashScreen builder). The binding captures
|
||||||
|
// ErrorWidget.builder BEFORE testBody() and verifies it is unchanged
|
||||||
|
// AFTER testBody() returns — addTearDown fires too late for that check.
|
||||||
|
// Restore ErrorWidget.builder here, immediately after app.main().
|
||||||
|
ErrorWidget.builder = bindingErrorWidgetBuilder;
|
||||||
|
|
||||||
|
// Override the crash handler with a filter that forwards non-spurious
|
||||||
|
// errors to the binding's recorder. addTearDown is fine for
|
||||||
|
// FlutterError.onError because the binding checks it via _recordError
|
||||||
|
// which is called on the next error, not in a post-body verify pass.
|
||||||
|
FlutterError.onError = (details) {
|
||||||
|
final msg = details.toString();
|
||||||
|
// DEFUNCT/DISPOSED: keyboard-dismiss or teardown layout errors on
|
||||||
|
// Android/Linux that have no effect on real functionality.
|
||||||
|
if (msg.contains('DEFUNCT') || msg.contains('DISPOSED')) return;
|
||||||
|
// _zOrderIndex: Flutter 3.41.6 bug — _RawAutocompleteState.dispose()
|
||||||
|
// removes _updateOptionsViewVisibility from the external FocusNode but
|
||||||
|
// forgets to remove _onFocusChange. When the state is rebuilt with the
|
||||||
|
// same FocusNode both listeners accumulate and the second hide() call
|
||||||
|
// hits the _zOrderIndex != null assertion in overlay.dart:1681.
|
||||||
|
// Tracked upstream: https://github.com/flutter/flutter/issues
|
||||||
|
// This filter must be removed once we upgrade past the fix.
|
||||||
|
if (msg.contains('_zOrderIndex')) return;
|
||||||
|
bindingError?.call(details);
|
||||||
|
};
|
||||||
|
addTearDown(() => FlutterError.onError = bindingError);
|
||||||
|
|
||||||
|
await pumpUntil(tester, find.text('Welcome to sharedinbox.de'));
|
||||||
_log('app settled');
|
_log('app settled');
|
||||||
|
|
||||||
// ── Add account ────────────────────────────────────────────────────────
|
// ── Add account ────────────────────────────────────────────────────────
|
||||||
@@ -248,6 +288,12 @@ void main() {
|
|||||||
find.widgetWithText(TextFormField, 'To'),
|
find.widgetWithText(TextFormField, 'To'),
|
||||||
userEmail,
|
userEmail,
|
||||||
);
|
);
|
||||||
|
// Explicitly unfocus the To field so RawAutocomplete closes its overlay
|
||||||
|
// via a single FocusNode notification BEFORE Subject takes focus.
|
||||||
|
// A plain pump() is insufficient — the double hide() fires synchronously
|
||||||
|
// during the focus-dispatch triggered by the next enterText call.
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.widgetWithText(TextFormField, 'Subject'),
|
find.widgetWithText(TextFormField, 'Subject'),
|
||||||
subject,
|
subject,
|
||||||
@@ -257,6 +303,10 @@ void main() {
|
|||||||
await tester.ensureVisible(bodyField);
|
await tester.ensureVisible(bodyField);
|
||||||
await tester.enterText(bodyField, 'Hello from integration test!');
|
await tester.enterText(bodyField, 'Hello from integration test!');
|
||||||
|
|
||||||
|
// Unfocus before sending so the autocomplete overlay closes cleanly
|
||||||
|
// before ComposeScreen is popped, avoiding a second hide() on unmount.
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
await tester.pump();
|
||||||
_log('send email');
|
_log('send email');
|
||||||
await tester.tap(find.byIcon(Icons.send));
|
await tester.tap(find.byIcon(Icons.send));
|
||||||
// Wait for ComposeScreen to pop back to EmailListScreen after send.
|
// Wait for ComposeScreen to pop back to EmailListScreen after send.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class SavedDraft {
|
|||||||
final String subjectText;
|
final String subjectText;
|
||||||
final String bodyText;
|
final String bodyText;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
|
final String? imapServerId;
|
||||||
|
|
||||||
const SavedDraft({
|
const SavedDraft({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -17,5 +18,6 @@ class SavedDraft {
|
|||||||
required this.subjectText,
|
required this.subjectText,
|
||||||
required this.bodyText,
|
required this.bodyText,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
|
this.imapServerId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class Email {
|
|||||||
final String? references;
|
final String? references;
|
||||||
final DateTime? snoozedUntil;
|
final DateTime? snoozedUntil;
|
||||||
final String? snoozedFromMailboxPath;
|
final String? snoozedFromMailboxPath;
|
||||||
|
// RFC 2369 List-Unsubscribe header value, e.g. "<mailto:...>, <https://...>".
|
||||||
|
final String? listUnsubscribeHeader;
|
||||||
|
|
||||||
const Email({
|
const Email({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -43,6 +45,7 @@ class Email {
|
|||||||
this.references,
|
this.references,
|
||||||
this.snoozedUntil,
|
this.snoozedUntil,
|
||||||
this.snoozedFromMailboxPath,
|
this.snoozedFromMailboxPath,
|
||||||
|
this.listUnsubscribeHeader,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Email.fromJson(Map<String, dynamic> json) {
|
factory Email.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -77,6 +80,7 @@ class Email {
|
|||||||
? DateTime.parse(json['snoozedUntil'] as String)
|
? DateTime.parse(json['snoozedUntil'] as String)
|
||||||
: null,
|
: null,
|
||||||
snoozedFromMailboxPath: json['snoozedFromMailboxPath'] as String?,
|
snoozedFromMailboxPath: json['snoozedFromMailboxPath'] as String?,
|
||||||
|
listUnsubscribeHeader: json['listUnsubscribeHeader'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +106,7 @@ class Email {
|
|||||||
'references': references,
|
'references': references,
|
||||||
'snoozedUntil': snoozedUntil?.toIso8601String(),
|
'snoozedUntil': snoozedUntil?.toIso8601String(),
|
||||||
'snoozedFromMailboxPath': snoozedFromMailboxPath,
|
'snoozedFromMailboxPath': snoozedFromMailboxPath,
|
||||||
|
'listUnsubscribeHeader': listUnsubscribeHeader,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +131,7 @@ class Email {
|
|||||||
String? references,
|
String? references,
|
||||||
DateTime? snoozedUntil,
|
DateTime? snoozedUntil,
|
||||||
String? snoozedFromMailboxPath,
|
String? snoozedFromMailboxPath,
|
||||||
|
String? listUnsubscribeHeader,
|
||||||
}) {
|
}) {
|
||||||
return Email(
|
return Email(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -149,6 +155,8 @@ class Email {
|
|||||||
snoozedUntil: snoozedUntil ?? this.snoozedUntil,
|
snoozedUntil: snoozedUntil ?? this.snoozedUntil,
|
||||||
snoozedFromMailboxPath:
|
snoozedFromMailboxPath:
|
||||||
snoozedFromMailboxPath ?? this.snoozedFromMailboxPath,
|
snoozedFromMailboxPath ?? this.snoozedFromMailboxPath,
|
||||||
|
listUnsubscribeHeader:
|
||||||
|
listUnsubscribeHeader ?? this.listUnsubscribeHeader,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,12 +232,29 @@ class EmailHeader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Full message body — fetched on demand, cached in the local DB.
|
/// Full message body — fetched on demand, cached in the local DB.
|
||||||
|
class MimePart {
|
||||||
|
final String contentType;
|
||||||
|
final String? filename;
|
||||||
|
final int? size;
|
||||||
|
final String? encoding;
|
||||||
|
final List<MimePart> children;
|
||||||
|
|
||||||
|
const MimePart({
|
||||||
|
required this.contentType,
|
||||||
|
this.filename,
|
||||||
|
this.size,
|
||||||
|
this.encoding,
|
||||||
|
this.children = const [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class EmailBody {
|
class EmailBody {
|
||||||
final String emailId;
|
final String emailId;
|
||||||
final String? textBody;
|
final String? textBody;
|
||||||
final String? htmlBody;
|
final String? htmlBody;
|
||||||
final List<EmailAttachment> attachments;
|
final List<EmailAttachment> attachments;
|
||||||
final List<EmailHeader> headers;
|
final List<EmailHeader> headers;
|
||||||
|
final MimePart? mimeTree;
|
||||||
|
|
||||||
const EmailBody({
|
const EmailBody({
|
||||||
required this.emailId,
|
required this.emailId,
|
||||||
@@ -237,6 +262,7 @@ class EmailBody {
|
|||||||
this.htmlBody,
|
this.htmlBody,
|
||||||
required this.attachments,
|
required this.attachments,
|
||||||
this.headers = const [],
|
this.headers = const [],
|
||||||
|
this.mimeTree,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,4 +21,10 @@ abstract class DraftRepository {
|
|||||||
|
|
||||||
/// Permanently removes the draft with [id].
|
/// Permanently removes the draft with [id].
|
||||||
Future<void> deleteDraft(int id);
|
Future<void> deleteDraft(int id);
|
||||||
|
|
||||||
|
/// Syncs local drafts with the server IMAP Drafts folder for [accountId].
|
||||||
|
/// Uploads local drafts that have no [SavedDraft.imapServerId]; imports
|
||||||
|
/// server drafts that are not already tracked locally.
|
||||||
|
/// No-op when the implementation has no IMAP connection configured.
|
||||||
|
Future<void> syncDrafts(String accountId, String password);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
|
||||||
abstract class EmailRepository {
|
abstract class EmailRepository {
|
||||||
Stream<List<Email>> observeEmails(String accountId, String mailboxPath);
|
Stream<List<Email>> observeEmails(
|
||||||
|
String accountId,
|
||||||
|
String mailboxPath, {
|
||||||
|
int limit = 50,
|
||||||
|
});
|
||||||
|
|
||||||
/// Groups emails by threadId and returns one [EmailThread] per thread,
|
/// Groups emails by threadId and returns one [EmailThread] per thread,
|
||||||
/// sorted by the latest message date descending.
|
/// sorted by the latest message date descending.
|
||||||
Stream<List<EmailThread>> observeThreads(
|
Stream<List<EmailThread>> observeThreads(
|
||||||
String accountId,
|
String accountId,
|
||||||
String mailboxPath,
|
String mailboxPath, {
|
||||||
);
|
int limit = 50,
|
||||||
|
});
|
||||||
|
|
||||||
/// Returns all emails belonging to [threadId] in [mailboxPath].
|
/// Returns all emails belonging to [threadId] in [mailboxPath].
|
||||||
Stream<List<Email>> observeEmailsInThread(
|
Stream<List<Email>> observeEmailsInThread(
|
||||||
@@ -22,6 +27,7 @@ abstract class EmailRepository {
|
|||||||
Future<EmailBody> getEmailBody(String emailId);
|
Future<EmailBody> getEmailBody(String emailId);
|
||||||
Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath);
|
Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath);
|
||||||
Future<void> setFlag(String emailId, {bool? seen, bool? flagged});
|
Future<void> setFlag(String emailId, {bool? seen, bool? flagged});
|
||||||
|
Future<void> markAllAsRead(String accountId, String mailboxPath);
|
||||||
Future<void> moveEmail(String emailId, String destMailboxPath);
|
Future<void> moveEmail(String emailId, String destMailboxPath);
|
||||||
|
|
||||||
/// Deletes the email. Returns the path of the mailbox it was moved to
|
/// Deletes the email. Returns the path of the mailbox it was moved to
|
||||||
@@ -35,6 +41,10 @@ abstract class EmailRepository {
|
|||||||
/// return the cached path without a network round-trip.
|
/// return the cached path without a network round-trip.
|
||||||
Future<String> downloadAttachment(String emailId, EmailAttachment attachment);
|
Future<String> downloadAttachment(String emailId, EmailAttachment attachment);
|
||||||
|
|
||||||
|
/// Fetches the original RFC 2822 message from the server as a raw string.
|
||||||
|
/// Always performs a live network request — the raw message is not cached.
|
||||||
|
Future<String> fetchRawRfc822(String emailId);
|
||||||
|
|
||||||
/// Returns emails in [mailboxPath] whose subject or body contain [query].
|
/// Returns emails in [mailboxPath] whose subject or body contain [query].
|
||||||
/// Results come from the server (IMAP SEARCH) and are not cached.
|
/// Results come from the server (IMAP SEARCH) and are not cached.
|
||||||
Future<List<Email>> searchEmails(
|
Future<List<Email>> searchEmails(
|
||||||
@@ -51,6 +61,14 @@ abstract class EmailRepository {
|
|||||||
/// accounts if null) whose from, to, or cc fields contain [address].
|
/// accounts if null) whose from, to, or cc fields contain [address].
|
||||||
Future<List<Email>> getEmailsByAddress(String? accountId, String address);
|
Future<List<Email>> getEmailsByAddress(String? accountId, String address);
|
||||||
|
|
||||||
|
/// Returns unique email addresses from the local cache whose email or display
|
||||||
|
/// name contains [query]. Results are deduplicated and capped at [limit].
|
||||||
|
Future<List<EmailAddress>> searchAddresses(
|
||||||
|
String? accountId,
|
||||||
|
String query, {
|
||||||
|
int limit = 10,
|
||||||
|
});
|
||||||
|
|
||||||
/// Sends any queued local mutations for [accountId] to the server.
|
/// Sends any queued local mutations for [accountId] to the server.
|
||||||
/// Returns the number of changes successfully applied.
|
/// Returns the number of changes successfully applied.
|
||||||
Future<int> flushPendingChanges(String accountId, String password);
|
Future<int> flushPendingChanges(String accountId, String password);
|
||||||
@@ -81,6 +99,17 @@ abstract class EmailRepository {
|
|||||||
/// Used for the "Undo" feature when the original rows were hard-deleted (IMAP).
|
/// Used for the "Undo" feature when the original rows were hard-deleted (IMAP).
|
||||||
Future<void> restoreEmails(List<Email> emails);
|
Future<void> restoreEmails(List<Email> emails);
|
||||||
|
|
||||||
|
/// Finds an email in [accountId]'s mailboxes by its RFC 2822 Message-ID header.
|
||||||
|
/// Returns null if not found. Used during undo to locate an email after its
|
||||||
|
/// IMAP UID changed (e.g. after a server-applied move assigned a new UID).
|
||||||
|
Future<Email?> findEmailByMessageId(String accountId, String messageId);
|
||||||
|
|
||||||
|
/// Applies locally stored active Sieve rules to INBOX emails that have not
|
||||||
|
/// been processed yet. Records each processed email in LocalSieveApplied so
|
||||||
|
/// the same email is never filtered twice (across restarts or re-syncs).
|
||||||
|
/// Returns the number of emails where a rule matched and an action was taken.
|
||||||
|
Future<int> applySieveRules(String accountId);
|
||||||
|
|
||||||
/// Emits the accountId whenever a new change is enqueued locally.
|
/// Emits the accountId whenever a new change is enqueued locally.
|
||||||
/// Used by AccountSyncManager to trigger an immediate flush.
|
/// Used by AccountSyncManager to trigger an immediate flush.
|
||||||
Stream<String> get onChangesQueued;
|
Stream<String> get onChangesQueued;
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
abstract interface class SearchHistoryRepository {
|
||||||
|
Future<List<String>> getRecentSearches();
|
||||||
|
Future<void> saveSearch(String query);
|
||||||
|
Future<void> clearHistory();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
||||||
|
|
||||||
|
/// Stores and retrieves ephemeral X25519 key pairs for secure account sharing.
|
||||||
|
abstract class ShareKeyRepository {
|
||||||
|
/// Generates a new key pair and persists it with a 20-minute expiry.
|
||||||
|
Future<ShareKeyMaterial> createKeyPair();
|
||||||
|
|
||||||
|
/// Returns the key pair whose ID matches [keyId], or null if not found /
|
||||||
|
/// expired.
|
||||||
|
Future<ShareKeyMaterial?> findByKeyId(Uint8List keyId);
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@ class MailboxSyncStats {
|
|||||||
required this.fetched,
|
required this.fetched,
|
||||||
required this.skipped,
|
required this.skipped,
|
||||||
required this.bytesTransferred,
|
required this.bytesTransferred,
|
||||||
|
this.duration,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String mailboxPath;
|
final String mailboxPath;
|
||||||
final int fetched;
|
final int fetched;
|
||||||
final int skipped;
|
final int skipped;
|
||||||
final int bytesTransferred;
|
final int bytesTransferred;
|
||||||
|
final Duration? duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SyncLogEntry {
|
class SyncLogEntry {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
|
const _kChannelId = 'new_mail';
|
||||||
|
const _kChannelName = 'New mail';
|
||||||
|
|
||||||
|
final _plugin = FlutterLocalNotificationsPlugin();
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
|
Future<void> initNotifications() async {
|
||||||
|
try {
|
||||||
|
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||||
|
await _plugin.initialize(
|
||||||
|
const InitializationSettings(android: android),
|
||||||
|
onDidReceiveNotificationResponse: (_) {},
|
||||||
|
);
|
||||||
|
await _plugin
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin>()
|
||||||
|
?.requestNotificationsPermission();
|
||||||
|
_initialized = true;
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Plugin not registered on this device; notifications silently disabled.
|
||||||
|
} catch (_) {
|
||||||
|
// Unexpected initialization failure; notifications silently disabled.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showNewMailNotification(String accountEmail) async {
|
||||||
|
if (!Platform.isAndroid || !_initialized) return;
|
||||||
|
await _plugin.show(
|
||||||
|
accountEmail.hashCode & 0x7FFFFFFF,
|
||||||
|
'New mail',
|
||||||
|
accountEmail,
|
||||||
|
const NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
_kChannelId,
|
||||||
|
_kChannelName,
|
||||||
|
channelDescription: 'Notifications for new incoming mail',
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:cryptography/cryptography.dart';
|
||||||
|
|
||||||
|
const _pubKeyPrefix = 'sharedinbox.de:pubkey:v1:';
|
||||||
|
const _encAccountsPrefix = 'sharedinbox.de:encrypted-accounts:v1:';
|
||||||
|
|
||||||
|
// ECIES wire sizes (bytes).
|
||||||
|
const _keyIdLen = 16;
|
||||||
|
const _pubKeyLen = 32;
|
||||||
|
const _nonceLen = 12;
|
||||||
|
const _macLen = 16;
|
||||||
|
|
||||||
|
/// Describes a freshly generated key pair before it is written to the database.
|
||||||
|
class ShareKeyMaterial {
|
||||||
|
const ShareKeyMaterial({
|
||||||
|
required this.keyId,
|
||||||
|
required this.publicKeyBytes,
|
||||||
|
required this.privateKeyBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Random 16-byte identifier (hex-encoded when stored / included in QR).
|
||||||
|
final Uint8List keyId;
|
||||||
|
|
||||||
|
/// X25519 public key, 32 bytes.
|
||||||
|
final Uint8List publicKeyBytes;
|
||||||
|
|
||||||
|
/// X25519 private key, 32 bytes.
|
||||||
|
final Uint8List privateKeyBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An account + password pair, used in the plaintext payload before encryption.
|
||||||
|
class AccountPayload {
|
||||||
|
const AccountPayload({required this.accountJson, required this.password});
|
||||||
|
|
||||||
|
final Map<String, dynamic> accountJson;
|
||||||
|
final String password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure-Dart cryptographic helpers for the secure account-sharing flow.
|
||||||
|
///
|
||||||
|
/// Protocol:
|
||||||
|
/// Receiver generates an X25519 key pair with 20-minute lifetime and shows
|
||||||
|
/// its public key as a QR code. The sender scans that QR, encrypts the
|
||||||
|
/// selected account(s) using ECIES (X25519-ECDH + HKDF-SHA256 + AES-256-GCM)
|
||||||
|
/// and shows the encrypted payload as a QR code. The receiver scans that QR,
|
||||||
|
/// looks up the private key by the embedded key-ID, and decrypts.
|
||||||
|
class ShareEncryptionService {
|
||||||
|
static final _x25519 = X25519();
|
||||||
|
static final _aesGcm = AesGcm.with256bits();
|
||||||
|
static final _hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
|
||||||
|
static final _rng = Random.secure();
|
||||||
|
|
||||||
|
// ── Key generation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static Future<ShareKeyMaterial> generateKeyPair() async {
|
||||||
|
final keyId = Uint8List(_keyIdLen);
|
||||||
|
for (var i = 0; i < _keyIdLen; i++) {
|
||||||
|
keyId[i] = _rng.nextInt(256);
|
||||||
|
}
|
||||||
|
|
||||||
|
final keyPair = await _x25519.newKeyPair();
|
||||||
|
final pub = await keyPair.extractPublicKey();
|
||||||
|
final priv = await keyPair.extractPrivateKeyBytes();
|
||||||
|
|
||||||
|
return ShareKeyMaterial(
|
||||||
|
keyId: keyId,
|
||||||
|
publicKeyBytes: Uint8List.fromList(pub.bytes),
|
||||||
|
privateKeyBytes: Uint8List.fromList(priv),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public-key QR encoding / parsing ────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Encodes the receiver's public key as a QR-code string.
|
||||||
|
///
|
||||||
|
/// Format: `sharedinbox.de:pubkey:v1:<base64(keyId[16] || pubKey[32])>`
|
||||||
|
static String encodePublicKeyQr(Uint8List keyId, Uint8List publicKeyBytes) {
|
||||||
|
assert(keyId.length == _keyIdLen);
|
||||||
|
assert(publicKeyBytes.length == _pubKeyLen);
|
||||||
|
final data = Uint8List(_keyIdLen + _pubKeyLen)
|
||||||
|
..setAll(0, keyId)
|
||||||
|
..setAll(_keyIdLen, publicKeyBytes);
|
||||||
|
return '$_pubKeyPrefix${base64.encode(data)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a public-key QR string. Returns null if the format is invalid.
|
||||||
|
static ({Uint8List keyId, Uint8List publicKeyBytes})? parsePublicKeyQr(
|
||||||
|
String s,
|
||||||
|
) {
|
||||||
|
if (!s.startsWith(_pubKeyPrefix)) return null;
|
||||||
|
try {
|
||||||
|
final data =
|
||||||
|
Uint8List.fromList(base64.decode(s.substring(_pubKeyPrefix.length)));
|
||||||
|
if (data.length != _keyIdLen + _pubKeyLen) return null;
|
||||||
|
return (
|
||||||
|
keyId: data.sublist(0, _keyIdLen),
|
||||||
|
publicKeyBytes: data.sublist(_keyIdLen),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Encryption ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Encrypts [accounts] for the given recipient key pair using ECIES.
|
||||||
|
///
|
||||||
|
/// Returns the QR-code string to show on the sender device.
|
||||||
|
///
|
||||||
|
/// Wire format (base64-encoded):
|
||||||
|
/// keyId[16] || ephPubKey[32] || nonce[12] || ciphertext || mac[16]
|
||||||
|
static Future<String> encryptAccounts({
|
||||||
|
required Uint8List recipientKeyId,
|
||||||
|
required Uint8List recipientPublicKeyBytes,
|
||||||
|
required List<AccountPayload> accounts,
|
||||||
|
}) async {
|
||||||
|
// Build plaintext JSON.
|
||||||
|
final plaintext = utf8.encode(
|
||||||
|
jsonEncode({
|
||||||
|
'v': 2,
|
||||||
|
'issuedAt': DateTime.now().toUtc().toIso8601String(),
|
||||||
|
'accounts': accounts
|
||||||
|
.map((a) => {'account': a.accountJson, 'password': a.password})
|
||||||
|
.toList(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ephemeral sender key pair for forward-secrecy.
|
||||||
|
final ephKeyPair = await _x25519.newKeyPair();
|
||||||
|
final ephPub = await ephKeyPair.extractPublicKey();
|
||||||
|
|
||||||
|
// ECDH: shared secret = X25519(ephPriv, recipientPub).
|
||||||
|
final sharedSecret = await _x25519.sharedSecretKey(
|
||||||
|
keyPair: ephKeyPair,
|
||||||
|
remotePublicKey: SimplePublicKey(
|
||||||
|
recipientPublicKeyBytes,
|
||||||
|
type: KeyPairType.x25519,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derive AES key via HKDF-SHA256.
|
||||||
|
final aesKey = await _hkdf.deriveKey(
|
||||||
|
secretKey: sharedSecret,
|
||||||
|
nonce: recipientKeyId,
|
||||||
|
info: utf8.encode('sharedinbox-account-transfer'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Encrypt with AES-256-GCM.
|
||||||
|
final nonce = Uint8List(_nonceLen);
|
||||||
|
for (var i = 0; i < _nonceLen; i++) {
|
||||||
|
nonce[i] = _rng.nextInt(256);
|
||||||
|
}
|
||||||
|
|
||||||
|
final box = await _aesGcm.encrypt(
|
||||||
|
plaintext,
|
||||||
|
secretKey: aesKey,
|
||||||
|
nonce: nonce,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pack wire format.
|
||||||
|
final ephPubBytes = Uint8List.fromList(ephPub.bytes);
|
||||||
|
final cipherBytes = Uint8List.fromList(box.cipherText);
|
||||||
|
final macBytes = Uint8List.fromList(box.mac.bytes);
|
||||||
|
|
||||||
|
final out = Uint8List(
|
||||||
|
_keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length + _macLen,
|
||||||
|
)
|
||||||
|
..setAll(0, recipientKeyId)
|
||||||
|
..setAll(_keyIdLen, ephPubBytes)
|
||||||
|
..setAll(_keyIdLen + _pubKeyLen, nonce)
|
||||||
|
..setAll(_keyIdLen + _pubKeyLen + _nonceLen, cipherBytes)
|
||||||
|
..setAll(
|
||||||
|
_keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length,
|
||||||
|
macBytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
return '$_encAccountsPrefix${base64.encode(out)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decryption ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Parses and decrypts an encrypted-accounts QR string.
|
||||||
|
///
|
||||||
|
/// Throws [FormatException] if the format is invalid.
|
||||||
|
/// Throws [SecretBoxAuthenticationError] if authentication fails (tampered).
|
||||||
|
static Future<List<AccountPayload>> decryptAccounts({
|
||||||
|
required String qrString,
|
||||||
|
required Uint8List privateKeyBytes,
|
||||||
|
required Uint8List publicKeyBytes,
|
||||||
|
required Uint8List keyId,
|
||||||
|
}) async {
|
||||||
|
if (!qrString.startsWith(_encAccountsPrefix)) {
|
||||||
|
throw const FormatException('Not an encrypted-accounts QR code');
|
||||||
|
}
|
||||||
|
|
||||||
|
final Uint8List data;
|
||||||
|
try {
|
||||||
|
data = Uint8List.fromList(
|
||||||
|
base64.decode(qrString.substring(_encAccountsPrefix.length)),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
throw const FormatException('Invalid base64 in encrypted-accounts QR');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimum: keyId + ephPubKey + nonce + mac (no ciphertext is valid but odd).
|
||||||
|
if (data.length < _keyIdLen + _pubKeyLen + _nonceLen + _macLen) {
|
||||||
|
throw const FormatException('Encrypted-accounts payload too short');
|
||||||
|
}
|
||||||
|
|
||||||
|
final embeddedKeyId = data.sublist(0, _keyIdLen);
|
||||||
|
// Verify that this payload was encrypted for the right key pair.
|
||||||
|
for (var i = 0; i < _keyIdLen; i++) {
|
||||||
|
if (embeddedKeyId[i] != keyId[i]) {
|
||||||
|
throw const FormatException(
|
||||||
|
'Key ID mismatch — please scan a fresh public-key QR code',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final ephPubBytes = data.sublist(_keyIdLen, _keyIdLen + _pubKeyLen);
|
||||||
|
final nonce = data.sublist(
|
||||||
|
_keyIdLen + _pubKeyLen,
|
||||||
|
_keyIdLen + _pubKeyLen + _nonceLen,
|
||||||
|
);
|
||||||
|
final cipherText = data.sublist(
|
||||||
|
_keyIdLen + _pubKeyLen + _nonceLen,
|
||||||
|
data.length - _macLen,
|
||||||
|
);
|
||||||
|
final mac = data.sublist(data.length - _macLen);
|
||||||
|
|
||||||
|
// Reconstruct key pair.
|
||||||
|
final keyPair = SimpleKeyPairData(
|
||||||
|
privateKeyBytes,
|
||||||
|
publicKey: SimplePublicKey(publicKeyBytes, type: KeyPairType.x25519),
|
||||||
|
type: KeyPairType.x25519,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ECDH.
|
||||||
|
final sharedSecret = await _x25519.sharedSecretKey(
|
||||||
|
keyPair: keyPair,
|
||||||
|
remotePublicKey: SimplePublicKey(ephPubBytes, type: KeyPairType.x25519),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-derive AES key.
|
||||||
|
final aesKey = await _hkdf.deriveKey(
|
||||||
|
secretKey: sharedSecret,
|
||||||
|
nonce: keyId,
|
||||||
|
info: utf8.encode('sharedinbox-account-transfer'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Decrypt — throws SecretBoxAuthenticationError if tampered.
|
||||||
|
final plaintext = await _aesGcm.decrypt(
|
||||||
|
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
|
||||||
|
secretKey: aesKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse JSON.
|
||||||
|
final Map<String, dynamic> json;
|
||||||
|
try {
|
||||||
|
json = jsonDecode(utf8.decode(plaintext)) as Map<String, dynamic>;
|
||||||
|
} catch (_) {
|
||||||
|
throw const FormatException('Decrypted payload is not valid JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((json['v'] as int?) != 2) {
|
||||||
|
throw const FormatException('Unsupported encrypted-accounts version');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify issuedAt is within 20 minutes.
|
||||||
|
final issuedAtRaw = json['issuedAt'] as String?;
|
||||||
|
if (issuedAtRaw != null) {
|
||||||
|
final issuedAt = DateTime.tryParse(issuedAtRaw);
|
||||||
|
if (issuedAt != null) {
|
||||||
|
final age = DateTime.now().toUtc().difference(issuedAt.toUtc());
|
||||||
|
if (age.abs() > const Duration(minutes: 20)) {
|
||||||
|
throw const FormatException(
|
||||||
|
'The encrypted payload has expired (older than 20 minutes)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final rawAccounts = json['accounts'] as List<dynamic>;
|
||||||
|
return rawAccounts.map((entry) {
|
||||||
|
final m = entry as Map<String, dynamic>;
|
||||||
|
return AccountPayload(
|
||||||
|
accountJson: m['account'] as Map<String, dynamic>,
|
||||||
|
password: m['password'] as String,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,10 +26,10 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
|||||||
final newList = [...state, action];
|
final newList = [...state, action];
|
||||||
if (newList.length > _maxHistory) {
|
if (newList.length > _maxHistory) {
|
||||||
final removed = newList.removeAt(0);
|
final removed = newList.removeAt(0);
|
||||||
unawaited(_ref.read(undoRepositoryProvider).deleteAction(removed.id));
|
await _ref.read(undoRepositoryProvider).deleteAction(removed.id);
|
||||||
}
|
}
|
||||||
state = newList;
|
state = newList;
|
||||||
unawaited(_ref.read(undoRepositoryProvider).saveAction(action));
|
await _ref.read(undoRepositoryProvider).saveAction(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
@@ -45,17 +45,17 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
|||||||
final UndoAction action;
|
final UndoAction action;
|
||||||
if (actionId == null) {
|
if (actionId == null) {
|
||||||
action = state.last;
|
action = state.last;
|
||||||
state = state.sublist(0, state.length - 1);
|
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
action = state.firstWhere((a) => a.id == actionId);
|
action = state.firstWhere((a) => a.id == actionId);
|
||||||
state = state.where((a) => a.id != actionId).toList();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return; // Action not found
|
return; // Action not found
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
unawaited(_ref.read(undoRepositoryProvider).deleteAction(action.id));
|
// Keep the original entry in state and DB so the user can see what
|
||||||
|
// happened and retry if the undo failed (e.g. after an IMAP sync reverted
|
||||||
|
// the local change). The inverse action added below allows undoing the undo.
|
||||||
|
|
||||||
final repo = _ref.read(emailRepositoryProvider);
|
final repo = _ref.read(emailRepositoryProvider);
|
||||||
|
|
||||||
@@ -70,10 +70,22 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
|||||||
? null
|
? null
|
||||||
: action.originalEmails.where((e) => e.id == id).firstOrNull;
|
: action.originalEmails.where((e) => e.id == id).firstOrNull;
|
||||||
|
|
||||||
// 2. If row is missing (hard delete), restore it first.
|
// 2. Resolve the current DB row for the email.
|
||||||
// We restore it at its CURRENT state (where it is on the server,
|
// For IMAP, after a server-applied move the email gets a new UID, so
|
||||||
// or where it was moving to).
|
// the original id ('accountId:oldUid') no longer exists. Look it up by
|
||||||
if (original != null) {
|
// Message-ID so we use the correct UID in the pending change.
|
||||||
|
var currentEmail = await repo.getEmail(id);
|
||||||
|
if (currentEmail == null && original?.messageId != null) {
|
||||||
|
currentEmail = await repo.findEmailByMessageId(
|
||||||
|
action.accountId,
|
||||||
|
original!.messageId!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final currentId = currentEmail?.id ?? id;
|
||||||
|
|
||||||
|
// 3. If the row is absent (hard delete or UID changed after sync),
|
||||||
|
// restore it from the saved snapshot so moveEmail can find it.
|
||||||
|
if (currentEmail == null && original != null) {
|
||||||
final currentPath = cancelled
|
final currentPath = cancelled
|
||||||
? action.sourceMailboxPath
|
? action.sourceMailboxPath
|
||||||
: (action.destinationMailboxPath ?? action.sourceMailboxPath);
|
: (action.destinationMailboxPath ?? action.sourceMailboxPath);
|
||||||
@@ -82,19 +94,40 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Move it back to source.
|
// 4. Move it back to source.
|
||||||
// This updates local DB optimistically and (if not cancelled) enqueues
|
// This updates local DB optimistically and (if not cancelled) enqueues
|
||||||
// a reverse move on the server.
|
// a reverse move on the server using the correct UID.
|
||||||
await repo.moveEmail(id, action.sourceMailboxPath);
|
await repo.moveEmail(currentId, action.sourceMailboxPath);
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
// 4. If we successfully cancelled the original, the reverse move
|
// 5. If we successfully cancelled the original, the reverse move
|
||||||
// we just enqueued is redundant.
|
// we just enqueued is redundant.
|
||||||
await repo.cancelPendingChange(id, 'move');
|
await repo.cancelPendingChange(currentId, 'move');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Best effort.
|
// Best effort.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add a reverse action so the undo log always retains a record and the
|
||||||
|
// user can re-apply the original operation. sourceMailboxPath on the
|
||||||
|
// inverse is the original destination (e.g. Trash) so that undoing the
|
||||||
|
// inverse moves emails back there; destinationMailboxPath records where
|
||||||
|
// they are now (the original source, e.g. INBOX).
|
||||||
|
final inverseDest = action.destinationMailboxPath;
|
||||||
|
if (inverseDest != null) {
|
||||||
|
await pushAction(
|
||||||
|
UndoAction(
|
||||||
|
id: '${action.id}-inv',
|
||||||
|
accountId: action.accountId,
|
||||||
|
type: UndoType.move,
|
||||||
|
emailIds: action.emailIds,
|
||||||
|
sourceMailboxPath: inverseDest,
|
||||||
|
destinationMailboxPath: action.sourceMailboxPath,
|
||||||
|
originalEmails: action.originalEmails,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
const _kAppVersion = String.fromEnvironment('GIT_HASH');
|
||||||
|
const _kLatestJsonUrl = 'https://sharedinbox.de/latest.json';
|
||||||
|
|
||||||
|
class UpdateInfo {
|
||||||
|
const UpdateInfo({required this.latestVersion, required this.downloadUrl});
|
||||||
|
|
||||||
|
final String latestVersion;
|
||||||
|
final String downloadUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an [UpdateInfo] when a newer Linux or Windows version is available,
|
||||||
|
/// or null if the app is up to date, the version is unknown, or the platform
|
||||||
|
/// is not a supported desktop.
|
||||||
|
final updateInfoProvider = FutureProvider<UpdateInfo?>((ref) async {
|
||||||
|
final platformKey = Platform.isLinux
|
||||||
|
? 'linux'
|
||||||
|
: Platform.isWindows
|
||||||
|
? 'windows'
|
||||||
|
: null;
|
||||||
|
if (platformKey == null || _kAppVersion.isEmpty) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final resp = await http
|
||||||
|
.get(Uri.parse(_kLatestJsonUrl))
|
||||||
|
.timeout(const Duration(seconds: 10));
|
||||||
|
if (resp.statusCode != 200) return null;
|
||||||
|
final json = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||||
|
final latest = json['version'] as String?;
|
||||||
|
final url = json[platformKey] as String?;
|
||||||
|
if (latest == null || url == null) return null;
|
||||||
|
if (latest == _kAppVersion) return null;
|
||||||
|
return UpdateInfo(latestVersion: latest, downloadUrl: url);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
sealed class SieveAction {}
|
||||||
|
|
||||||
|
final class FileIntoAction extends SieveAction {
|
||||||
|
FileIntoAction(this.folder);
|
||||||
|
final String folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class KeepAction extends SieveAction {}
|
||||||
|
|
||||||
|
final class DiscardAction extends SieveAction {}
|
||||||
|
|
||||||
|
final class MarkAsSeenAction extends SieveAction {}
|
||||||
|
|
||||||
|
final class FlagAction extends SieveAction {
|
||||||
|
FlagAction(this.flags);
|
||||||
|
final List<String> flags;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
sealed class SieveCondition {}
|
||||||
|
|
||||||
|
final class HeaderCondition extends SieveCondition {
|
||||||
|
HeaderCondition(this.headers, this.matchType, this.keyList);
|
||||||
|
final List<String> headers;
|
||||||
|
final String matchType; // ':contains', ':is', ':matches'
|
||||||
|
final List<String> keyList;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SizeCondition extends SieveCondition {
|
||||||
|
SizeCondition(this.comparison, this.bytes);
|
||||||
|
final String comparison; // ':over' or ':under'
|
||||||
|
final int bytes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
|
||||||
|
|
||||||
|
/// A lightweight email representation used by [SieveInterpreter].
|
||||||
|
/// Header names are lower-cased.
|
||||||
|
class SieveEmailContext {
|
||||||
|
const SieveEmailContext({required this.headers, this.sizeBytes = 0});
|
||||||
|
|
||||||
|
final Map<String, List<String>> headers;
|
||||||
|
final int sizeBytes;
|
||||||
|
|
||||||
|
List<String> getHeader(String name) =>
|
||||||
|
headers[name.toLowerCase()] ?? const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks the outcome of running a Sieve script against one email.
|
||||||
|
class SieveExecutionContext {
|
||||||
|
bool isCancelled = false;
|
||||||
|
Set<String> targetFolders = {};
|
||||||
|
Set<String> flagsToAdd = {};
|
||||||
|
bool keepInInbox = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluates a compiled list of [SieveRule]s against a [SieveEmailContext].
|
||||||
|
class SieveInterpreter {
|
||||||
|
/// Executes [rules] and returns the resulting [SieveExecutionContext].
|
||||||
|
///
|
||||||
|
/// Rules produced by [SieveParser] may carry a [SieveRule.branchGroupId]
|
||||||
|
/// to represent if/elsif/else chains; at most one branch per group fires.
|
||||||
|
SieveExecutionContext execute(
|
||||||
|
List<SieveRule> rules,
|
||||||
|
SieveEmailContext email,
|
||||||
|
) {
|
||||||
|
final ctx = SieveExecutionContext();
|
||||||
|
final firedGroups = <int>{};
|
||||||
|
|
||||||
|
for (final rule in rules) {
|
||||||
|
if (ctx.isCancelled) break;
|
||||||
|
|
||||||
|
final groupId = rule.branchGroupId;
|
||||||
|
if (groupId != null && firedGroups.contains(groupId)) continue;
|
||||||
|
|
||||||
|
bool matches;
|
||||||
|
if (rule.isElseBranch) {
|
||||||
|
matches = true; // else fires unconditionally (group not yet consumed)
|
||||||
|
} else {
|
||||||
|
matches = _evaluateConditions(rule, email);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches) {
|
||||||
|
_applyActions(rule.actions, ctx);
|
||||||
|
if (groupId != null) firedGroups.add(groupId);
|
||||||
|
if (ctx.isCancelled) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implicit keep: if no fileinto/discard was reached, email stays in inbox.
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _evaluateConditions(SieveRule rule, SieveEmailContext email) {
|
||||||
|
if (rule.conditions.isEmpty) return true;
|
||||||
|
return switch (rule.joinType) {
|
||||||
|
'allof' => rule.conditions.every((c) => _evalCondition(c, email)),
|
||||||
|
'anyof' => rule.conditions.any((c) => _evalCondition(c, email)),
|
||||||
|
_ => rule.conditions.length == 1 &&
|
||||||
|
_evalCondition(rule.conditions.first, email),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _evalCondition(SieveCondition cond, SieveEmailContext email) {
|
||||||
|
return switch (cond) {
|
||||||
|
final HeaderCondition c => _evalHeader(c, email),
|
||||||
|
final SizeCondition c => _evalSize(c, email),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _evalHeader(HeaderCondition cond, SieveEmailContext email) {
|
||||||
|
for (final header in cond.headers) {
|
||||||
|
final values = email.getHeader(header);
|
||||||
|
for (final value in values) {
|
||||||
|
for (final key in cond.keyList) {
|
||||||
|
if (_matchString(value, cond.matchType, key)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _evalSize(SizeCondition cond, SieveEmailContext email) {
|
||||||
|
return switch (cond.comparison) {
|
||||||
|
':over' => email.sizeBytes > cond.bytes,
|
||||||
|
':under' => email.sizeBytes < cond.bytes,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _matchString(String value, String matchType, String key) {
|
||||||
|
final v = value.toLowerCase();
|
||||||
|
final k = key.toLowerCase();
|
||||||
|
return switch (matchType) {
|
||||||
|
':contains' => k.isEmpty || v.contains(k),
|
||||||
|
':is' => v == k,
|
||||||
|
':matches' => _globMatch(v, k),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _globMatch(String value, String pattern) {
|
||||||
|
final regexStr =
|
||||||
|
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
||||||
|
return RegExp('^$regexStr\$').hasMatch(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyActions(List<SieveAction> actions, SieveExecutionContext ctx) {
|
||||||
|
for (final action in actions) {
|
||||||
|
switch (action) {
|
||||||
|
case final FileIntoAction a:
|
||||||
|
ctx.targetFolders.add(a.folder);
|
||||||
|
ctx.keepInInbox = false;
|
||||||
|
case DiscardAction():
|
||||||
|
ctx.isCancelled = true;
|
||||||
|
ctx.keepInInbox = false;
|
||||||
|
return;
|
||||||
|
case KeepAction():
|
||||||
|
ctx.keepInInbox = true;
|
||||||
|
case MarkAsSeenAction():
|
||||||
|
ctx.flagsToAdd.add(r'\Seen');
|
||||||
|
case final FlagAction a:
|
||||||
|
ctx.flagsToAdd.addAll(a.flags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,593 @@
|
|||||||
|
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
|
||||||
|
|
||||||
|
/// Parses a Sieve script (RFC 5228 subset) into a flat list of [SieveRule]s.
|
||||||
|
///
|
||||||
|
/// Supported commands: require, if, elsif, else, fileinto, keep, discard,
|
||||||
|
/// flag, setflag, addflag, stop.
|
||||||
|
/// Supported tests: header, address, size, exists, allof, anyof, not, true.
|
||||||
|
/// Supported match types: :contains, :is, :matches.
|
||||||
|
class SieveParser {
|
||||||
|
List<SieveRule> parse(String script) {
|
||||||
|
final scanner = _Scanner(script);
|
||||||
|
final rules = <SieveRule>[];
|
||||||
|
_parseStatements(scanner, rules);
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _parseStatements(_Scanner s, List<SieveRule> out) {
|
||||||
|
while (!s.isAtEnd) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.isAtEnd) break;
|
||||||
|
|
||||||
|
final word = s.peekWord();
|
||||||
|
if (word == null) break;
|
||||||
|
|
||||||
|
if (word == 'require') {
|
||||||
|
_parseRequire(s);
|
||||||
|
} else if (word == 'if') {
|
||||||
|
_parseIf(s, out);
|
||||||
|
} else if (word == 'elsif' || word == 'else') {
|
||||||
|
// Reached by _parseIf, should not appear at top level.
|
||||||
|
break;
|
||||||
|
} else if (word == '}') {
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
final action = _tryParseAction(s);
|
||||||
|
if (action != null) {
|
||||||
|
out.add(
|
||||||
|
SieveRule(
|
||||||
|
joinType: 'single',
|
||||||
|
conditions: const [],
|
||||||
|
actions: [action],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
s.skipToNextSemicolon();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _parseRequire(_Scanner s) {
|
||||||
|
s.expectWord('require');
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
_parseStringOrList(s); // discard capability list
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar(';');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monotonically increasing id shared per parse run, threaded via closure.
|
||||||
|
int _groupCounter = 0;
|
||||||
|
|
||||||
|
void _parseIf(_Scanner s, List<SieveRule> out) {
|
||||||
|
final groupId = ++_groupCounter;
|
||||||
|
|
||||||
|
s.expectWord('if');
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final (joinType, conditions) = _parseTest(s);
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final ifActions = _parseBlock(s);
|
||||||
|
|
||||||
|
out.add(
|
||||||
|
SieveRule(
|
||||||
|
joinType: joinType,
|
||||||
|
conditions: conditions,
|
||||||
|
actions: ifActions,
|
||||||
|
branchGroupId: groupId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse zero or more elsif branches.
|
||||||
|
while (true) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.peekWord() != 'elsif') break;
|
||||||
|
s.expectWord('elsif');
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final (ej, ec) = _parseTest(s);
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final elsifActions = _parseBlock(s);
|
||||||
|
out.add(
|
||||||
|
SieveRule(
|
||||||
|
joinType: ej,
|
||||||
|
conditions: ec,
|
||||||
|
actions: elsifActions,
|
||||||
|
branchGroupId: groupId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional else branch.
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.peekWord() == 'else') {
|
||||||
|
s.expectWord('else');
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final elseActions = _parseBlock(s);
|
||||||
|
out.add(
|
||||||
|
SieveRule(
|
||||||
|
joinType: 'single',
|
||||||
|
conditions: const [],
|
||||||
|
actions: elseActions,
|
||||||
|
branchGroupId: groupId,
|
||||||
|
isElseBranch: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SieveAction> _parseBlock(_Scanner s) {
|
||||||
|
s.expectChar('{');
|
||||||
|
final blockRules = <SieveRule>[];
|
||||||
|
_parseStatements(s, blockRules);
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar('}');
|
||||||
|
return blockRules.expand((r) => r.actions).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns (joinType, conditions).
|
||||||
|
(String, List<SieveCondition>) _parseTest(_Scanner s) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final word = s.peekWord();
|
||||||
|
|
||||||
|
if (word == 'allof' || word == 'anyof') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar('(');
|
||||||
|
final conditions = <SieveCondition>[];
|
||||||
|
while (true) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.peek() == ')') break;
|
||||||
|
final (_, conds) = _parseTest(s);
|
||||||
|
conditions.addAll(conds);
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.peek() == ',') {
|
||||||
|
s.advance();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar(')');
|
||||||
|
return (word!, conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
final cond = _parseSingleTest(s);
|
||||||
|
return ('single', cond != null ? [cond] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
SieveCondition? _parseSingleTest(_Scanner s) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final word = s.peekWord()?.toLowerCase();
|
||||||
|
if (word == null) return null;
|
||||||
|
|
||||||
|
if (word == 'not') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
// Negation is not represented in the flat rule model; the caller
|
||||||
|
// should handle the negated condition separately. For now we parse
|
||||||
|
// and return the inner condition unchanged (best-effort for this subset).
|
||||||
|
return _parseSingleTest(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (word == 'true') {
|
||||||
|
s.readWord();
|
||||||
|
return null; // no condition = always matches
|
||||||
|
}
|
||||||
|
|
||||||
|
if (word == 'header' || word == 'address') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final matchType = _parseMatchType(s);
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
// Consume optional :comparator "..." tagged argument.
|
||||||
|
if (s.peekTaggedArg() == ':comparator') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
_parseStringOrList(s); // discard comparator value
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
}
|
||||||
|
final headers = _parseStringOrList(s);
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final keys = _parseStringOrList(s);
|
||||||
|
return HeaderCondition(headers, matchType, keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (word == 'exists') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final headers = _parseStringOrList(s);
|
||||||
|
// Represent exists as :contains "" so any non-empty value matches.
|
||||||
|
return HeaderCondition(headers, ':contains', const ['']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (word == 'size') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final comp = s.readTaggedArg(); // :over or :under
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final bytes = _parseSizeNumber(s);
|
||||||
|
return SizeCondition(comp, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown test — skip to closing paren or brace.
|
||||||
|
s.readWord();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _parseMatchType(_Scanner s) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final tag = s.peekTaggedArg();
|
||||||
|
if (tag == ':contains' || tag == ':is' || tag == ':matches') {
|
||||||
|
s.readWord();
|
||||||
|
return tag!;
|
||||||
|
}
|
||||||
|
// Default per RFC 5228 is :is.
|
||||||
|
return ':is';
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _parseStringOrList(_Scanner s) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.peek() == '[') {
|
||||||
|
s.advance(); // consume '['
|
||||||
|
final items = <String>[];
|
||||||
|
while (true) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.peek() == ']') {
|
||||||
|
s.advance();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
items.add(_parseString(s));
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.peek() == ',') {
|
||||||
|
s.advance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
return [_parseString(s)];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _parseString(_Scanner s) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.peek() == '"') {
|
||||||
|
return s.readQuotedString();
|
||||||
|
}
|
||||||
|
// Multi-line text: text:...\r\n.\r\n (RFC 5228 §2.4.2)
|
||||||
|
if (s.peekWord()?.toLowerCase() == 'text:') {
|
||||||
|
return s.readTextBlock();
|
||||||
|
}
|
||||||
|
throw SieveParseException(
|
||||||
|
'Expected string at position ${s.position}: "${s.remaining.substring(0, 20)}"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _parseSizeNumber(_Scanner s) {
|
||||||
|
final digits = s.readDigits();
|
||||||
|
final value = int.parse(digits);
|
||||||
|
final unit = s.peekSizeUnit();
|
||||||
|
if (unit != null) {
|
||||||
|
s.advance();
|
||||||
|
return switch (unit.toUpperCase()) {
|
||||||
|
'K' => value * 1024,
|
||||||
|
'M' => value * 1024 * 1024,
|
||||||
|
'G' => value * 1024 * 1024 * 1024,
|
||||||
|
_ => value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
SieveAction? _tryParseAction(_Scanner s) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final word = s.peekWord()?.toLowerCase();
|
||||||
|
if (word == null) return null;
|
||||||
|
|
||||||
|
if (word == 'fileinto') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final folder = _parseString(s);
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar(';');
|
||||||
|
return FileIntoAction(folder);
|
||||||
|
}
|
||||||
|
if (word == 'keep') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar(';');
|
||||||
|
return KeepAction();
|
||||||
|
}
|
||||||
|
if (word == 'discard') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar(';');
|
||||||
|
return DiscardAction();
|
||||||
|
}
|
||||||
|
if (word == 'stop') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar(';');
|
||||||
|
return KeepAction(); // stop with no prior action = implicit keep
|
||||||
|
}
|
||||||
|
if (word == 'flag' || word == 'setflag' || word == 'addflag') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
// Optional variable name (string arg before the flag list).
|
||||||
|
final peek = s.peek();
|
||||||
|
List<String> flags;
|
||||||
|
if (peek == '"') {
|
||||||
|
final first = _parseString(s);
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.peek() == '[' || s.peek() == '"') {
|
||||||
|
// first was the variable name, next is the flag list
|
||||||
|
flags = _parseStringOrList(s);
|
||||||
|
} else {
|
||||||
|
flags = [first];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
flags = _parseStringOrList(s);
|
||||||
|
}
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar(';');
|
||||||
|
if (flags.any(
|
||||||
|
(f) => f.toLowerCase() == r'\seen' || f.toLowerCase() == r'\\seen',
|
||||||
|
)) {
|
||||||
|
return MarkAsSeenAction();
|
||||||
|
}
|
||||||
|
return FlagAction(flags);
|
||||||
|
}
|
||||||
|
if (word == 'mark') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar(';');
|
||||||
|
return MarkAsSeenAction();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Low-level scanner
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class SieveParseException implements Exception {
|
||||||
|
SieveParseException(this.message);
|
||||||
|
final String message;
|
||||||
|
@override
|
||||||
|
String toString() => 'SieveParseException: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Scanner {
|
||||||
|
_Scanner(this._src);
|
||||||
|
|
||||||
|
final String _src;
|
||||||
|
int _pos = 0;
|
||||||
|
|
||||||
|
int get position => _pos;
|
||||||
|
bool get isAtEnd => _pos >= _src.length;
|
||||||
|
String get remaining => _pos < _src.length ? _src.substring(_pos) : '';
|
||||||
|
|
||||||
|
String? peek() {
|
||||||
|
if (isAtEnd) return null;
|
||||||
|
return _src[_pos];
|
||||||
|
}
|
||||||
|
|
||||||
|
String advance() {
|
||||||
|
if (isAtEnd) throw SieveParseException('Unexpected end of input');
|
||||||
|
return _src[_pos++];
|
||||||
|
}
|
||||||
|
|
||||||
|
void skipWhitespaceAndComments() {
|
||||||
|
while (!isAtEnd) {
|
||||||
|
final ch = _src[_pos];
|
||||||
|
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
|
||||||
|
_pos++;
|
||||||
|
} else if (ch == '#') {
|
||||||
|
// Line comment — skip to end of line.
|
||||||
|
while (!isAtEnd && _src[_pos] != '\n') {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
} else if (_pos + 1 < _src.length && ch == '/' && _src[_pos + 1] == '*') {
|
||||||
|
// Block comment.
|
||||||
|
_pos += 2;
|
||||||
|
while (_pos + 1 < _src.length) {
|
||||||
|
if (_src[_pos] == '*' && _src[_pos + 1] == '/') {
|
||||||
|
_pos += 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Peeks at the next word-like token (letters/digits/underscores/colons for
|
||||||
|
/// tagged args, and special single-char tokens like `{`, `}`, `;`).
|
||||||
|
String? peekWord() {
|
||||||
|
if (isAtEnd) return null;
|
||||||
|
final ch = _src[_pos];
|
||||||
|
if ('{}();[],'.contains(ch)) return ch;
|
||||||
|
if (ch == ':') {
|
||||||
|
// Tagged arg like :contains
|
||||||
|
final start = _pos;
|
||||||
|
var end = _pos + 1;
|
||||||
|
while (end < _src.length && _isWordChar(_src[end])) {
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
return _src.substring(start, end).toLowerCase();
|
||||||
|
}
|
||||||
|
if (_isWordChar(ch)) {
|
||||||
|
final start = _pos;
|
||||||
|
var end = _pos + 1;
|
||||||
|
while (
|
||||||
|
end < _src.length && (_isWordChar(_src[end]) || _src[end] == ':')) {
|
||||||
|
// Include trailing colon for "text:" multiline token.
|
||||||
|
if (_src[end] == ':') {
|
||||||
|
end++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
return _src.substring(start, end).toLowerCase();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String readWord() {
|
||||||
|
final start = _pos;
|
||||||
|
final ch = _src[_pos];
|
||||||
|
if ('{}();[],'.contains(ch)) {
|
||||||
|
_pos++;
|
||||||
|
return ch;
|
||||||
|
}
|
||||||
|
if (ch == ':') {
|
||||||
|
_pos++;
|
||||||
|
while (!isAtEnd && _isWordChar(_src[_pos])) {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
while (!isAtEnd && (_isWordChar(_src[_pos]) || _src[_pos] == ':')) {
|
||||||
|
if (_src[_pos] == ':') {
|
||||||
|
_pos++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _src.substring(start, _pos).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? peekTaggedArg() {
|
||||||
|
if (!isAtEnd && _src[_pos] == ':') return peekWord();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String readTaggedArg() {
|
||||||
|
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
||||||
|
throw SieveParseException(
|
||||||
|
'Expected tagged argument at position $_pos',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? peekSizeUnit() {
|
||||||
|
if (isAtEnd) return null;
|
||||||
|
final ch = _src[_pos].toUpperCase();
|
||||||
|
if (ch == 'K' || ch == 'M' || ch == 'G') return ch;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String readDigits() {
|
||||||
|
if (isAtEnd || !_isDigit(_src[_pos])) {
|
||||||
|
throw SieveParseException(
|
||||||
|
'Expected number at position $_pos',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final start = _pos;
|
||||||
|
while (!isAtEnd && _isDigit(_src[_pos])) {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
return _src.substring(start, _pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
String readQuotedString() {
|
||||||
|
if (_src[_pos] != '"') {
|
||||||
|
throw SieveParseException(
|
||||||
|
'Expected " at position $_pos',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_pos++; // skip opening quote
|
||||||
|
final buf = StringBuffer();
|
||||||
|
while (!isAtEnd) {
|
||||||
|
final ch = _src[_pos];
|
||||||
|
if (ch == '"') {
|
||||||
|
_pos++;
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
if (ch == '\\' && _pos + 1 < _src.length) {
|
||||||
|
_pos++;
|
||||||
|
buf.write(_src[_pos]);
|
||||||
|
_pos++;
|
||||||
|
} else {
|
||||||
|
buf.write(ch);
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw SieveParseException('Unterminated string');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a `text:` multi-line block (RFC 5228 §2.4.2).
|
||||||
|
/// Format: `text:\r\n<lines>\r\n.\r\n`
|
||||||
|
String readTextBlock() {
|
||||||
|
// Consume "text:"
|
||||||
|
while (!isAtEnd && _src[_pos] != ':') {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
if (!isAtEnd) _pos++; // skip ':'
|
||||||
|
// Skip optional whitespace then newline.
|
||||||
|
while (!isAtEnd && (_src[_pos] == ' ' || _src[_pos] == '\t')) {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
if (!isAtEnd && _src[_pos] == '\r') _pos++;
|
||||||
|
if (!isAtEnd && _src[_pos] == '\n') _pos++;
|
||||||
|
final buf = StringBuffer();
|
||||||
|
while (!isAtEnd) {
|
||||||
|
// Check for terminator: a lone "." on its own line.
|
||||||
|
if (_src[_pos] == '.' &&
|
||||||
|
(_pos + 1 >= _src.length ||
|
||||||
|
_src[_pos + 1] == '\r' ||
|
||||||
|
_src[_pos + 1] == '\n')) {
|
||||||
|
_pos++;
|
||||||
|
if (!isAtEnd && _src[_pos] == '\r') _pos++;
|
||||||
|
if (!isAtEnd && _src[_pos] == '\n') _pos++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buf.write(_src[_pos]);
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void expectChar(String ch) {
|
||||||
|
skipWhitespaceAndComments();
|
||||||
|
if (isAtEnd || _src[_pos] != ch) {
|
||||||
|
throw SieveParseException(
|
||||||
|
'Expected "$ch" at position $_pos, got '
|
||||||
|
'"${isAtEnd ? "EOF" : _src[_pos]}"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
void expectWord(String word) {
|
||||||
|
skipWhitespaceAndComments();
|
||||||
|
final got = readWord();
|
||||||
|
if (got.toLowerCase() != word.toLowerCase()) {
|
||||||
|
throw SieveParseException(
|
||||||
|
'Expected "$word" at position $_pos, got "$got"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void skipToNextSemicolon() {
|
||||||
|
while (!isAtEnd && _src[_pos] != ';') {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
if (!isAtEnd) _pos++; // skip ';'
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isWordChar(String ch) {
|
||||||
|
final c = ch.codeUnitAt(0);
|
||||||
|
return (c >= 0x41 && c <= 0x5A) || // A-Z
|
||||||
|
(c >= 0x61 && c <= 0x7A) || // a-z
|
||||||
|
(c >= 0x30 && c <= 0x39) || // 0-9
|
||||||
|
c == 0x5F || // _
|
||||||
|
c == 0x2D; // -
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isDigit(String ch) {
|
||||||
|
final c = ch.codeUnitAt(0);
|
||||||
|
return c >= 0x30 && c <= 0x39;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
||||||
|
|
||||||
|
class SieveRule {
|
||||||
|
const SieveRule({
|
||||||
|
required this.joinType,
|
||||||
|
required this.conditions,
|
||||||
|
required this.actions,
|
||||||
|
this.branchGroupId,
|
||||||
|
this.isElseBranch = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 'allof', 'anyof', or 'single'
|
||||||
|
final String joinType;
|
||||||
|
final List<SieveCondition> conditions;
|
||||||
|
final List<SieveAction> actions;
|
||||||
|
// Non-null groups this rule into an if/elsif/else chain.
|
||||||
|
final int? branchGroupId;
|
||||||
|
// True for the unconditional else branch.
|
||||||
|
final bool isElseBranch;
|
||||||
|
}
|
||||||
@@ -4,12 +4,16 @@ import 'package:enough_mail/enough_mail.dart' as imap;
|
|||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult;
|
import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult;
|
||||||
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/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||||
import 'package:sharedinbox/core/utils/logger.dart';
|
import 'package:sharedinbox/core/utils/logger.dart';
|
||||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart'
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart'
|
||||||
show ImapConnectFn, connectImap, verboseLogKey;
|
show ImapConnectFn, connectImap, verboseLogKey;
|
||||||
|
import 'package:sharedinbox/data/imap/tls_error.dart' show isTlsConfigError;
|
||||||
|
|
||||||
|
typedef OnNewMailCallback = Future<void> Function(String accountEmail);
|
||||||
|
|
||||||
/// Manages background sync for all accounts.
|
/// Manages background sync for all accounts.
|
||||||
///
|
///
|
||||||
@@ -22,19 +26,35 @@ class AccountSyncManager {
|
|||||||
this._emails, {
|
this._emails, {
|
||||||
ImapConnectFn imapConnect = connectImap,
|
ImapConnectFn imapConnect = connectImap,
|
||||||
SyncLogRepository syncLog = const NoOpSyncLogRepository(),
|
SyncLogRepository syncLog = const NoOpSyncLogRepository(),
|
||||||
|
DraftRepository? drafts,
|
||||||
|
OnNewMailCallback? onNewMail,
|
||||||
}) : _imapConnect = imapConnect,
|
}) : _imapConnect = imapConnect,
|
||||||
_syncLog = syncLog;
|
_syncLog = syncLog,
|
||||||
|
_drafts = drafts,
|
||||||
|
_onNewMail = onNewMail;
|
||||||
|
|
||||||
final AccountRepository _accounts;
|
final AccountRepository _accounts;
|
||||||
final MailboxRepository _mailboxes;
|
final MailboxRepository _mailboxes;
|
||||||
final EmailRepository _emails;
|
final EmailRepository _emails;
|
||||||
final ImapConnectFn _imapConnect;
|
final ImapConnectFn _imapConnect;
|
||||||
final SyncLogRepository _syncLog;
|
final SyncLogRepository _syncLog;
|
||||||
|
final DraftRepository? _drafts;
|
||||||
|
final OnNewMailCallback? _onNewMail;
|
||||||
|
|
||||||
final Map<String, _SyncLoop> _active = {};
|
final Map<String, _SyncLoop> _active = {};
|
||||||
StreamSubscription<List<Account>>? _accountsSub;
|
StreamSubscription<List<Account>>? _accountsSub;
|
||||||
StreamSubscription<String>? _onChangesSub;
|
StreamSubscription<String>? _onChangesSub;
|
||||||
|
|
||||||
|
final _syncPhaseCtrl = StreamController<(String, bool)>.broadcast();
|
||||||
|
|
||||||
|
/// Emits `true` when [accountId] starts syncing, `false` when it stops.
|
||||||
|
Stream<bool> watchSyncing(String accountId) =>
|
||||||
|
_syncPhaseCtrl.stream.where((e) => e.$1 == accountId).map((e) => e.$2);
|
||||||
|
|
||||||
|
void _emitSyncing(String accountId, {required bool syncing}) {
|
||||||
|
if (!_syncPhaseCtrl.isClosed) _syncPhaseCtrl.add((accountId, syncing));
|
||||||
|
}
|
||||||
|
|
||||||
void start() {
|
void start() {
|
||||||
_onChangesSub = _emails.onChangesQueued.listen((accountId) {
|
_onChangesSub = _emails.onChangesQueued.listen((accountId) {
|
||||||
_active[accountId]?.kick();
|
_active[accountId]?.kick();
|
||||||
@@ -45,6 +65,7 @@ class AccountSyncManager {
|
|||||||
|
|
||||||
for (final account in accounts) {
|
for (final account in accounts) {
|
||||||
if (_active.containsKey(account.id)) continue;
|
if (_active.containsKey(account.id)) continue;
|
||||||
|
final id = account.id;
|
||||||
final loop = switch (account.type) {
|
final loop = switch (account.type) {
|
||||||
AccountType.imap => _AccountSync(
|
AccountType.imap => _AccountSync(
|
||||||
account,
|
account,
|
||||||
@@ -53,6 +74,10 @@ class AccountSyncManager {
|
|||||||
_emails,
|
_emails,
|
||||||
_imapConnect,
|
_imapConnect,
|
||||||
_syncLog,
|
_syncLog,
|
||||||
|
_drafts,
|
||||||
|
_onNewMail,
|
||||||
|
onSyncStart: () => _emitSyncing(id, syncing: true),
|
||||||
|
onSyncEnd: () => _emitSyncing(id, syncing: false),
|
||||||
),
|
),
|
||||||
AccountType.jmap => _JmapAccountSync(
|
AccountType.jmap => _JmapAccountSync(
|
||||||
account,
|
account,
|
||||||
@@ -60,6 +85,8 @@ class AccountSyncManager {
|
|||||||
_emails,
|
_emails,
|
||||||
_accounts,
|
_accounts,
|
||||||
_syncLog,
|
_syncLog,
|
||||||
|
onSyncStart: () => _emitSyncing(id, syncing: true),
|
||||||
|
onSyncEnd: () => _emitSyncing(id, syncing: false),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
_active[account.id] = loop;
|
_active[account.id] = loop;
|
||||||
@@ -81,6 +108,7 @@ class AccountSyncManager {
|
|||||||
s.stop();
|
s.stop();
|
||||||
}
|
}
|
||||||
_active.clear();
|
_active.clear();
|
||||||
|
unawaited(_syncPhaseCtrl.close());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wakes the idle/wait phase of the given account's sync loop so a new
|
/// Wakes the idle/wait phase of the given account's sync loop so a new
|
||||||
@@ -113,6 +141,10 @@ class AccountSyncManager {
|
|||||||
_emails,
|
_emails,
|
||||||
_imapConnect,
|
_imapConnect,
|
||||||
_syncLog,
|
_syncLog,
|
||||||
|
_drafts,
|
||||||
|
_onNewMail,
|
||||||
|
onSyncStart: () => _emitSyncing(accountId, syncing: true),
|
||||||
|
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
|
||||||
),
|
),
|
||||||
AccountType.jmap => _JmapAccountSync(
|
AccountType.jmap => _JmapAccountSync(
|
||||||
account,
|
account,
|
||||||
@@ -120,6 +152,8 @@ class AccountSyncManager {
|
|||||||
_emails,
|
_emails,
|
||||||
_accounts,
|
_accounts,
|
||||||
_syncLog,
|
_syncLog,
|
||||||
|
onSyncStart: () => _emitSyncing(accountId, syncing: true),
|
||||||
|
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
_active[accountId] = loop;
|
_active[accountId] = loop;
|
||||||
@@ -145,7 +179,12 @@ class _AccountSync implements _SyncLoop {
|
|||||||
this._emails,
|
this._emails,
|
||||||
this._imapConnect,
|
this._imapConnect,
|
||||||
this._syncLog,
|
this._syncLog,
|
||||||
);
|
this._drafts,
|
||||||
|
this._onNewMail, {
|
||||||
|
void Function()? onSyncStart,
|
||||||
|
void Function()? onSyncEnd,
|
||||||
|
}) : _onSyncStart = onSyncStart,
|
||||||
|
_onSyncEnd = onSyncEnd;
|
||||||
|
|
||||||
final Account account;
|
final Account account;
|
||||||
final AccountRepository _accounts;
|
final AccountRepository _accounts;
|
||||||
@@ -153,11 +192,16 @@ class _AccountSync implements _SyncLoop {
|
|||||||
final EmailRepository _emails;
|
final EmailRepository _emails;
|
||||||
final ImapConnectFn _imapConnect;
|
final ImapConnectFn _imapConnect;
|
||||||
final SyncLogRepository _syncLog;
|
final SyncLogRepository _syncLog;
|
||||||
|
final DraftRepository? _drafts;
|
||||||
|
final OnNewMailCallback? _onNewMail;
|
||||||
|
final void Function()? _onSyncStart;
|
||||||
|
final void Function()? _onSyncEnd;
|
||||||
|
|
||||||
imap.ImapClient? _idleClient;
|
imap.ImapClient? _idleClient;
|
||||||
bool _running = false;
|
bool _running = false;
|
||||||
int _backoffSeconds = 5;
|
int _backoffSeconds = 5;
|
||||||
Completer<void>? _stopSignal;
|
Completer<void>? _stopSignal;
|
||||||
|
Timer? _waitTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void start() {
|
void start() {
|
||||||
@@ -185,6 +229,7 @@ class _AccountSync implements _SyncLoop {
|
|||||||
Future<void> _loop() async {
|
Future<void> _loop() async {
|
||||||
while (_running) {
|
while (_running) {
|
||||||
final startedAt = DateTime.now();
|
final startedAt = DateTime.now();
|
||||||
|
_onSyncStart?.call();
|
||||||
try {
|
try {
|
||||||
final (_SyncStats stats, String? capturedLog) = await _runSync(
|
final (_SyncStats stats, String? capturedLog) = await _runSync(
|
||||||
account.verbose,
|
account.verbose,
|
||||||
@@ -204,8 +249,10 @@ class _AccountSync implements _SyncLoop {
|
|||||||
protocolLog: capturedLog,
|
protocolLog: capturedLog,
|
||||||
);
|
);
|
||||||
_backoffSeconds = 5;
|
_backoffSeconds = 5;
|
||||||
|
_onSyncEnd?.call();
|
||||||
await _idle();
|
await _idle();
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
|
_onSyncEnd?.call();
|
||||||
final isPermanent = _isPermanentError(e);
|
final isPermanent = _isPermanentError(e);
|
||||||
try {
|
try {
|
||||||
await _syncLog.log(
|
await _syncLog.log(
|
||||||
@@ -246,6 +293,7 @@ class _AccountSync implements _SyncLoop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _isPermanentError(Object e) {
|
bool _isPermanentError(Object e) {
|
||||||
|
if (isTlsConfigError(e)) return true;
|
||||||
final s = e.toString().toLowerCase();
|
final s = e.toString().toLowerCase();
|
||||||
// enough_mail doesn't always have typed exceptions for auth, so we check strings.
|
// enough_mail doesn't always have typed exceptions for auth, so we check strings.
|
||||||
return s.contains('invalid credentials') ||
|
return s.contains('invalid credentials') ||
|
||||||
@@ -256,11 +304,16 @@ class _AccountSync implements _SyncLoop {
|
|||||||
Future<void> _waitSeconds(int seconds) async {
|
Future<void> _waitSeconds(int seconds) async {
|
||||||
if (!_running) return;
|
if (!_running) return;
|
||||||
_stopSignal = Completer<void>();
|
_stopSignal = Completer<void>();
|
||||||
await Future.any([
|
_waitTimer = Timer(Duration(seconds: seconds), () {
|
||||||
Future.delayed(Duration(seconds: seconds)),
|
if (!_stopSignal!.isCompleted) _stopSignal!.complete();
|
||||||
_stopSignal!.future,
|
});
|
||||||
]);
|
try {
|
||||||
_stopSignal = null;
|
await _stopSignal!.future;
|
||||||
|
} finally {
|
||||||
|
_waitTimer?.cancel();
|
||||||
|
_waitTimer = null;
|
||||||
|
_stopSignal = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<(_SyncStats, String?)> _runSync(bool verbose) async {
|
Future<(_SyncStats, String?)> _runSync(bool verbose) async {
|
||||||
@@ -279,6 +332,8 @@ class _AccountSync implements _SyncLoop {
|
|||||||
Future<_SyncStats> _sync() async {
|
Future<_SyncStats> _sync() async {
|
||||||
final password = await _accounts.getPassword(account.id);
|
final password = await _accounts.getPassword(account.id);
|
||||||
|
|
||||||
|
await _drafts?.syncDrafts(account.id, password);
|
||||||
|
|
||||||
// Check for expired snoozes and move them back to Inbox before syncing.
|
// Check for expired snoozes and move them back to Inbox before syncing.
|
||||||
await _emails.wakeUpEmails(account.id);
|
await _emails.wakeUpEmails(account.id);
|
||||||
|
|
||||||
@@ -292,6 +347,7 @@ class _AccountSync implements _SyncLoop {
|
|||||||
final mailboxStats = <MailboxSyncStats>[];
|
final mailboxStats = <MailboxSyncStats>[];
|
||||||
for (final mailbox in mailboxes) {
|
for (final mailbox in mailboxes) {
|
||||||
if (!_running) break;
|
if (!_running) break;
|
||||||
|
final mailboxStart = DateTime.now();
|
||||||
final r = await _emails.syncEmails(account.id, mailbox.path);
|
final r = await _emails.syncEmails(account.id, mailbox.path);
|
||||||
emailResult += r;
|
emailResult += r;
|
||||||
mailboxStats.add(
|
mailboxStats.add(
|
||||||
@@ -300,9 +356,11 @@ class _AccountSync implements _SyncLoop {
|
|||||||
fetched: r.fetched,
|
fetched: r.fetched,
|
||||||
skipped: r.skipped,
|
skipped: r.skipped,
|
||||||
bytesTransferred: r.bytesTransferred,
|
bytesTransferred: r.bytesTransferred,
|
||||||
|
duration: DateTime.now().difference(mailboxStart),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await _emails.applySieveRules(account.id);
|
||||||
return _SyncStats(
|
return _SyncStats(
|
||||||
emailsFetched: emailResult.fetched,
|
emailsFetched: emailResult.fetched,
|
||||||
emailsSkipped: emailResult.skipped,
|
emailsSkipped: emailResult.skipped,
|
||||||
@@ -325,6 +383,7 @@ class _AccountSync implements _SyncLoop {
|
|||||||
await client.selectMailboxByPath('INBOX');
|
await client.selectMailboxByPath('INBOX');
|
||||||
|
|
||||||
final newMessageCompleter = Completer<void>();
|
final newMessageCompleter = Completer<void>();
|
||||||
|
var hasNewMail = false;
|
||||||
|
|
||||||
final sub = client.eventBus
|
final sub = client.eventBus
|
||||||
.on<imap.ImapEvent>()
|
.on<imap.ImapEvent>()
|
||||||
@@ -332,7 +391,11 @@ class _AccountSync implements _SyncLoop {
|
|||||||
(e) =>
|
(e) =>
|
||||||
e is imap.ImapMessagesExistEvent || e is imap.ImapExpungeEvent,
|
e is imap.ImapMessagesExistEvent || e is imap.ImapExpungeEvent,
|
||||||
)
|
)
|
||||||
.listen((_) {
|
.listen((e) {
|
||||||
|
if (e is imap.ImapMessagesExistEvent &&
|
||||||
|
e.newMessagesExists > e.oldMessagesExists) {
|
||||||
|
hasNewMail = true;
|
||||||
|
}
|
||||||
if (!newMessageCompleter.isCompleted) newMessageCompleter.complete();
|
if (!newMessageCompleter.isCompleted) newMessageCompleter.complete();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -340,14 +403,23 @@ class _AccountSync implements _SyncLoop {
|
|||||||
|
|
||||||
// Cap IDLE at 25 minutes (RFC 2177). Also wakes up when stop() is
|
// Cap IDLE at 25 minutes (RFC 2177). Also wakes up when stop() is
|
||||||
// called or a new message / expunge event arrives.
|
// called or a new message / expunge event arrives.
|
||||||
await Future.any([
|
final idleTimer = Timer(const Duration(minutes: 25), () {
|
||||||
newMessageCompleter.future,
|
if (_stopSignal != null && !_stopSignal!.isCompleted) {
|
||||||
Future.delayed(const Duration(minutes: 25)),
|
_stopSignal!.complete();
|
||||||
_stopSignal!.future,
|
}
|
||||||
]);
|
});
|
||||||
|
try {
|
||||||
|
await Future.any([newMessageCompleter.future, _stopSignal!.future]);
|
||||||
|
} finally {
|
||||||
|
idleTimer.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
await client.idleDone();
|
await client.idleDone();
|
||||||
await sub.cancel();
|
await sub.cancel();
|
||||||
|
|
||||||
|
if (hasNewMail) {
|
||||||
|
unawaited(_onNewMail?.call(account.email));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await client.logout();
|
await client.logout();
|
||||||
_idleClient = null;
|
_idleClient = null;
|
||||||
@@ -364,18 +436,24 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
this._mailboxes,
|
this._mailboxes,
|
||||||
this._emails,
|
this._emails,
|
||||||
this._accounts,
|
this._accounts,
|
||||||
this._syncLog,
|
this._syncLog, {
|
||||||
);
|
void Function()? onSyncStart,
|
||||||
|
void Function()? onSyncEnd,
|
||||||
|
}) : _onSyncStart = onSyncStart,
|
||||||
|
_onSyncEnd = onSyncEnd;
|
||||||
|
|
||||||
final Account account;
|
final Account account;
|
||||||
final MailboxRepository _mailboxes;
|
final MailboxRepository _mailboxes;
|
||||||
final EmailRepository _emails;
|
final EmailRepository _emails;
|
||||||
final AccountRepository _accounts;
|
final AccountRepository _accounts;
|
||||||
final SyncLogRepository _syncLog;
|
final SyncLogRepository _syncLog;
|
||||||
|
final void Function()? _onSyncStart;
|
||||||
|
final void Function()? _onSyncEnd;
|
||||||
|
|
||||||
bool _running = false;
|
bool _running = false;
|
||||||
int _backoffSeconds = 5;
|
int _backoffSeconds = 5;
|
||||||
Completer<void>? _stopSignal;
|
Completer<void>? _stopSignal;
|
||||||
|
Timer? _waitTimer;
|
||||||
|
|
||||||
static const _pollInterval = Duration(seconds: 30);
|
static const _pollInterval = Duration(seconds: 30);
|
||||||
|
|
||||||
@@ -403,6 +481,7 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
Future<void> _loop() async {
|
Future<void> _loop() async {
|
||||||
while (_running) {
|
while (_running) {
|
||||||
final startedAt = DateTime.now();
|
final startedAt = DateTime.now();
|
||||||
|
_onSyncStart?.call();
|
||||||
try {
|
try {
|
||||||
final (_SyncStats stats, String? capturedLog) = await _runSync(
|
final (_SyncStats stats, String? capturedLog) = await _runSync(
|
||||||
account.verbose,
|
account.verbose,
|
||||||
@@ -422,8 +501,10 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
protocolLog: capturedLog,
|
protocolLog: capturedLog,
|
||||||
);
|
);
|
||||||
_backoffSeconds = 5;
|
_backoffSeconds = 5;
|
||||||
|
_onSyncEnd?.call();
|
||||||
await _wait();
|
await _wait();
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
|
_onSyncEnd?.call();
|
||||||
final isPermanent = _isPermanentError(e);
|
final isPermanent = _isPermanentError(e);
|
||||||
try {
|
try {
|
||||||
await _syncLog.log(
|
await _syncLog.log(
|
||||||
@@ -464,6 +545,7 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _isPermanentError(Object e) {
|
bool _isPermanentError(Object e) {
|
||||||
|
if (isTlsConfigError(e)) return true;
|
||||||
final s = e.toString().toLowerCase();
|
final s = e.toString().toLowerCase();
|
||||||
return s.contains('invalid credentials') ||
|
return s.contains('invalid credentials') ||
|
||||||
s.contains('authentication failed') ||
|
s.contains('authentication failed') ||
|
||||||
@@ -475,11 +557,16 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
Future<void> _waitSeconds(int seconds) async {
|
Future<void> _waitSeconds(int seconds) async {
|
||||||
if (!_running) return;
|
if (!_running) return;
|
||||||
_stopSignal = Completer<void>();
|
_stopSignal = Completer<void>();
|
||||||
await Future.any([
|
_waitTimer = Timer(Duration(seconds: seconds), () {
|
||||||
Future.delayed(Duration(seconds: seconds)),
|
if (!_stopSignal!.isCompleted) _stopSignal!.complete();
|
||||||
_stopSignal!.future,
|
});
|
||||||
]);
|
try {
|
||||||
_stopSignal = null;
|
await _stopSignal!.future;
|
||||||
|
} finally {
|
||||||
|
_waitTimer?.cancel();
|
||||||
|
_waitTimer = null;
|
||||||
|
_stopSignal = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<(_SyncStats, String?)> _runSync(bool verbose) async {
|
Future<(_SyncStats, String?)> _runSync(bool verbose) async {
|
||||||
@@ -514,6 +601,7 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
final mailboxStats = <MailboxSyncStats>[];
|
final mailboxStats = <MailboxSyncStats>[];
|
||||||
for (final mailbox in mailboxes) {
|
for (final mailbox in mailboxes) {
|
||||||
if (!_running) break;
|
if (!_running) break;
|
||||||
|
final mailboxStart = DateTime.now();
|
||||||
final r = await _emails.syncEmails(account.id, mailbox.path);
|
final r = await _emails.syncEmails(account.id, mailbox.path);
|
||||||
emailResult += r;
|
emailResult += r;
|
||||||
mailboxStats.add(
|
mailboxStats.add(
|
||||||
@@ -522,9 +610,11 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
fetched: r.fetched,
|
fetched: r.fetched,
|
||||||
skipped: r.skipped,
|
skipped: r.skipped,
|
||||||
bytesTransferred: r.bytesTransferred,
|
bytesTransferred: r.bytesTransferred,
|
||||||
|
duration: DateTime.now().difference(mailboxStart),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await _emails.applySieveRules(account.id);
|
||||||
return _SyncStats(
|
return _SyncStats(
|
||||||
emailsFetched: emailResult.fetched,
|
emailsFetched: emailResult.fetched,
|
||||||
emailsSkipped: emailResult.skipped,
|
emailsSkipped: emailResult.skipped,
|
||||||
@@ -551,11 +641,16 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
onError: (_) {},
|
onError: (_) {},
|
||||||
);
|
);
|
||||||
|
|
||||||
await Future.any([
|
final pollTimer = Timer(_pollInterval, () {
|
||||||
pushReady.future,
|
if (_stopSignal != null && !_stopSignal!.isCompleted) {
|
||||||
Future.delayed(_pollInterval),
|
_stopSignal!.complete();
|
||||||
_stopSignal!.future,
|
}
|
||||||
]);
|
});
|
||||||
|
try {
|
||||||
|
await Future.any([pushReady.future, _stopSignal!.future]);
|
||||||
|
} finally {
|
||||||
|
pollTimer.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
await pushSub.cancel();
|
await pushSub.cancel();
|
||||||
_stopSignal = null;
|
_stopSignal = null;
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart' as model;
|
||||||
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/services/notification_service.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||||
|
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
||||||
|
|
||||||
|
import 'package:workmanager/workmanager.dart';
|
||||||
|
|
||||||
|
const _kTaskName = 'si_bg_sync';
|
||||||
|
const _kResourceType = 'background_check';
|
||||||
|
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
void callbackDispatcher() {
|
||||||
|
Workmanager().executeTask((_, __) async {
|
||||||
|
try {
|
||||||
|
await _doBackgroundSync();
|
||||||
|
} catch (_) {}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> registerBackgroundSync() async {
|
||||||
|
try {
|
||||||
|
await Workmanager().initialize(callbackDispatcher);
|
||||||
|
await Workmanager().registerPeriodicTask(
|
||||||
|
_kTaskName,
|
||||||
|
_kTaskName,
|
||||||
|
frequency: const Duration(minutes: 15),
|
||||||
|
constraints: Constraints(networkType: NetworkType.connected),
|
||||||
|
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
|
||||||
|
);
|
||||||
|
} on PlatformException {
|
||||||
|
// WorkManager channel unavailable on this device; background sync disabled.
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Plugin not registered on this device; background sync disabled.
|
||||||
|
} catch (_) {
|
||||||
|
// Unexpected initialization failure; background sync disabled.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _doBackgroundSync() async {
|
||||||
|
final dir = await getApplicationSupportDirectory();
|
||||||
|
final db = AppDatabase(
|
||||||
|
NativeDatabase(File(p.join(dir.path, 'sharedinbox.db'))),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final accountRepo = AccountRepositoryImpl(
|
||||||
|
db,
|
||||||
|
const FlutterSecureStorageImpl(),
|
||||||
|
);
|
||||||
|
final accounts = await accountRepo.observeAccounts().first;
|
||||||
|
await initNotifications();
|
||||||
|
for (final account in accounts) {
|
||||||
|
if (account.type != model.AccountType.imap) continue;
|
||||||
|
await _checkAccount(db, accountRepo, account);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkAccount(
|
||||||
|
AppDatabase db,
|
||||||
|
AccountRepository accountRepo,
|
||||||
|
model.Account account,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final password = await accountRepo.getPassword(account.id);
|
||||||
|
final username =
|
||||||
|
account.username.isNotEmpty ? account.username : account.email;
|
||||||
|
final client = await connectImap(account, username, password);
|
||||||
|
try {
|
||||||
|
final status = await client.statusMailbox(
|
||||||
|
imap.Mailbox.virtual('INBOX', []),
|
||||||
|
[imap.StatusFlags.uidNext],
|
||||||
|
);
|
||||||
|
final currentUidNext = status.uidNext;
|
||||||
|
|
||||||
|
final stored = await (db.select(db.syncStates)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) &
|
||||||
|
t.resourceType.equals(_kResourceType),
|
||||||
|
))
|
||||||
|
.getSingleOrNull();
|
||||||
|
final lastUidNext = _parseUidNext(stored?.state);
|
||||||
|
|
||||||
|
await db.into(db.syncStates).insertOnConflictUpdate(
|
||||||
|
SyncStatesCompanion.insert(
|
||||||
|
accountId: account.id,
|
||||||
|
resourceType: _kResourceType,
|
||||||
|
state: jsonEncode({'uidNext': currentUidNext}),
|
||||||
|
syncedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lastUidNext != null &&
|
||||||
|
currentUidNext != null &&
|
||||||
|
currentUidNext > lastUidNext) {
|
||||||
|
await showNewMailNotification(account.email);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _parseUidNext(String? state) {
|
||||||
|
if (state == null) return null;
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(state);
|
||||||
|
if (decoded is Map<String, Object?>) {
|
||||||
|
return decoded['uidNext'] as int?;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ class ReliabilityRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runForAccount(String accountId) async {
|
Future<void> _runForAccount(String accountId, {bool force = false}) async {
|
||||||
try {
|
try {
|
||||||
final mailboxes = await _mailboxes.observeMailboxes(accountId).first;
|
final mailboxes = await _mailboxes.observeMailboxes(accountId).first;
|
||||||
var totalMissingLocally = 0;
|
var totalMissingLocally = 0;
|
||||||
@@ -59,7 +59,7 @@ class ReliabilityRunner {
|
|||||||
final details = <String, dynamic>{};
|
final details = <String, dynamic>{};
|
||||||
|
|
||||||
for (final mailbox in mailboxes) {
|
for (final mailbox in mailboxes) {
|
||||||
if (!_running) break;
|
if (!force && !_running) break;
|
||||||
final result = await _emails.verifySyncReliability(
|
final result = await _emails.verifySyncReliability(
|
||||||
accountId,
|
accountId,
|
||||||
mailbox.path,
|
mailbox.path,
|
||||||
@@ -103,7 +103,14 @@ class ReliabilityRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Forces a reliability check for all accounts immediately.
|
/// Forces a reliability check for all accounts immediately.
|
||||||
|
///
|
||||||
|
/// Works regardless of whether [start] has been called, so the UI can
|
||||||
|
/// trigger a manual check at any time without depending on the periodic
|
||||||
|
/// runner being active.
|
||||||
Future<void> checkNow() async {
|
Future<void> checkNow() async {
|
||||||
await _runAll();
|
final accounts = await _accounts.observeAccounts().first;
|
||||||
|
for (final account in accounts) {
|
||||||
|
await _runForAccount(account.id, force: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||||
|
|
||||||
|
/// Replaces `src="cid:..."` references in [html] with inline `data:` URIs
|
||||||
|
/// by looking up each Content-ID in the MIME tree of [msg].
|
||||||
|
///
|
||||||
|
/// Emails with `multipart/related` often embed images this way. Without
|
||||||
|
/// substitution the WebView shows broken image icons even after the full
|
||||||
|
/// message has been downloaded.
|
||||||
|
String injectInlineImages(String html, imap.MimeMessage msg) {
|
||||||
|
final inlineParts = msg.findContentInfo(
|
||||||
|
disposition: imap.ContentDisposition.inline,
|
||||||
|
);
|
||||||
|
if (inlineParts.isEmpty) return html;
|
||||||
|
|
||||||
|
var result = html;
|
||||||
|
for (final info in inlineParts) {
|
||||||
|
final cid = info.cid;
|
||||||
|
if (cid == null || cid.isEmpty) continue;
|
||||||
|
final bareCid = cid.startsWith('<') && cid.endsWith('>')
|
||||||
|
? cid.substring(1, cid.length - 1)
|
||||||
|
: cid;
|
||||||
|
|
||||||
|
final part = msg.getPart(info.fetchId);
|
||||||
|
if (part == null) continue;
|
||||||
|
final bytes = part.decodeContentBinary();
|
||||||
|
if (bytes == null || bytes.isEmpty) continue;
|
||||||
|
|
||||||
|
final contentType =
|
||||||
|
info.contentType?.mediaType.text ?? 'application/octet-stream';
|
||||||
|
final dataUri = 'data:$contentType;base64,${base64.encode(bytes)}';
|
||||||
|
|
||||||
|
result = result
|
||||||
|
.replaceAll('src="cid:$bareCid"', 'src="$dataUri"')
|
||||||
|
.replaceAll("src='cid:$bareCid'", "src='$dataUri'")
|
||||||
|
.replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"')
|
||||||
|
.replaceAll(
|
||||||
|
"src='cid:${bareCid.toLowerCase()}'",
|
||||||
|
"src='$dataUri'",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -2,3 +2,21 @@ bool isLocalhost(String host) {
|
|||||||
final h = host.trim().toLowerCase();
|
final h = host.trim().toLowerCase();
|
||||||
return h == 'localhost' || h == '127.0.0.1' || h == '::1';
|
return h == 'localhost' || h == '127.0.0.1' || h == '::1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? validateHostname(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) return 'Required';
|
||||||
|
return _checkHostChars(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
String? validateOptionalHostname(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) return null;
|
||||||
|
return _checkHostChars(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _checkHostChars(String h) {
|
||||||
|
if (h.contains(RegExp(r'[@/\\]')) ||
|
||||||
|
h.codeUnits.any((c) => c < 32 || c == 127)) {
|
||||||
|
return 'Invalid hostname';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
+184
-10
@@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.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';
|
||||||
|
|
||||||
@@ -88,6 +89,9 @@ class Emails extends Table {
|
|||||||
DateTimeColumn get snoozedUntil => dateTime().nullable()();
|
DateTimeColumn get snoozedUntil => dateTime().nullable()();
|
||||||
TextColumn get snoozedFromMailboxPath => text().nullable()();
|
TextColumn get snoozedFromMailboxPath => text().nullable()();
|
||||||
|
|
||||||
|
// Added in schema v23: RFC 2369 List-Unsubscribe header value.
|
||||||
|
TextColumn get listUnsubscribeHeader => text().nullable()();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
@@ -104,6 +108,8 @@ class EmailBodies extends Table {
|
|||||||
DateTimeColumn get cachedAt => dateTime().nullable()();
|
DateTimeColumn get cachedAt => dateTime().nullable()();
|
||||||
// Added in schema v20: raw or parsed headers
|
// Added in schema v20: raw or parsed headers
|
||||||
TextColumn get headersJson => text().nullable()();
|
TextColumn get headersJson => text().nullable()();
|
||||||
|
// Added in schema v28: serialised MimePart tree (JSON)
|
||||||
|
TextColumn get mimeTreeJson => text().nullable()();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {emailId};
|
Set<Column> get primaryKey => {emailId};
|
||||||
@@ -199,6 +205,8 @@ class SyncLogMailboxes extends Table {
|
|||||||
IntColumn get fetched => integer().withDefault(const Constant(0))();
|
IntColumn get fetched => integer().withDefault(const Constant(0))();
|
||||||
IntColumn get skipped => integer().withDefault(const Constant(0))();
|
IntColumn get skipped => integer().withDefault(const Constant(0))();
|
||||||
IntColumn get bytesTransferred => integer().withDefault(const Constant(0))();
|
IntColumn get bytesTransferred => integer().withDefault(const Constant(0))();
|
||||||
|
// Added in schema v30: how long this mailbox took to sync, in milliseconds.
|
||||||
|
IntColumn get durationMs => integer().nullable()();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stores the result of the periodic "ground truth" verification.
|
/// Stores the result of the periodic "ground truth" verification.
|
||||||
@@ -227,6 +235,44 @@ class Drafts extends Table {
|
|||||||
TextColumn get subjectText => text().withDefault(const Constant(''))();
|
TextColumn get subjectText => text().withDefault(const Constant(''))();
|
||||||
TextColumn get bodyText => text().withDefault(const Constant(''))();
|
TextColumn get bodyText => text().withDefault(const Constant(''))();
|
||||||
DateTimeColumn get updatedAt => dateTime()();
|
DateTimeColumn get updatedAt => dateTime()();
|
||||||
|
// Added in schema v24: IMAP UID string ("mailbox:uid") on the server.
|
||||||
|
TextColumn get imapServerId => text().nullable()();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ephemeral public/private key pair generated for secure account sharing.
|
||||||
|
/// Expires after 20 minutes; used to decrypt an incoming encrypted-accounts QR.
|
||||||
|
@DataClassName('ShareKeyRow')
|
||||||
|
class ShareKeys extends Table {
|
||||||
|
/// Random 16-byte key ID, hex-encoded. Identifies which key pair the sender
|
||||||
|
/// used so the receiver can look it up even if multiple pairs exist.
|
||||||
|
TextColumn get id => text()();
|
||||||
|
|
||||||
|
/// Base64-encoded X25519 public key (32 bytes).
|
||||||
|
TextColumn get publicKey => text()();
|
||||||
|
|
||||||
|
/// Base64-encoded X25519 private key (32 bytes).
|
||||||
|
TextColumn get privateKey => text()();
|
||||||
|
DateTimeColumn get expiresAt => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
@DataClassName('SearchHistoryRow')
|
||||||
|
class SearchHistoryEntries extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
TextColumn get query => text()();
|
||||||
|
DateTimeColumn get searchedAt => dateTime()();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DataClassName('LocalSieveScriptRow')
|
||||||
|
class LocalSieveScripts extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
TextColumn get accountId =>
|
||||||
|
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
|
||||||
|
TextColumn get name => text()();
|
||||||
|
TextColumn get content => text().withDefault(const Constant(''))();
|
||||||
|
BoolColumn get isActive => boolean().withDefault(const Constant(false))();
|
||||||
}
|
}
|
||||||
|
|
||||||
@DataClassName('UndoActionRow')
|
@DataClassName('UndoActionRow')
|
||||||
@@ -242,6 +288,21 @@ class UndoActions extends Table {
|
|||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records which emails have already had local Sieve rules applied.
|
||||||
|
/// Keyed by (accountId, messageId) so the same email is never processed twice,
|
||||||
|
/// even across restarts or re-syncs.
|
||||||
|
@DataClassName('LocalSieveAppliedRow')
|
||||||
|
class LocalSieveApplied extends Table {
|
||||||
|
TextColumn get accountId =>
|
||||||
|
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
|
||||||
|
// RFC 2822 Message-ID header value — stable across folder moves.
|
||||||
|
TextColumn get messageId => text()();
|
||||||
|
DateTimeColumn get appliedAt => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {accountId, messageId};
|
||||||
|
}
|
||||||
|
|
||||||
// ── Database ──────────────────────────────────────────────────────────────────
|
// ── Database ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@DriftDatabase(
|
@DriftDatabase(
|
||||||
@@ -258,16 +319,57 @@ class UndoActions extends Table {
|
|||||||
SyncLogMailboxes,
|
SyncLogMailboxes,
|
||||||
SyncHealth,
|
SyncHealth,
|
||||||
UndoActions,
|
UndoActions,
|
||||||
|
SearchHistoryEntries,
|
||||||
|
LocalSieveScripts,
|
||||||
|
LocalSieveApplied,
|
||||||
|
ShareKeys,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
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 => 22;
|
int get schemaVersion => 32;
|
||||||
|
|
||||||
|
Future<void> _createEmailFts() async {
|
||||||
|
await customStatement('''
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS email_fts USING fts5(
|
||||||
|
subject, preview, from_json,
|
||||||
|
content='emails',
|
||||||
|
content_rowid='rowid'
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
await customStatement('''
|
||||||
|
CREATE TRIGGER IF NOT EXISTS email_fts_ai
|
||||||
|
AFTER INSERT ON emails BEGIN
|
||||||
|
INSERT INTO email_fts(rowid, subject, preview, from_json)
|
||||||
|
VALUES (new.rowid, new.subject, new.preview, new.from_json);
|
||||||
|
END
|
||||||
|
''');
|
||||||
|
await customStatement('''
|
||||||
|
CREATE TRIGGER IF NOT EXISTS email_fts_au
|
||||||
|
AFTER UPDATE OF subject, preview, from_json ON emails BEGIN
|
||||||
|
INSERT INTO email_fts(email_fts, rowid, subject, preview, from_json)
|
||||||
|
VALUES ('delete', old.rowid, old.subject, old.preview, old.from_json);
|
||||||
|
INSERT INTO email_fts(rowid, subject, preview, from_json)
|
||||||
|
VALUES (new.rowid, new.subject, new.preview, new.from_json);
|
||||||
|
END
|
||||||
|
''');
|
||||||
|
await customStatement('''
|
||||||
|
CREATE TRIGGER IF NOT EXISTS email_fts_ad
|
||||||
|
AFTER DELETE ON emails BEGIN
|
||||||
|
INSERT INTO email_fts(email_fts, rowid, subject, preview, from_json)
|
||||||
|
VALUES ('delete', old.rowid, old.subject, old.preview, old.from_json);
|
||||||
|
END
|
||||||
|
''');
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
|
onCreate: (m) async {
|
||||||
|
await m.createAll();
|
||||||
|
await _createEmailFts();
|
||||||
|
},
|
||||||
onUpgrade: (m, from, to) async {
|
onUpgrade: (m, from, to) async {
|
||||||
// NOTE: m.createTable(T) creates the LATEST version of table T.
|
// NOTE: m.createTable(T) creates the LATEST version of table T.
|
||||||
// If you later add a column C to T in version X, you must guard
|
// If you later add a column C to T in version X, you must guard
|
||||||
@@ -420,6 +522,54 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (from < 23) {
|
||||||
|
await m.addColumn(emails, emails.listUnsubscribeHeader);
|
||||||
|
}
|
||||||
|
if (from >= 4 && from < 24) {
|
||||||
|
await m.addColumn(drafts, drafts.imapServerId);
|
||||||
|
}
|
||||||
|
if (from < 25) {
|
||||||
|
// For observeMailboxes: filter by account_id, sort by path.
|
||||||
|
await m.createIndex(
|
||||||
|
Index(
|
||||||
|
'mailboxes_account_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS mailboxes_account_id ON mailboxes (account_id, path);',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// For observeThreads: filter by account_id+mailbox_path, sort by latest_date.
|
||||||
|
await m.createIndex(
|
||||||
|
Index(
|
||||||
|
'threads_latest_date',
|
||||||
|
'CREATE INDEX IF NOT EXISTS threads_latest_date ON threads (account_id, mailbox_path, latest_date DESC);',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (from < 26) {
|
||||||
|
await _createEmailFts();
|
||||||
|
// Backfill FTS index from existing rows.
|
||||||
|
await customStatement('''
|
||||||
|
INSERT INTO email_fts(rowid, subject, preview, from_json)
|
||||||
|
SELECT rowid, subject, preview, from_json FROM emails
|
||||||
|
''');
|
||||||
|
}
|
||||||
|
if (from < 27) {
|
||||||
|
await m.createTable(searchHistoryEntries);
|
||||||
|
}
|
||||||
|
if (from < 28) {
|
||||||
|
await m.addColumn(emailBodies, emailBodies.mimeTreeJson);
|
||||||
|
}
|
||||||
|
if (from < 29) {
|
||||||
|
await m.createTable(localSieveScripts);
|
||||||
|
}
|
||||||
|
if (from >= 12 && from < 30) {
|
||||||
|
await m.addColumn(syncLogMailboxes, syncLogMailboxes.durationMs);
|
||||||
|
}
|
||||||
|
if (from < 31) {
|
||||||
|
await m.createTable(shareKeys);
|
||||||
|
}
|
||||||
|
if (from < 32) {
|
||||||
|
await m.createTable(localSieveApplied);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -429,20 +579,44 @@ String? _dbPath;
|
|||||||
|
|
||||||
/// Call after WidgetsFlutterBinding.ensureInitialized() so that the
|
/// Call after WidgetsFlutterBinding.ensureInitialized() so that the
|
||||||
/// path_provider plugin channel is registered before the first DB access.
|
/// path_provider plugin channel is registered before the first DB access.
|
||||||
|
/// On some Android versions the Pigeon channel is not ready at the very
|
||||||
|
/// start of main(); if it fails, _openConnection() retries lazily.
|
||||||
Future<void> initDatabasePath() async {
|
Future<void> initDatabasePath() async {
|
||||||
final dir = await getApplicationSupportDirectory();
|
try {
|
||||||
_dbPath = p.join(dir.path, 'sharedinbox.db');
|
final dir = await getApplicationSupportDirectory();
|
||||||
|
_dbPath = p.join(dir.path, 'sharedinbox.db');
|
||||||
|
} on PlatformException {
|
||||||
|
// Channel not yet established; LazyDatabase will resolve the path
|
||||||
|
// on first access, after runApp() completes initialization.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the application support path, retrying on PlatformException to
|
||||||
|
/// survive a race where the path_provider Pigeon channel isn't ready yet.
|
||||||
|
Future<String> _resolveDatabasePath() async {
|
||||||
|
if (_dbPath != null) return _dbPath!;
|
||||||
|
// initDatabasePath() failed (channel not ready before runApp). Retry now
|
||||||
|
// that the engine is fully initialised, with brief back-off.
|
||||||
|
const delays = [100, 300, 600];
|
||||||
|
for (final ms in delays) {
|
||||||
|
try {
|
||||||
|
final dir = await getApplicationSupportDirectory();
|
||||||
|
_dbPath = p.join(dir.path, 'sharedinbox.db');
|
||||||
|
return _dbPath!;
|
||||||
|
} on PlatformException {
|
||||||
|
await Future<void>.delayed(Duration(milliseconds: ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'channel-error',
|
||||||
|
message: 'path_provider unavailable after ${delays.length + 1} attempts — '
|
||||||
|
'cannot open database.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyDatabase _openConnection() {
|
LazyDatabase _openConnection() {
|
||||||
return LazyDatabase(() async {
|
return LazyDatabase(() async {
|
||||||
final file = File(
|
final file = File(await _resolveDatabasePath());
|
||||||
_dbPath ??
|
|
||||||
p.join(
|
|
||||||
(await getApplicationSupportDirectory()).path,
|
|
||||||
'sharedinbox.db',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return NativeDatabase.createInBackground(
|
return NativeDatabase.createInBackground(
|
||||||
file,
|
file,
|
||||||
setup: (db) {
|
setup: (db) {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
|
||||||
|
class LocalSieveRepository {
|
||||||
|
LocalSieveRepository(this._db);
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
|
||||||
|
Future<List<SieveScript>> listScripts(String accountId) async {
|
||||||
|
final rows = await (_db.select(_db.localSieveScripts)
|
||||||
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
|
.get();
|
||||||
|
return rows
|
||||||
|
.map(
|
||||||
|
(r) => SieveScript(
|
||||||
|
id: r.id.toString(),
|
||||||
|
name: r.name,
|
||||||
|
blobId: r.id.toString(),
|
||||||
|
isActive: r.isActive,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> getScriptContent(String accountId, String blobId) async {
|
||||||
|
final rowId = int.parse(blobId);
|
||||||
|
final row = await (_db.select(_db.localSieveScripts)
|
||||||
|
..where(
|
||||||
|
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||||
|
))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (row == null) throw Exception('Local script not found: $blobId');
|
||||||
|
return row.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SieveScript> saveScript(
|
||||||
|
String accountId, {
|
||||||
|
String? id,
|
||||||
|
required String name,
|
||||||
|
required String content,
|
||||||
|
}) async {
|
||||||
|
if (id != null) {
|
||||||
|
final rowId = int.parse(id);
|
||||||
|
await (_db.update(_db.localSieveScripts)
|
||||||
|
..where(
|
||||||
|
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||||
|
))
|
||||||
|
.write(
|
||||||
|
LocalSieveScriptsCompanion(
|
||||||
|
name: Value(name),
|
||||||
|
content: Value(content),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final updated = await (_db.select(_db.localSieveScripts)
|
||||||
|
..where(
|
||||||
|
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||||
|
))
|
||||||
|
.getSingleOrNull();
|
||||||
|
return SieveScript(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
blobId: id,
|
||||||
|
isActive: updated?.isActive ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final rowId = await _db.into(_db.localSieveScripts).insert(
|
||||||
|
LocalSieveScriptsCompanion.insert(
|
||||||
|
accountId: accountId,
|
||||||
|
name: name,
|
||||||
|
content: Value(content),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final idStr = rowId.toString();
|
||||||
|
return SieveScript(id: idStr, name: name, blobId: idStr, isActive: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteScript(String accountId, String scriptId) async {
|
||||||
|
final rowId = int.parse(scriptId);
|
||||||
|
await (_db.delete(_db.localSieveScripts)
|
||||||
|
..where(
|
||||||
|
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||||
|
))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> activateScript(String accountId, String scriptId) async {
|
||||||
|
await _db.transaction(() async {
|
||||||
|
await (_db.update(_db.localSieveScripts)
|
||||||
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
|
.write(const LocalSieveScriptsCompanion(isActive: Value(false)));
|
||||||
|
final rowId = int.parse(scriptId);
|
||||||
|
await (_db.update(_db.localSieveScripts)
|
||||||
|
..where(
|
||||||
|
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||||
|
))
|
||||||
|
.write(const LocalSieveScriptsCompanion(isActive: Value(true)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,15 +21,52 @@ class TlsModeMismatchException implements Exception {
|
|||||||
'STARTTLS). Original error: $original';
|
'STARTTLS). Original error: $original';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If [error] is a TLS handshake failure caused by a wrong-version-number
|
/// Wraps a TLS certificate verification failure into a user-actionable message.
|
||||||
/// (i.e. the server is not speaking TLS), throw a [TlsModeMismatchException]
|
///
|
||||||
/// with [host]/[port] context. Otherwise rethrow [error] unchanged.
|
/// Thrown when the server's certificate cannot be verified — either because it
|
||||||
|
/// is self-signed, expired, or the CA chain has changed since the account was
|
||||||
|
/// set up.
|
||||||
|
class TlsCertificateException implements Exception {
|
||||||
|
TlsCertificateException(this.host, this.port, this.original);
|
||||||
|
final String host;
|
||||||
|
final int port;
|
||||||
|
final Object original;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'TLS certificate error on $host:$port — the server certificate could '
|
||||||
|
'not be verified. The certificate may have changed or expired. '
|
||||||
|
'Please re-check your account settings or contact your mail provider. '
|
||||||
|
'Original error: $original';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if [error] is a permanent TLS configuration error that will
|
||||||
|
/// not resolve on its own and requires user action.
|
||||||
|
bool isTlsConfigError(Object error) =>
|
||||||
|
error is TlsModeMismatchException || error is TlsCertificateException;
|
||||||
|
|
||||||
|
/// If [error] is a recognisable TLS handshake failure, wraps it in a typed
|
||||||
|
/// exception and throws it. Otherwise rethrows [error] unchanged.
|
||||||
|
///
|
||||||
|
/// Recognised patterns:
|
||||||
|
/// - `WRONG_VERSION_NUMBER` → [TlsModeMismatchException] (port/mode mismatch)
|
||||||
|
/// - `CERTIFICATE_VERIFY_FAILED` / `HandshakeException` → [TlsCertificateException]
|
||||||
Never rethrowAsTlsHint(Object error, StackTrace stack, String host, int port) {
|
Never rethrowAsTlsHint(Object error, StackTrace stack, String host, int port) {
|
||||||
if (error.toString().contains('WRONG_VERSION_NUMBER')) {
|
final s = error.toString();
|
||||||
|
if (s.contains('WRONG_VERSION_NUMBER')) {
|
||||||
Error.throwWithStackTrace(
|
Error.throwWithStackTrace(
|
||||||
TlsModeMismatchException(host, port, error),
|
TlsModeMismatchException(host, port, error),
|
||||||
stack,
|
stack,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (s.contains('CERTIFICATE_VERIFY_FAILED') ||
|
||||||
|
s.contains('HandshakeException') ||
|
||||||
|
s.contains('CERTIFICATE_EXPIRED') ||
|
||||||
|
s.contains('CERTIFICATE_UNKNOWN')) {
|
||||||
|
Error.throwWithStackTrace(
|
||||||
|
TlsCertificateException(host, port, error),
|
||||||
|
stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
Error.throwWithStackTrace(error, stack);
|
Error.throwWithStackTrace(error, stack);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/draft.dart';
|
import 'package:sharedinbox/core/models/draft.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/data/db/database.dart';
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
|
|
||||||
class DraftRepositoryImpl implements DraftRepository {
|
class DraftRepositoryImpl implements DraftRepository {
|
||||||
DraftRepositoryImpl(this._db);
|
DraftRepositoryImpl(
|
||||||
|
this._db,
|
||||||
|
this._accounts, {
|
||||||
|
ImapConnectFn? imapConnect,
|
||||||
|
}) : _imapConnect = imapConnect;
|
||||||
|
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
|
final AccountRepository _accounts;
|
||||||
|
final ImapConnectFn? _imapConnect;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SavedDraft> saveDraft({
|
Future<SavedDraft> saveDraft({
|
||||||
@@ -95,6 +105,110 @@ class DraftRepositoryImpl implements DraftRepository {
|
|||||||
await (_db.delete(_db.drafts)..where((t) => t.id.equals(id))).go();
|
await (_db.delete(_db.drafts)..where((t) => t.id.equals(id))).go();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> syncDrafts(String accountId, String password) async {
|
||||||
|
final connect = _imapConnect;
|
||||||
|
if (connect == null) return;
|
||||||
|
|
||||||
|
final account = await _accounts.getAccount(accountId);
|
||||||
|
if (account == null || account.type != AccountType.imap) return;
|
||||||
|
|
||||||
|
final username =
|
||||||
|
account.username.isNotEmpty ? account.username : account.email;
|
||||||
|
imap.ImapClient? client;
|
||||||
|
try {
|
||||||
|
client = await connect(account, username, password);
|
||||||
|
await _syncWithServer(client, accountId);
|
||||||
|
} finally {
|
||||||
|
await client?.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncWithServer(
|
||||||
|
imap.ImapClient client,
|
||||||
|
String accountId,
|
||||||
|
) async {
|
||||||
|
// Create/select the Drafts folder.
|
||||||
|
try {
|
||||||
|
await client.createMailbox('Drafts');
|
||||||
|
} catch (_) {
|
||||||
|
// Already exists.
|
||||||
|
}
|
||||||
|
final selectResult = await client.selectMailboxByPath('Drafts');
|
||||||
|
final messageCount = selectResult.messagesExists;
|
||||||
|
|
||||||
|
// Upload local drafts that have no server counterpart.
|
||||||
|
final localDrafts = await (_db.select(_db.drafts)
|
||||||
|
..where(
|
||||||
|
(t) => t.accountId.equals(accountId) & t.imapServerId.isNull(),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
for (final row in localDrafts) {
|
||||||
|
final builder = imap.MessageBuilder()
|
||||||
|
..to = _parseAddresses(row.toText)
|
||||||
|
..cc = _parseAddresses(row.ccText)
|
||||||
|
..subject = row.subjectText
|
||||||
|
..text = row.bodyText;
|
||||||
|
final mime = builder.buildMimeMessage();
|
||||||
|
final appendResult = await client.appendMessage(
|
||||||
|
mime,
|
||||||
|
targetMailboxPath: 'Drafts',
|
||||||
|
flags: [r'\Draft'],
|
||||||
|
);
|
||||||
|
final uidList =
|
||||||
|
appendResult.responseCodeAppendUid?.targetSequence.toList();
|
||||||
|
final uid = (uidList != null && uidList.isNotEmpty)
|
||||||
|
? uidList.first.toString()
|
||||||
|
: null;
|
||||||
|
if (uid != null) {
|
||||||
|
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id)))
|
||||||
|
.write(DraftsCompanion(imapServerId: Value(uid)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download server drafts not tracked locally.
|
||||||
|
if (messageCount > 0) {
|
||||||
|
final knownServerIds = await (_db.select(_db.drafts)
|
||||||
|
..where(
|
||||||
|
(t) => t.accountId.equals(accountId) & t.imapServerId.isNotNull(),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
final knownIds = knownServerIds.map((r) => r.imapServerId!).toSet();
|
||||||
|
|
||||||
|
final seq = imap.MessageSequence.fromAll();
|
||||||
|
final fetch = await client.uidFetchMessages(seq, '(UID FLAGS ENVELOPE)');
|
||||||
|
for (final msg in fetch.messages) {
|
||||||
|
final uid = msg.uid?.toString();
|
||||||
|
if (uid == null || knownIds.contains(uid)) continue;
|
||||||
|
if (msg.flags?.contains(r'\Deleted') ?? false) continue;
|
||||||
|
final env = msg.envelope;
|
||||||
|
final now = DateTime.now();
|
||||||
|
await _db.into(_db.drafts).insert(
|
||||||
|
DraftsCompanion.insert(
|
||||||
|
accountId: Value(accountId),
|
||||||
|
toText: Value(_addressListToText(env?.to)),
|
||||||
|
ccText: Value(_addressListToText(env?.cc)),
|
||||||
|
subjectText: Value(env?.subject ?? ''),
|
||||||
|
bodyText: const Value(''),
|
||||||
|
updatedAt: now,
|
||||||
|
imapServerId: Value(uid),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<imap.MailAddress> _parseAddresses(String text) {
|
||||||
|
if (text.trim().isEmpty) return [];
|
||||||
|
return text.split(',').map((s) => imap.MailAddress('', s.trim())).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _addressListToText(List<imap.MailAddress>? addresses) {
|
||||||
|
if (addresses == null || addresses.isEmpty) return '';
|
||||||
|
return addresses.map((a) => a.email).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
SavedDraft _toModel(Draft row) => SavedDraft(
|
SavedDraft _toModel(Draft row) => SavedDraft(
|
||||||
id: row.id,
|
id: row.id,
|
||||||
accountId: row.accountId,
|
accountId: row.accountId,
|
||||||
@@ -104,5 +218,6 @@ class DraftRepositoryImpl implements DraftRepository {
|
|||||||
subjectText: row.subjectText,
|
subjectText: row.subjectText,
|
||||||
bodyText: row.bodyText,
|
bodyText: row.bodyText,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
|
imapServerId: row.imapServerId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import 'package:sharedinbox/core/models/account.dart' as account_model;
|
|||||||
import 'package:sharedinbox/core/models/email.dart' as model;
|
import 'package:sharedinbox/core/models/email.dart' as model;
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_interpreter.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_parser.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
|
||||||
|
import 'package:sharedinbox/core/utils/cid_utils.dart';
|
||||||
import 'package:sharedinbox/core/utils/logger.dart';
|
import 'package:sharedinbox/core/utils/logger.dart';
|
||||||
import 'package:sharedinbox/data/db/database.dart';
|
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';
|
||||||
@@ -58,15 +62,17 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
@override
|
@override
|
||||||
Stream<List<model.Email>> observeEmails(
|
Stream<List<model.Email>> observeEmails(
|
||||||
String accountId,
|
String accountId,
|
||||||
String mailboxPath,
|
String mailboxPath, {
|
||||||
) {
|
int limit = 50,
|
||||||
|
}) {
|
||||||
return (_db.select(_db.emails)
|
return (_db.select(_db.emails)
|
||||||
..where(
|
..where(
|
||||||
(t) =>
|
(t) =>
|
||||||
t.accountId.equals(accountId) &
|
t.accountId.equals(accountId) &
|
||||||
t.mailboxPath.equals(mailboxPath),
|
t.mailboxPath.equals(mailboxPath),
|
||||||
)
|
)
|
||||||
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]))
|
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])
|
||||||
|
..limit(limit))
|
||||||
.watch()
|
.watch()
|
||||||
.map((rows) => rows.map(_toModel).toList());
|
.map((rows) => rows.map(_toModel).toList());
|
||||||
}
|
}
|
||||||
@@ -74,15 +80,17 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
@override
|
@override
|
||||||
Stream<List<model.EmailThread>> observeThreads(
|
Stream<List<model.EmailThread>> observeThreads(
|
||||||
String accountId,
|
String accountId,
|
||||||
String mailboxPath,
|
String mailboxPath, {
|
||||||
) {
|
int limit = 50,
|
||||||
|
}) {
|
||||||
return (_db.select(_db.threads)
|
return (_db.select(_db.threads)
|
||||||
..where(
|
..where(
|
||||||
(t) =>
|
(t) =>
|
||||||
t.accountId.equals(accountId) &
|
t.accountId.equals(accountId) &
|
||||||
t.mailboxPath.equals(mailboxPath),
|
t.mailboxPath.equals(mailboxPath),
|
||||||
)
|
)
|
||||||
..orderBy([(t) => OrderingTerm.desc(t.latestDate)]))
|
..orderBy([(t) => OrderingTerm.desc(t.latestDate)])
|
||||||
|
..limit(limit))
|
||||||
.watch()
|
.watch()
|
||||||
.map((rows) => rows.map(_threadRowToModel).toList());
|
.map((rows) => rows.map(_threadRowToModel).toList());
|
||||||
}
|
}
|
||||||
@@ -231,7 +239,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
|
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
|
||||||
final msg = fetch.messages.first;
|
final msg = fetch.messages.first;
|
||||||
final textBody = msg.decodeTextPlainPart();
|
final textBody = msg.decodeTextPlainPart();
|
||||||
final htmlBody = msg.decodeTextHtmlPart();
|
final rawHtml = msg.decodeTextHtmlPart();
|
||||||
|
final htmlBody =
|
||||||
|
rawHtml == null ? null : injectInlineImages(rawHtml, msg);
|
||||||
final contentInfos = msg.findContentInfo();
|
final contentInfos = msg.findContentInfo();
|
||||||
|
|
||||||
final attachmentsJson = jsonEncode(
|
final attachmentsJson = jsonEncode(
|
||||||
@@ -255,6 +265,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final mimeTreeJson = _buildMimeTreeJson(msg);
|
||||||
|
|
||||||
await _db.into(_db.emailBodies).insertOnConflictUpdate(
|
await _db.into(_db.emailBodies).insertOnConflictUpdate(
|
||||||
EmailBodiesCompanion.insert(
|
EmailBodiesCompanion.insert(
|
||||||
emailId: emailId,
|
emailId: emailId,
|
||||||
@@ -262,6 +274,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
htmlBody: Value(htmlBody),
|
htmlBody: Value(htmlBody),
|
||||||
attachmentsJson: Value(attachmentsJson),
|
attachmentsJson: Value(attachmentsJson),
|
||||||
headersJson: Value(headersJson),
|
headersJson: Value(headersJson),
|
||||||
|
mimeTreeJson: Value(mimeTreeJson),
|
||||||
cachedAt: Value(DateTime.now()),
|
cachedAt: Value(DateTime.now()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -271,6 +284,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
htmlBody: htmlBody,
|
htmlBody: htmlBody,
|
||||||
attachments: _parseAttachments(attachmentsJson),
|
attachments: _parseAttachments(attachmentsJson),
|
||||||
headers: _parseHeaders(headersJson),
|
headers: _parseHeaders(headersJson),
|
||||||
|
mimeTree: _parseMimeTree(mimeTreeJson),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await client.logout();
|
await client.logout();
|
||||||
@@ -307,9 +321,17 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
'htmlBody',
|
'htmlBody',
|
||||||
'bodyValues',
|
'bodyValues',
|
||||||
'attachments',
|
'attachments',
|
||||||
|
'bodyStructure',
|
||||||
],
|
],
|
||||||
'fetchHTMLBodyValues': true,
|
'fetchHTMLBodyValues': true,
|
||||||
'fetchTextBodyValues': true,
|
'fetchTextBodyValues': true,
|
||||||
|
'bodyProperties': [
|
||||||
|
'partId',
|
||||||
|
'type',
|
||||||
|
'name',
|
||||||
|
'size',
|
||||||
|
'subParts',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
'0',
|
'0',
|
||||||
],
|
],
|
||||||
@@ -329,6 +351,12 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final rawBodyStructure =
|
||||||
|
emailData['bodyStructure'] as Map<String, dynamic>?;
|
||||||
|
final mimeTreeJson = rawBodyStructure != null
|
||||||
|
? jsonEncode(_jmapBodyStructureToJson(rawBodyStructure))
|
||||||
|
: null;
|
||||||
|
|
||||||
await _db.into(_db.emailBodies).insertOnConflictUpdate(
|
await _db.into(_db.emailBodies).insertOnConflictUpdate(
|
||||||
EmailBodiesCompanion.insert(
|
EmailBodiesCompanion.insert(
|
||||||
emailId: emailId,
|
emailId: emailId,
|
||||||
@@ -336,6 +364,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
htmlBody: Value(htmlBody),
|
htmlBody: Value(htmlBody),
|
||||||
attachmentsJson: Value(attachmentsJson),
|
attachmentsJson: Value(attachmentsJson),
|
||||||
headersJson: Value(headersJson),
|
headersJson: Value(headersJson),
|
||||||
|
mimeTreeJson: Value(mimeTreeJson),
|
||||||
cachedAt: Value(DateTime.now()),
|
cachedAt: Value(DateTime.now()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -346,6 +375,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
htmlBody: htmlBody,
|
htmlBody: htmlBody,
|
||||||
attachments: _parseAttachments(attachmentsJson),
|
attachments: _parseAttachments(attachmentsJson),
|
||||||
headers: _parseHeaders(headersJson),
|
headers: _parseHeaders(headersJson),
|
||||||
|
mimeTree: _parseMimeTree(mimeTreeJson),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,7 +558,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
imap.MessageSequence sequence,
|
imap.MessageSequence sequence,
|
||||||
) async {
|
) async {
|
||||||
const fetchItems =
|
const fetchItems =
|
||||||
'(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (REFERENCES)])';
|
'(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (REFERENCES LIST-UNSUBSCRIBE)])';
|
||||||
final fetch = sequence.isUidSequence
|
final fetch = sequence.isUidSequence
|
||||||
? await client.uidFetchMessages(sequence, fetchItems)
|
? await client.uidFetchMessages(sequence, fetchItems)
|
||||||
: await client.fetchMessages(sequence, fetchItems);
|
: await client.fetchMessages(sequence, fetchItems);
|
||||||
@@ -569,6 +599,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final msgId = envelope.messageId?.trim();
|
final msgId = envelope.messageId?.trim();
|
||||||
final inReplyTo = envelope.inReplyTo?.trim();
|
final inReplyTo = envelope.inReplyTo?.trim();
|
||||||
final refs = msg.getHeaderValue('References')?.trim();
|
final refs = msg.getHeaderValue('References')?.trim();
|
||||||
|
final listUnsubscribe = msg.getHeaderValue('List-Unsubscribe')?.trim();
|
||||||
final threadId = _computeThreadId(
|
final threadId = _computeThreadId(
|
||||||
emailId: emailId,
|
emailId: emailId,
|
||||||
messageId: msgId,
|
messageId: msgId,
|
||||||
@@ -612,6 +643,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
inReplyTo: Value(inReplyTo),
|
inReplyTo: Value(inReplyTo),
|
||||||
references: Value(refs),
|
references: Value(refs),
|
||||||
snoozedUntil: Value(snoozedUntil),
|
snoozedUntil: Value(snoozedUntil),
|
||||||
|
listUnsubscribeHeader: Value(listUnsubscribe),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -950,6 +982,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
'htmlBody',
|
'htmlBody',
|
||||||
'bodyValues',
|
'bodyValues',
|
||||||
'attachments',
|
'attachments',
|
||||||
|
'header:List-Unsubscribe:asText',
|
||||||
];
|
];
|
||||||
|
|
||||||
static const _emailGetBodyOptions = {
|
static const _emailGetBodyOptions = {
|
||||||
@@ -1151,6 +1184,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final jmapReferences = _joinJmapStringList(
|
final jmapReferences = _joinJmapStringList(
|
||||||
m['references'] as List<dynamic>?,
|
m['references'] as List<dynamic>?,
|
||||||
);
|
);
|
||||||
|
final jmapListUnsubscribe =
|
||||||
|
(m['header:List-Unsubscribe:asText'] as String?)?.trim();
|
||||||
|
|
||||||
await _db.into(_db.emails).insertOnConflictUpdate(
|
await _db.into(_db.emails).insertOnConflictUpdate(
|
||||||
EmailsCompanion.insert(
|
EmailsCompanion.insert(
|
||||||
@@ -1173,6 +1208,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
inReplyTo: Value(jmapInReplyTo),
|
inReplyTo: Value(jmapInReplyTo),
|
||||||
references: Value(jmapReferences),
|
references: Value(jmapReferences),
|
||||||
snoozedUntil: Value(snoozedUntil),
|
snoozedUntil: Value(snoozedUntil),
|
||||||
|
listUnsubscribeHeader: Value(jmapListUnsubscribe),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1438,7 +1474,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final row = await (_db.select(
|
final row = await (_db.select(
|
||||||
_db.emails,
|
_db.emails,
|
||||||
)..where((t) => t.id.equals(emailId)))
|
)..where((t) => t.id.equals(emailId)))
|
||||||
.getSingle();
|
.getSingleOrNull();
|
||||||
|
if (row == null) return;
|
||||||
final account = (await _accounts.getAccount(row.accountId))!;
|
final account = (await _accounts.getAccount(row.accountId))!;
|
||||||
|
|
||||||
if (account.type == account_model.AccountType.jmap) {
|
if (account.type == account_model.AccountType.jmap) {
|
||||||
@@ -1510,12 +1547,70 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markAllAsRead(String accountId, String mailboxPath) async {
|
||||||
|
final account = (await _accounts.getAccount(accountId))!;
|
||||||
|
final unread = await (_db.select(_db.emails)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(accountId) &
|
||||||
|
t.mailboxPath.equals(mailboxPath) &
|
||||||
|
t.isSeen.equals(false),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
if (unread.isEmpty) return;
|
||||||
|
|
||||||
|
await _db.transaction(() async {
|
||||||
|
for (final row in unread) {
|
||||||
|
if (account.type == account_model.AccountType.jmap) {
|
||||||
|
await _enqueueChange(
|
||||||
|
accountId,
|
||||||
|
row.id,
|
||||||
|
'flag_seen',
|
||||||
|
jsonEncode({'seen': true}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await _enqueueChange(
|
||||||
|
accountId,
|
||||||
|
row.id,
|
||||||
|
'flag_seen',
|
||||||
|
jsonEncode({
|
||||||
|
'uid': row.uid,
|
||||||
|
'mailboxPath': row.mailboxPath,
|
||||||
|
'seen': true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk mark all unread emails in this mailbox as seen.
|
||||||
|
await (_db.update(_db.emails)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(accountId) &
|
||||||
|
t.mailboxPath.equals(mailboxPath) &
|
||||||
|
t.isSeen.equals(false),
|
||||||
|
))
|
||||||
|
.write(const EmailsCompanion(isSeen: Value(true)));
|
||||||
|
|
||||||
|
// Update all threads in this mailbox to reflect no unread.
|
||||||
|
await (_db.update(_db.threads)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(accountId) &
|
||||||
|
t.mailboxPath.equals(mailboxPath),
|
||||||
|
))
|
||||||
|
.write(const ThreadsCompanion(hasUnread: Value(false)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> moveEmail(String emailId, String destMailboxPath) async {
|
Future<void> moveEmail(String emailId, String destMailboxPath) async {
|
||||||
final row = await (_db.select(
|
final row = await (_db.select(
|
||||||
_db.emails,
|
_db.emails,
|
||||||
)..where((t) => t.id.equals(emailId)))
|
)..where((t) => t.id.equals(emailId)))
|
||||||
.getSingle();
|
.getSingleOrNull();
|
||||||
|
if (row == null) return;
|
||||||
final account = (await _accounts.getAccount(row.accountId))!;
|
final account = (await _accounts.getAccount(row.accountId))!;
|
||||||
|
|
||||||
if (row.mailboxPath == destMailboxPath) {
|
if (row.mailboxPath == destMailboxPath) {
|
||||||
@@ -1583,7 +1678,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final row = await (_db.select(
|
final row = await (_db.select(
|
||||||
_db.emails,
|
_db.emails,
|
||||||
)..where((t) => t.id.equals(emailId)))
|
)..where((t) => t.id.equals(emailId)))
|
||||||
.getSingle();
|
.getSingleOrNull();
|
||||||
|
if (row == null) return null;
|
||||||
final account = (await _accounts.getAccount(row.accountId))!;
|
final account = (await _accounts.getAccount(row.accountId))!;
|
||||||
|
|
||||||
// Move to Trash when possible so the user can recover the message.
|
// Move to Trash when possible so the user can recover the message.
|
||||||
@@ -1777,6 +1873,22 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
return expired.length;
|
return expired.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@override
|
||||||
|
Future<model.Email?> findEmailByMessageId(
|
||||||
|
String accountId,
|
||||||
|
String messageId,
|
||||||
|
) async {
|
||||||
|
final row = await (_db.select(_db.emails)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(accountId) & t.messageId.equals(messageId),
|
||||||
|
)
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
return row == null ? null : _toModel(row);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> restoreEmails(List<model.Email> emails) async {
|
Future<void> restoreEmails(List<model.Email> emails) async {
|
||||||
for (final e in emails) {
|
for (final e in emails) {
|
||||||
@@ -1808,6 +1920,218 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Applies locally stored active Sieve rules to INBOX emails that have not
|
||||||
|
/// been processed yet. See [EmailRepository.applySieveRules] for details.
|
||||||
|
@override
|
||||||
|
Future<int> applySieveRules(String accountId) async {
|
||||||
|
final scriptRow = await (_db.select(_db.localSieveScripts)
|
||||||
|
..where(
|
||||||
|
(t) => t.accountId.equals(accountId) & t.isActive.equals(true),
|
||||||
|
)
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (scriptRow == null) return 0;
|
||||||
|
|
||||||
|
List<SieveRule> rules;
|
||||||
|
try {
|
||||||
|
rules = SieveParser().parse(scriptRow.content);
|
||||||
|
} catch (e) {
|
||||||
|
log('Sieve parse error for account $accountId: $e');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (rules.isEmpty) return 0;
|
||||||
|
|
||||||
|
final inboxMailbox = await (_db.select(_db.mailboxes)
|
||||||
|
..where(
|
||||||
|
(t) => t.accountId.equals(accountId) & t.role.equals('inbox'),
|
||||||
|
)
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
final inboxPath = inboxMailbox?.path ?? 'INBOX';
|
||||||
|
|
||||||
|
final alreadyApplied = await (_db.select(_db.localSieveApplied)
|
||||||
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
|
.get();
|
||||||
|
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
|
||||||
|
|
||||||
|
final inboxEmails = await (_db.select(_db.emails)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(accountId) &
|
||||||
|
t.mailboxPath.equals(inboxPath) &
|
||||||
|
t.messageId.isNotNull(),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
final account = (await _accounts.getAccount(accountId))!;
|
||||||
|
final interpreter = SieveInterpreter();
|
||||||
|
var matched = 0;
|
||||||
|
|
||||||
|
for (final row in inboxEmails) {
|
||||||
|
final msgId = row.messageId!;
|
||||||
|
if (appliedIds.contains(msgId)) continue;
|
||||||
|
|
||||||
|
final emailCtx = _buildSieveContext(row);
|
||||||
|
|
||||||
|
SieveExecutionContext result;
|
||||||
|
try {
|
||||||
|
result = interpreter.execute(rules, emailCtx);
|
||||||
|
} catch (e) {
|
||||||
|
log('Sieve interpreter error for message $msgId: $e');
|
||||||
|
await _markSieveApplied(accountId, msgId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _markSieveApplied(accountId, msgId);
|
||||||
|
|
||||||
|
if (result.isCancelled) {
|
||||||
|
await _enqueueSieveDelete(account, row);
|
||||||
|
matched++;
|
||||||
|
} else if (result.targetFolders.isNotEmpty) {
|
||||||
|
final dest = result.targetFolders.first;
|
||||||
|
await _enqueueSieveMove(account, row, dest);
|
||||||
|
matched++;
|
||||||
|
} else if (result.flagsToAdd.isNotEmpty) {
|
||||||
|
await _enqueueSieveFlagSeen(account, row);
|
||||||
|
matched++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
SieveEmailContext _buildSieveContext(Email row) {
|
||||||
|
String formatAddrs(String json) {
|
||||||
|
try {
|
||||||
|
final list = jsonDecode(json) as List<dynamic>;
|
||||||
|
return list.map((e) {
|
||||||
|
final m = e as Map<String, dynamic>;
|
||||||
|
final name = m['name'] as String? ?? '';
|
||||||
|
final email = m['email'] as String? ?? '';
|
||||||
|
return name.isEmpty ? email : '$name <$email>';
|
||||||
|
}).join(', ');
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SieveEmailContext(
|
||||||
|
headers: {
|
||||||
|
if (row.subject != null && row.subject!.isNotEmpty)
|
||||||
|
'subject': [row.subject!],
|
||||||
|
'from': [formatAddrs(row.fromJson)],
|
||||||
|
'to': [formatAddrs(row.toAddresses)],
|
||||||
|
'cc': [formatAddrs(row.ccJson)],
|
||||||
|
if (row.messageId != null) 'message-id': [row.messageId!],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markSieveApplied(String accountId, String messageId) async {
|
||||||
|
await _db.into(_db.localSieveApplied).insertOnConflictUpdate(
|
||||||
|
LocalSieveAppliedCompanion.insert(
|
||||||
|
accountId: accountId,
|
||||||
|
messageId: messageId,
|
||||||
|
appliedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _enqueueSieveMove(
|
||||||
|
account_model.Account account,
|
||||||
|
Email row,
|
||||||
|
String folder,
|
||||||
|
) async {
|
||||||
|
String destPath;
|
||||||
|
if (account.type == account_model.AccountType.jmap) {
|
||||||
|
final destMailbox = await (_db.select(_db.mailboxes)
|
||||||
|
..where(
|
||||||
|
(t) => t.accountId.equals(account.id) & t.name.equals(folder),
|
||||||
|
)
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (destMailbox == null) {
|
||||||
|
log('Sieve: JMAP mailbox "$folder" not found for account ${account.id}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
destPath = destMailbox.path;
|
||||||
|
await _enqueueChange(
|
||||||
|
account.id,
|
||||||
|
row.id,
|
||||||
|
'move',
|
||||||
|
jsonEncode({'src': row.mailboxPath, 'dest': destPath}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
destPath = folder;
|
||||||
|
await _enqueueChange(
|
||||||
|
account.id,
|
||||||
|
row.id,
|
||||||
|
'move',
|
||||||
|
jsonEncode({
|
||||||
|
'uid': row.uid,
|
||||||
|
'mailboxPath': row.mailboxPath,
|
||||||
|
'dest': destPath,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await (_db.update(_db.emails)..where((t) => t.id.equals(row.id))).write(
|
||||||
|
EmailsCompanion(mailboxPath: Value(destPath)),
|
||||||
|
);
|
||||||
|
await _updateThread(account.id, row.mailboxPath, row.threadId ?? row.id);
|
||||||
|
await _updateThread(account.id, destPath, row.threadId ?? row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _enqueueSieveDelete(
|
||||||
|
account_model.Account account,
|
||||||
|
Email row,
|
||||||
|
) async {
|
||||||
|
if (account.type == account_model.AccountType.jmap) {
|
||||||
|
await _enqueueChange(
|
||||||
|
account.id,
|
||||||
|
row.id,
|
||||||
|
'delete',
|
||||||
|
jsonEncode(<String, dynamic>{}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await _enqueueChange(
|
||||||
|
account.id,
|
||||||
|
row.id,
|
||||||
|
'delete',
|
||||||
|
jsonEncode({'uid': row.uid, 'mailboxPath': row.mailboxPath}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await (_db.delete(_db.emails)..where((t) => t.id.equals(row.id))).go();
|
||||||
|
await _updateThread(account.id, row.mailboxPath, row.threadId ?? row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _enqueueSieveFlagSeen(
|
||||||
|
account_model.Account account,
|
||||||
|
Email row,
|
||||||
|
) async {
|
||||||
|
if (account.type == account_model.AccountType.jmap) {
|
||||||
|
await _enqueueChange(
|
||||||
|
account.id,
|
||||||
|
row.id,
|
||||||
|
'flag_seen',
|
||||||
|
jsonEncode({'seen': true}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await _enqueueChange(
|
||||||
|
account.id,
|
||||||
|
row.id,
|
||||||
|
'flag_seen',
|
||||||
|
jsonEncode({
|
||||||
|
'uid': row.uid,
|
||||||
|
'mailboxPath': row.mailboxPath,
|
||||||
|
'seen': true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await (_db.update(_db.emails)..where((t) => t.id.equals(row.id))).write(
|
||||||
|
const EmailsCompanion(isSeen: Value(true)),
|
||||||
|
);
|
||||||
|
await _updateThread(account.id, row.mailboxPath, row.threadId ?? row.id);
|
||||||
|
}
|
||||||
|
|
||||||
/// Drains pending changes for [accountId] via the appropriate protocol.
|
/// Drains pending changes for [accountId] via the appropriate protocol.
|
||||||
/// Called at the start of each sync cycle. Returns count of applied changes.
|
/// Called at the start of each sync cycle. Returns count of applied changes.
|
||||||
@override
|
@override
|
||||||
@@ -1923,7 +2247,18 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
.go();
|
.go();
|
||||||
applied++;
|
applied++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await _recordChangeError(row, e);
|
if (_isImapNotFoundError(e)) {
|
||||||
|
// Email already gone on the server — treat as success so the
|
||||||
|
// pending change doesn't accumulate or block future changes.
|
||||||
|
await (_db.delete(
|
||||||
|
_db.pendingChanges,
|
||||||
|
)..where((t) => t.id.equals(row.id)))
|
||||||
|
.go();
|
||||||
|
applied++;
|
||||||
|
log('IMAP change ${row.id} skipped: message already gone ($e)');
|
||||||
|
} else {
|
||||||
|
await _recordChangeError(row, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1932,13 +2267,19 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
return applied;
|
return applied;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isImapNotFoundError(Object e) {
|
||||||
|
final s = e.toString().toLowerCase();
|
||||||
|
return s.contains('nonexistent') || s.contains('not found');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _applyPendingChangeImap(
|
Future<void> _applyPendingChangeImap(
|
||||||
imap.ImapClient client,
|
imap.ImapClient client,
|
||||||
PendingChangeRow row,
|
PendingChangeRow row,
|
||||||
) async {
|
) async {
|
||||||
final payload = jsonDecode(row.payload) as Map<String, dynamic>;
|
final payload = jsonDecode(row.payload) as Map<String, dynamic>;
|
||||||
final uid = payload['uid'] as int;
|
final uid = payload['uid'] as int;
|
||||||
final mailboxPath = payload['mailboxPath'] as String;
|
// snooze/unsnooze payloads use 'src' for the source folder; all others use 'mailboxPath'.
|
||||||
|
final mailboxPath = (payload['mailboxPath'] ?? payload['src']) as String;
|
||||||
final seq = imap.MessageSequence.fromId(uid, isUid: true);
|
final seq = imap.MessageSequence.fromId(uid, isUid: true);
|
||||||
await client.selectMailboxByPath(mailboxPath);
|
await client.selectMailboxByPath(mailboxPath);
|
||||||
|
|
||||||
@@ -2077,8 +2418,29 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final until = payload['until'] as String;
|
final until = payload['until'] as String;
|
||||||
final timestamp = until.replaceAll(':', '').replaceAll('-', '');
|
final timestamp = until.replaceAll(':', '').replaceAll('-', '');
|
||||||
final keyword = 'snz:$timestamp';
|
final keyword = 'snz:$timestamp';
|
||||||
final destMailboxId = payload['dest'] as String;
|
var destMailboxId = payload['dest'] as String;
|
||||||
final srcMailboxId = payload['src'] as String;
|
final srcMailboxId = payload['src'] as String;
|
||||||
|
// When the Snoozed folder didn't exist at enqueue time, 'dest' holds
|
||||||
|
// the literal name 'Snoozed' rather than a JMAP mailbox ID. Create it.
|
||||||
|
if (destMailboxId == 'Snoozed') {
|
||||||
|
final createResps = await jmap.call([
|
||||||
|
[
|
||||||
|
'Mailbox/set',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'create': {
|
||||||
|
'new-snoozed': {'name': 'Snoozed', 'role': 'snoozed'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final createResult = _responseArgs(createResps, 0, 'Mailbox/set');
|
||||||
|
final created = createResult['created'] as Map<String, dynamic>?;
|
||||||
|
final newId = (created?['new-snoozed']
|
||||||
|
as Map<String, dynamic>?)?['id'] as String?;
|
||||||
|
if (newId != null) destMailboxId = newId;
|
||||||
|
}
|
||||||
responses = await jmap.call([
|
responses = await jmap.call([
|
||||||
[
|
[
|
||||||
'Email/set',
|
'Email/set',
|
||||||
@@ -2442,9 +2804,13 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||||
|
// Fetch the full message so enough_mail has MIME headers (including
|
||||||
|
// Content-Transfer-Encoding) and getPart() can decode the part correctly.
|
||||||
|
// A partial BODY.PEEK[n] fetch omits those headers, causing
|
||||||
|
// decodeContentBinary() to return raw base64 instead of decoded bytes.
|
||||||
final fetch = await client.uidFetchMessage(
|
final fetch = await client.uidFetchMessage(
|
||||||
emailRow.uid,
|
emailRow.uid,
|
||||||
'BODY.PEEK[${attachment.fetchPartId}]',
|
'BODY.PEEK[]',
|
||||||
);
|
);
|
||||||
final msg = fetch.messages.first;
|
final msg = fetch.messages.first;
|
||||||
final part = msg.getPart(attachment.fetchPartId) ?? msg;
|
final part = msg.getPart(attachment.fetchPartId) ?? msg;
|
||||||
@@ -2459,33 +2825,103 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> fetchRawRfc822(String emailId) async {
|
||||||
|
final emailRow = await (_db.select(
|
||||||
|
_db.emails,
|
||||||
|
)..where((t) => t.id.equals(emailId)))
|
||||||
|
.getSingle();
|
||||||
|
final account = (await _accounts.getAccount(emailRow.accountId))!;
|
||||||
|
final password = await _accounts.getPassword(account.id);
|
||||||
|
|
||||||
|
if (account.type == account_model.AccountType.jmap) {
|
||||||
|
final jmap = await JmapClient.connect(
|
||||||
|
httpClient: _httpClient,
|
||||||
|
jmapUrl: Uri.parse(account.jmapUrl!),
|
||||||
|
username: _effectiveUsername(account),
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
final jmapEmailId = emailId.contains(':')
|
||||||
|
? emailId.substring(emailId.indexOf(':') + 1)
|
||||||
|
: emailId;
|
||||||
|
final responses = await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/get',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'ids': [jmapEmailId],
|
||||||
|
'properties': ['id', 'blobId'],
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final result = _responseArgs(responses, 0, 'Email/get');
|
||||||
|
final emailData =
|
||||||
|
(result['list'] as List<dynamic>).first as Map<String, dynamic>;
|
||||||
|
final blobId = emailData['blobId'] as String;
|
||||||
|
final bytes = await jmap.downloadBlob(
|
||||||
|
blobId,
|
||||||
|
name: 'email.eml',
|
||||||
|
type: 'message/rfc822',
|
||||||
|
);
|
||||||
|
return utf8.decode(bytes, allowMalformed: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
final client = await _imapConnect(
|
||||||
|
account,
|
||||||
|
_effectiveUsername(account),
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||||
|
final fetch = await client.uidFetchMessage(
|
||||||
|
emailRow.uid,
|
||||||
|
'BODY.PEEK[]',
|
||||||
|
);
|
||||||
|
return fetch.messages.first.renderMessage();
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<model.Email>> searchEmailsGlobal(
|
Future<List<model.Email>> searchEmailsGlobal(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String query,
|
String query,
|
||||||
) async {
|
) async {
|
||||||
|
final ftsQuery = _toFtsQuery(query);
|
||||||
|
if (ftsQuery.isEmpty) return [];
|
||||||
|
|
||||||
|
final sql = accountId != null
|
||||||
|
? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||||
|
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank LIMIT 50'
|
||||||
|
: 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||||
|
' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50';
|
||||||
|
final variables = accountId != null
|
||||||
|
? [Variable<String>(ftsQuery), Variable<String>(accountId)]
|
||||||
|
: [Variable<String>(ftsQuery)];
|
||||||
|
|
||||||
|
final queryRows = await _db
|
||||||
|
.customSelect(sql, variables: variables, readsFrom: {_db.emails}).get();
|
||||||
|
final emailRows = await Future.wait(
|
||||||
|
queryRows.map((r) => _db.emails.mapFromRow(r)),
|
||||||
|
);
|
||||||
|
return emailRows.map(_toModel).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a user query string into an FTS5 match expression.
|
||||||
|
/// Each whitespace-separated word becomes a prefix term (word*) so that
|
||||||
|
/// partial words still match. Special FTS5 characters are stripped.
|
||||||
|
static String _toFtsQuery(String query) {
|
||||||
final words = query
|
final words = query
|
||||||
.toLowerCase()
|
.trim()
|
||||||
.split(RegExp(r'\s+'))
|
.split(RegExp(r'\s+'))
|
||||||
.where((w) => w.isNotEmpty)
|
.where((w) => w.isNotEmpty)
|
||||||
|
.map((w) => w.replaceAll(RegExp(r'[^\w]'), ''))
|
||||||
|
.where((w) => w.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
final rows = await (_db.select(_db.emails)
|
if (words.isEmpty) return '';
|
||||||
..where((t) {
|
return words.map((w) => '$w*').join(' ');
|
||||||
Expression<bool> condition = const Constant(true);
|
|
||||||
if (accountId != null) {
|
|
||||||
condition = t.accountId.equals(accountId);
|
|
||||||
}
|
|
||||||
for (final word in words) {
|
|
||||||
final pattern = '%$word%';
|
|
||||||
condition = condition &
|
|
||||||
(t.subject.like(pattern) | t.preview.like(pattern));
|
|
||||||
}
|
|
||||||
return condition;
|
|
||||||
})
|
|
||||||
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])
|
|
||||||
..limit(50))
|
|
||||||
.get();
|
|
||||||
return rows.map(_toModel).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -2511,6 +2947,52 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
return rows.map(_toModel).toList();
|
return rows.map(_toModel).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<model.EmailAddress>> searchAddresses(
|
||||||
|
String? accountId,
|
||||||
|
String query, {
|
||||||
|
int limit = 10,
|
||||||
|
}) async {
|
||||||
|
if (query.length < 2) return [];
|
||||||
|
final pattern = '%${query.toLowerCase()}%';
|
||||||
|
final rows = await (_db.select(_db.emails)
|
||||||
|
..where((t) {
|
||||||
|
Expression<bool> cond = const Constant(true);
|
||||||
|
if (accountId != null) cond = t.accountId.equals(accountId);
|
||||||
|
cond = cond &
|
||||||
|
(t.fromJson.like(pattern) |
|
||||||
|
t.toAddresses.like(pattern) |
|
||||||
|
t.ccJson.like(pattern));
|
||||||
|
return cond;
|
||||||
|
})
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])
|
||||||
|
..limit(100))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
final seen = <String>{};
|
||||||
|
final results = <model.EmailAddress>[];
|
||||||
|
final lowerQuery = query.toLowerCase();
|
||||||
|
for (final row in rows) {
|
||||||
|
for (final jsonStr in [row.fromJson, row.toAddresses, row.ccJson]) {
|
||||||
|
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||||
|
for (final e in list) {
|
||||||
|
final map = e as Map<String, dynamic>;
|
||||||
|
final addr = model.EmailAddress(
|
||||||
|
name: map['name'] as String?,
|
||||||
|
email: map['email'] as String,
|
||||||
|
);
|
||||||
|
if ((addr.email.toLowerCase().contains(lowerQuery) ||
|
||||||
|
(addr.name?.toLowerCase().contains(lowerQuery) ?? false)) &&
|
||||||
|
seen.add(addr.email.toLowerCase())) {
|
||||||
|
results.add(addr);
|
||||||
|
if (results.length >= limit) return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<model.Email>> searchEmails(
|
Future<List<model.Email>> searchEmails(
|
||||||
String accountId,
|
String accountId,
|
||||||
@@ -2663,6 +3145,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
references: row.references,
|
references: row.references,
|
||||||
snoozedUntil: row.snoozedUntil,
|
snoozedUntil: row.snoozedUntil,
|
||||||
snoozedFromMailboxPath: row.snoozedFromMailboxPath,
|
snoozedFromMailboxPath: row.snoozedFromMailboxPath,
|
||||||
|
listUnsubscribeHeader: row.listUnsubscribeHeader,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2672,6 +3155,27 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
htmlBody: row.htmlBody,
|
htmlBody: row.htmlBody,
|
||||||
attachments: _parseAttachments(row.attachmentsJson),
|
attachments: _parseAttachments(row.attachmentsJson),
|
||||||
headers: _parseHeaders(row.headersJson),
|
headers: _parseHeaders(row.headersJson),
|
||||||
|
mimeTree: _parseMimeTree(row.mimeTreeJson),
|
||||||
|
);
|
||||||
|
|
||||||
|
model.MimePart? _parseMimeTree(String? jsonStr) {
|
||||||
|
if (jsonStr == null || jsonStr.isEmpty) return null;
|
||||||
|
try {
|
||||||
|
return _mimePartFromJson(jsonDecode(jsonStr) as Map<String, dynamic>);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
model.MimePart _mimePartFromJson(Map<String, dynamic> m) => model.MimePart(
|
||||||
|
contentType: m['contentType'] as String? ?? 'application/octet-stream',
|
||||||
|
filename: m['filename'] as String?,
|
||||||
|
size: m['size'] as int?,
|
||||||
|
encoding: m['encoding'] as String?,
|
||||||
|
children: ((m['children'] as List<dynamic>?) ?? [])
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
.map(_mimePartFromJson)
|
||||||
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
List<model.EmailHeader> _parseHeaders(String? jsonStr) {
|
List<model.EmailHeader> _parseHeaders(String? jsonStr) {
|
||||||
@@ -2763,3 +3267,36 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recursively converts an [imap.MimePart] into a JSON-serialisable map.
|
||||||
|
Map<String, dynamic> _mimePartToJson(imap.MimePart part) {
|
||||||
|
final ct = part.getHeaderContentType();
|
||||||
|
final disposition = part.getHeaderContentDisposition();
|
||||||
|
final rawEncoding =
|
||||||
|
part.getHeader('content-transfer-encoding')?.firstOrNull?.value;
|
||||||
|
final encoding = rawEncoding?.split(';').first.trim().toLowerCase();
|
||||||
|
return {
|
||||||
|
'contentType': ct?.mediaType.text ?? 'application/octet-stream',
|
||||||
|
'filename': disposition?.filename ?? ct?.parameters['name'],
|
||||||
|
'size': disposition?.size,
|
||||||
|
'encoding': encoding,
|
||||||
|
'children': (part.parts ?? []).map(_mimePartToJson).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a JSON string representing the MIME tree of [msg].
|
||||||
|
String _buildMimeTreeJson(imap.MimeMessage msg) =>
|
||||||
|
jsonEncode(_mimePartToJson(msg));
|
||||||
|
|
||||||
|
/// Converts a JMAP `bodyStructure` object into the same JSON format used by
|
||||||
|
/// [_mimePartToJson], so [_parseMimeTree] can deserialise it uniformly.
|
||||||
|
Map<String, dynamic> _jmapBodyStructureToJson(Map<String, dynamic> m) => {
|
||||||
|
'contentType': m['type'] as String? ?? 'application/octet-stream',
|
||||||
|
'filename': m['name'],
|
||||||
|
'size': m['size'],
|
||||||
|
'encoding': null,
|
||||||
|
'children': ((m['subParts'] as List<dynamic>?) ?? [])
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
.map(_jmapBodyStructureToJson)
|
||||||
|
.toList(),
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
|
||||||
|
class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
||||||
|
SearchHistoryRepositoryImpl(this._db);
|
||||||
|
final AppDatabase _db;
|
||||||
|
|
||||||
|
static const _maxEntries = 10;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> getRecentSearches() async {
|
||||||
|
final rows = await (_db.select(_db.searchHistoryEntries)
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
|
||||||
|
..limit(_maxEntries))
|
||||||
|
.get();
|
||||||
|
return rows.map((r) => r.query).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveSearch(String query) async {
|
||||||
|
final trimmed = query.trim();
|
||||||
|
if (trimmed.isEmpty) return;
|
||||||
|
|
||||||
|
await _db.transaction(() async {
|
||||||
|
// Remove existing entry for same query (deduplication).
|
||||||
|
await (_db.delete(_db.searchHistoryEntries)
|
||||||
|
..where((t) => t.query.equals(trimmed)))
|
||||||
|
.go();
|
||||||
|
|
||||||
|
await _db.into(_db.searchHistoryEntries).insert(
|
||||||
|
SearchHistoryEntriesCompanion.insert(
|
||||||
|
query: trimmed,
|
||||||
|
searchedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prune to the most recent _maxEntries.
|
||||||
|
final keepIds = await (_db.select(_db.searchHistoryEntries)
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
|
||||||
|
..limit(_maxEntries))
|
||||||
|
.map((r) => r.id)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (keepIds.isNotEmpty) {
|
||||||
|
await (_db.delete(_db.searchHistoryEntries)
|
||||||
|
..where((t) => t.id.isNotIn(keepIds)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearHistory() async {
|
||||||
|
await _db.delete(_db.searchHistoryEntries).go();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
|
||||||
|
/// Drift-backed implementation of [ShareKeyRepository].
|
||||||
|
///
|
||||||
|
/// Each key pair lives for 20 minutes. Expired rows are pruned whenever a
|
||||||
|
/// new key pair is created or looked up.
|
||||||
|
class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
||||||
|
ShareKeyRepositoryImpl(this._db);
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ShareKeyMaterial> createKeyPair() async {
|
||||||
|
await _pruneExpired();
|
||||||
|
|
||||||
|
final material = await ShareEncryptionService.generateKeyPair();
|
||||||
|
final keyIdHex = _hex(material.keyId);
|
||||||
|
final expiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
|
||||||
|
|
||||||
|
await _db.into(_db.shareKeys).insert(
|
||||||
|
ShareKeysCompanion.insert(
|
||||||
|
id: keyIdHex,
|
||||||
|
publicKey: base64.encode(material.publicKeyBytes),
|
||||||
|
privateKey: base64.encode(material.privateKeyBytes),
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return material;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ShareKeyMaterial?> findByKeyId(Uint8List keyId) async {
|
||||||
|
await _pruneExpired();
|
||||||
|
|
||||||
|
final keyIdHex = _hex(keyId);
|
||||||
|
final row = await (_db.select(_db.shareKeys)
|
||||||
|
..where((t) => t.id.equals(keyIdHex)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
|
||||||
|
if (row == null) return null;
|
||||||
|
if (row.expiresAt.isBefore(DateTime.now().toUtc())) return null;
|
||||||
|
|
||||||
|
return ShareKeyMaterial(
|
||||||
|
keyId: keyId,
|
||||||
|
publicKeyBytes: Uint8List.fromList(base64.decode(row.publicKey)),
|
||||||
|
privateKeyBytes: Uint8List.fromList(base64.decode(row.privateKey)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pruneExpired() async {
|
||||||
|
await (_db.delete(_db.shareKeys)
|
||||||
|
..where(
|
||||||
|
(t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()),
|
||||||
|
))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _hex(Uint8List bytes) =>
|
||||||
|
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
|||||||
fetched: Value(s.fetched),
|
fetched: Value(s.fetched),
|
||||||
skipped: Value(s.skipped),
|
skipped: Value(s.skipped),
|
||||||
bytesTransferred: Value(s.bytesTransferred),
|
bytesTransferred: Value(s.bytesTransferred),
|
||||||
|
durationMs: Value(s.duration?.inMilliseconds),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -90,6 +91,9 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
|||||||
fetched: m.fetched,
|
fetched: m.fetched,
|
||||||
skipped: m.skipped,
|
skipped: m.skipped,
|
||||||
bytesTransferred: m.bytesTransferred,
|
bytesTransferred: m.bytesTransferred,
|
||||||
|
duration: m.durationMs != null
|
||||||
|
? Duration(milliseconds: m.durationMs!)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
|||||||
+54
-2
@@ -3,26 +3,33 @@ import 'dart:async';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:http/http.dart' as http;
|
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/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.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';
|
||||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/undo_repository.dart';
|
import 'package:sharedinbox/core/repositories/undo_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';
|
||||||
|
import 'package:sharedinbox/core/services/notification_service.dart';
|
||||||
import 'package:sharedinbox/core/services/undo_service.dart';
|
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;
|
||||||
|
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';
|
||||||
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/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/storage/flutter_secure_storage_impl.dart';
|
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
||||||
@@ -56,6 +63,10 @@ final accountRepositoryProvider = Provider<AccountRepository>((ref) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final shareKeyRepositoryProvider = Provider<ShareKeyRepository>((ref) {
|
||||||
|
return ShareKeyRepositoryImpl(ref.watch(dbProvider));
|
||||||
|
});
|
||||||
|
|
||||||
final mailboxRepositoryProvider = Provider<MailboxRepository>((ref) {
|
final mailboxRepositoryProvider = Provider<MailboxRepository>((ref) {
|
||||||
return MailboxRepositoryImpl(
|
return MailboxRepositoryImpl(
|
||||||
ref.watch(dbProvider),
|
ref.watch(dbProvider),
|
||||||
@@ -65,7 +76,11 @@ final mailboxRepositoryProvider = Provider<MailboxRepository>((ref) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final draftRepositoryProvider = Provider<DraftRepository>((ref) {
|
final draftRepositoryProvider = Provider<DraftRepository>((ref) {
|
||||||
return DraftRepositoryImpl(ref.watch(dbProvider));
|
return DraftRepositoryImpl(
|
||||||
|
ref.watch(dbProvider),
|
||||||
|
ref.watch(accountRepositoryProvider),
|
||||||
|
imapConnect: ref.watch(imapConnectProvider),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final emailRepositoryProvider = Provider<EmailRepository>((ref) {
|
final emailRepositoryProvider = Provider<EmailRepository>((ref) {
|
||||||
@@ -81,6 +96,11 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
|
|||||||
return UndoRepositoryImpl(ref.watch(dbProvider));
|
return UndoRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final searchHistoryRepositoryProvider =
|
||||||
|
Provider<SearchHistoryRepository>((ref) {
|
||||||
|
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
||||||
|
});
|
||||||
|
|
||||||
final syncLogRepositoryProvider = Provider((ref) {
|
final syncLogRepositoryProvider = Provider((ref) {
|
||||||
return SyncLogRepositoryImpl(ref.watch(dbProvider));
|
return SyncLogRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
@@ -110,6 +130,11 @@ final syncHealthProvider =
|
|||||||
.watchSingleOrNull();
|
.watchSingleOrNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final isSyncingProvider =
|
||||||
|
StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
|
||||||
|
return ref.watch(syncManagerProvider).watchSyncing(accountId);
|
||||||
|
});
|
||||||
|
|
||||||
final syncManagerProvider = Provider<AccountSyncManager>((ref) {
|
final syncManagerProvider = Provider<AccountSyncManager>((ref) {
|
||||||
final manager = AccountSyncManager(
|
final manager = AccountSyncManager(
|
||||||
ref.watch(accountRepositoryProvider),
|
ref.watch(accountRepositoryProvider),
|
||||||
@@ -117,6 +142,8 @@ final syncManagerProvider = Provider<AccountSyncManager>((ref) {
|
|||||||
ref.watch(emailRepositoryProvider),
|
ref.watch(emailRepositoryProvider),
|
||||||
syncLog: ref.watch(syncLogRepositoryProvider),
|
syncLog: ref.watch(syncLogRepositoryProvider),
|
||||||
imapConnect: ref.watch(imapConnectProvider),
|
imapConnect: ref.watch(imapConnectProvider),
|
||||||
|
drafts: ref.watch(draftRepositoryProvider),
|
||||||
|
onNewMail: showNewMailNotification,
|
||||||
);
|
);
|
||||||
ref.onDispose(manager.dispose);
|
ref.onDispose(manager.dispose);
|
||||||
return manager;
|
return manager;
|
||||||
@@ -135,6 +162,10 @@ final sieveRepositoryProvider = Provider<SieveRepository>((ref) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final localSieveRepositoryProvider = Provider<LocalSieveRepository>((ref) {
|
||||||
|
return LocalSieveRepository(ref.watch(dbProvider));
|
||||||
|
});
|
||||||
|
|
||||||
final connectionTestServiceProvider = Provider<ConnectionTestService>((ref) {
|
final connectionTestServiceProvider = Provider<ConnectionTestService>((ref) {
|
||||||
return ConnectionTestServiceImpl(
|
return ConnectionTestServiceImpl(
|
||||||
ref.watch(httpClientProvider),
|
ref.watch(httpClientProvider),
|
||||||
@@ -156,6 +187,27 @@ final undoServiceProvider =
|
|||||||
return service;
|
return service;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Loads email header + body and marks the email as seen.
|
||||||
|
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
|
||||||
|
final emailDetailProvider = AsyncNotifierProvider.autoDispose
|
||||||
|
.family<EmailDetailNotifier, (Email?, EmailBody), String>(
|
||||||
|
EmailDetailNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
class EmailDetailNotifier
|
||||||
|
extends AutoDisposeFamilyAsyncNotifier<(Email?, EmailBody), String> {
|
||||||
|
@override
|
||||||
|
Future<(Email?, EmailBody)> build(String emailId) async {
|
||||||
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
final results = await Future.wait([
|
||||||
|
repo.getEmail(emailId),
|
||||||
|
repo.getEmailBody(emailId),
|
||||||
|
]);
|
||||||
|
unawaited(repo.setFlag(emailId, seen: true));
|
||||||
|
return (results[0] as Email?, results[1] as EmailBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final accountByIdProvider =
|
final accountByIdProvider =
|
||||||
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
|
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
|
||||||
return ref.watch(accountRepositoryProvider).observeAccounts().map(
|
return ref.watch(accountRepositoryProvider).observeAccounts().map(
|
||||||
|
|||||||
+8
-1
@@ -1,8 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/services/notification_service.dart';
|
||||||
|
import 'package:sharedinbox/core/sync/background_sync.dart';
|
||||||
import 'package:sharedinbox/data/db/database.dart';
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/router.dart';
|
import 'package:sharedinbox/ui/router.dart';
|
||||||
@@ -32,6 +35,10 @@ void main({List<Override> overrides = const []}) async {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await initDatabasePath();
|
await initDatabasePath();
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
await initNotifications();
|
||||||
|
await registerBackgroundSync();
|
||||||
|
}
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
|
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
|
||||||
);
|
);
|
||||||
@@ -63,7 +70,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'SharedInbox',
|
title: 'sharedinbox.de',
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import 'package:sharedinbox/core/models/sieve_script.dart';
|
import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/ui/screens/about_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/account_send_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
|
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
|
import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
||||||
@@ -33,6 +36,14 @@ final router = GoRouter(
|
|||||||
path: 'add',
|
path: 'add',
|
||||||
builder: (ctx, state) => const AddAccountScreen(),
|
builder: (ctx, state) => const AddAccountScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'receive',
|
||||||
|
builder: (ctx, state) => const AccountReceiveScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'send',
|
||||||
|
builder: (ctx, state) => const AccountSendScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'undo-log',
|
path: 'undo-log',
|
||||||
builder: (ctx, state) => const UndoLogScreen(),
|
builder: (ctx, state) => const UndoLogScreen(),
|
||||||
@@ -41,6 +52,10 @@ final router = GoRouter(
|
|||||||
path: 'changelog',
|
path: 'changelog',
|
||||||
builder: (ctx, state) => const ChangeLogScreen(),
|
builder: (ctx, state) => const ChangeLogScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'about',
|
||||||
|
builder: (ctx, state) => const AboutScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':accountId/edit',
|
path: ':accountId/edit',
|
||||||
builder: (ctx, state) => EditAccountScreen(
|
builder: (ctx, state) => EditAccountScreen(
|
||||||
@@ -65,6 +80,21 @@ final router = GoRouter(
|
|||||||
script: state.extra as SieveScript?,
|
script: state.extra as SieveScript?,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: ':accountId/sieve/local',
|
||||||
|
builder: (ctx, state) => SieveScriptsScreen(
|
||||||
|
accountId: state.pathParameters['accountId']!,
|
||||||
|
isLocal: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: ':accountId/sieve/local/edit',
|
||||||
|
builder: (ctx, state) => SieveScriptEditScreen(
|
||||||
|
accountId: state.pathParameters['accountId']!,
|
||||||
|
script: state.extra as SieveScript?,
|
||||||
|
isLocal: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':accountId/search',
|
path: ':accountId/search',
|
||||||
builder: (ctx, state) =>
|
builder: (ctx, state) =>
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
class AboutScreen extends ConsumerStatefulWidget {
|
||||||
|
const AboutScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AboutScreen> createState() => _AboutScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||||
|
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
||||||
|
late final Stream<List<Account>> _accountsStream;
|
||||||
|
|
||||||
|
static const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildMarkdown(
|
||||||
|
BuildContext context,
|
||||||
|
PackageInfo? pkg,
|
||||||
|
int imapCount,
|
||||||
|
int jmapCount,
|
||||||
|
) {
|
||||||
|
final size = MediaQuery.of(context).size;
|
||||||
|
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||||
|
final physW = (size.width * pixelRatio).toInt();
|
||||||
|
final physH = (size.height * pixelRatio).toInt();
|
||||||
|
final version =
|
||||||
|
pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
|
||||||
|
final versionDisplay = _gitHash.isNotEmpty
|
||||||
|
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
|
||||||
|
: version;
|
||||||
|
final osName = _capitalize(Platform.operatingSystem);
|
||||||
|
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||||
|
|
||||||
|
return '## sharedinbox.de\n\n'
|
||||||
|
'| Property | Value |\n'
|
||||||
|
'|----------|-------|\n'
|
||||||
|
'| App Version | $versionDisplay |\n'
|
||||||
|
'| Platform | ${Platform.operatingSystem} |\n'
|
||||||
|
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
||||||
|
'| Resolution | ${physW}x$physH px'
|
||||||
|
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
||||||
|
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
||||||
|
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
||||||
|
'| Processors | ${Platform.numberOfProcessors} |\n'
|
||||||
|
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
||||||
|
'| IMAP Accounts | $imapCount |\n'
|
||||||
|
'| JMAP Accounts | $jmapCount |\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _capitalize(String s) =>
|
||||||
|
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
|
||||||
|
|
||||||
|
Future<void> _copyToClipboard(
|
||||||
|
BuildContext context,
|
||||||
|
int imapCount,
|
||||||
|
int jmapCount,
|
||||||
|
) async {
|
||||||
|
PackageInfo? pkg;
|
||||||
|
try {
|
||||||
|
pkg = await _packageInfoFuture;
|
||||||
|
} catch (_) {}
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await Clipboard.setData(
|
||||||
|
ClipboardData(
|
||||||
|
text: _buildMarkdown(context, pkg, imapCount, jmapCount),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text('Copied to clipboard'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createIssue(
|
||||||
|
BuildContext context,
|
||||||
|
int imapCount,
|
||||||
|
int jmapCount,
|
||||||
|
) async {
|
||||||
|
PackageInfo? pkg;
|
||||||
|
try {
|
||||||
|
pkg = await _packageInfoFuture;
|
||||||
|
} catch (_) {}
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final body = Uri.encodeComponent(
|
||||||
|
_buildMarkdown(context, pkg, imapCount, jmapCount),
|
||||||
|
);
|
||||||
|
final url = Uri.parse(
|
||||||
|
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final launched =
|
||||||
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||||
|
if (!launched && context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text('Could not open browser.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
content: Text('Error: $e'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return StreamBuilder<List<Account>>(
|
||||||
|
stream: _accountsStream,
|
||||||
|
builder: (context, accountSnapshot) {
|
||||||
|
final accounts = accountSnapshot.data ?? [];
|
||||||
|
final imapCount =
|
||||||
|
accounts.where((a) => a.type == AccountType.imap).length;
|
||||||
|
final jmapCount =
|
||||||
|
accounts.where((a) => a.type == AccountType.jmap).length;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('About')),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FutureBuilder<PackageInfo>(
|
||||||
|
future: _packageInfoFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
return Markdown(
|
||||||
|
data: _buildMarkdown(
|
||||||
|
context,
|
||||||
|
snapshot.data,
|
||||||
|
imapCount,
|
||||||
|
jmapCount,
|
||||||
|
),
|
||||||
|
selectable: true,
|
||||||
|
onTapLink: (text, href, title) {
|
||||||
|
if (href != null) {
|
||||||
|
unawaited(
|
||||||
|
launchUrl(
|
||||||
|
Uri.parse(href),
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.copy),
|
||||||
|
label: const Text('Copy to clipboard'),
|
||||||
|
onPressed: () => unawaited(
|
||||||
|
_copyToClipboard(context, imapCount, jmapCount),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
icon: const Icon(Icons.bug_report),
|
||||||
|
label: const Text('Create issue'),
|
||||||
|
onPressed: () => unawaited(
|
||||||
|
_createIssue(context, imapCount, jmapCount),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/services/update_service.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class AccountListScreen extends ConsumerWidget {
|
class AccountListScreen extends ConsumerWidget {
|
||||||
const AccountListScreen({super.key});
|
const AccountListScreen({super.key});
|
||||||
@@ -14,7 +15,7 @@ class AccountListScreen extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('SharedInbox'),
|
title: const Text('sharedinbox.de'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
@@ -29,10 +30,18 @@ class AccountListScreen extends ConsumerWidget {
|
|||||||
const DrawerHeader(
|
const DrawerHeader(
|
||||||
decoration: BoxDecoration(color: Colors.blueGrey),
|
decoration: BoxDecoration(color: Colors.blueGrey),
|
||||||
child: Text(
|
child: Text(
|
||||||
'SharedInbox',
|
'sharedinbox.de',
|
||||||
style: TextStyle(color: Colors.white, fontSize: 24),
|
style: TextStyle(color: Colors.white, fontSize: 24),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.qr_code_scanner),
|
||||||
|
title: const Text('Receive accounts'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
unawaited(context.push('/accounts/receive'));
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.history),
|
leading: const Icon(Icons.history),
|
||||||
title: const Text('Undo Log'),
|
title: const Text('Undo Log'),
|
||||||
@@ -49,37 +58,39 @@ class AccountListScreen extends ConsumerWidget {
|
|||||||
unawaited(context.push('/accounts/changelog'));
|
unawaited(context.push('/accounts/changelog'));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.info_outline),
|
||||||
|
title: const Text('About'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context); // Close drawer
|
||||||
|
unawaited(context.push('/accounts/about'));
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: StreamBuilder(
|
body: Column(
|
||||||
stream: ref.watch(accountRepositoryProvider).observeAccounts(),
|
children: [
|
||||||
builder: (ctx, snap) {
|
const _UpdateBanner(),
|
||||||
if (!snap.hasData) {
|
Expanded(
|
||||||
return const Center(child: CircularProgressIndicator());
|
child: StreamBuilder(
|
||||||
}
|
stream: ref.watch(accountRepositoryProvider).observeAccounts(),
|
||||||
final accounts = snap.data!;
|
builder: (ctx, snap) {
|
||||||
if (accounts.isEmpty) {
|
if (!snap.hasData) {
|
||||||
return Center(
|
return const Center(child: CircularProgressIndicator());
|
||||||
child: Column(
|
}
|
||||||
mainAxisSize: MainAxisSize.min,
|
final accounts = snap.data!;
|
||||||
children: [
|
if (accounts.isEmpty) {
|
||||||
const Text('No accounts yet.'),
|
return const _OnboardingView();
|
||||||
const SizedBox(height: 12),
|
}
|
||||||
FilledButton.icon(
|
return ListView.builder(
|
||||||
onPressed: () => context.push('/accounts/add'),
|
itemCount: accounts.length,
|
||||||
icon: const Icon(Icons.add),
|
itemBuilder: (ctx, i) => _AccountTile(account: accounts[i]),
|
||||||
label: const Text('Add account'),
|
);
|
||||||
),
|
},
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
}
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: accounts.length,
|
|
||||||
itemBuilder: (ctx, i) => _AccountTile(account: accounts[i]),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () => context.push('/accounts/add'),
|
onPressed: () => context.push('/accounts/add'),
|
||||||
@@ -159,15 +170,27 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
value: _AccountAction.verifySync,
|
value: _AccountAction.verifySync,
|
||||||
child: Text('Verify sync health'),
|
child: Text('Verify sync health'),
|
||||||
),
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.forceSync,
|
||||||
|
child: Text('Force full sync'),
|
||||||
|
),
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: _AccountAction.edit,
|
value: _AccountAction.edit,
|
||||||
child: Text('Edit'),
|
child: Text('Edit'),
|
||||||
),
|
),
|
||||||
if (_sieveSupported(account))
|
if (_sieveSupported(account))
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: _AccountAction.emailFilters,
|
value: _AccountAction.emailFiltersRemote,
|
||||||
child: Text('Email filters'),
|
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 PopupMenuDivider(),
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: _AccountAction.delete,
|
value: _AccountAction.delete,
|
||||||
@@ -194,16 +217,53 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Starting sync verification...')),
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text('Starting sync verification...'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case _AccountAction.forceSync:
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Force full sync?'),
|
||||||
|
content: const Text(
|
||||||
|
'This clears all locally-cached emails and mailboxes for this '
|
||||||
|
'account and immediately re-downloads everything from the server. '
|
||||||
|
'Previously viewed email content will not need to be re-downloaded.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
child: const Text('Force sync'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed == true && context.mounted) {
|
||||||
|
await ProviderScope.containerOf(
|
||||||
|
context,
|
||||||
|
).read(syncManagerProvider).forceResync(account.id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case _AccountAction.edit:
|
case _AccountAction.edit:
|
||||||
await context.push('/accounts/${account.id}/edit');
|
await context.push('/accounts/${account.id}/edit');
|
||||||
break;
|
break;
|
||||||
case _AccountAction.emailFilters:
|
case _AccountAction.emailFiltersRemote:
|
||||||
await context.push('/accounts/${account.id}/sieve');
|
await context.push('/accounts/${account.id}/sieve');
|
||||||
break;
|
break;
|
||||||
|
case _AccountAction.emailFiltersLocal:
|
||||||
|
await context.push('/accounts/${account.id}/sieve/local');
|
||||||
|
break;
|
||||||
|
case _AccountAction.send:
|
||||||
|
await context.push('/accounts/send');
|
||||||
|
break;
|
||||||
case _AccountAction.delete:
|
case _AccountAction.delete:
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -233,7 +293,122 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _AccountAction { syncLog, verifySync, edit, emailFilters, delete }
|
class _OnboardingView extends StatelessWidget {
|
||||||
|
const _OnboardingView();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.mail_outline,
|
||||||
|
size: 64,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Welcome to sharedinbox.de',
|
||||||
|
style: theme.textTheme.headlineSmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Get started in three steps:',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const _Step(
|
||||||
|
number: '1',
|
||||||
|
title: 'Add an account',
|
||||||
|
description: 'Connect your IMAP or JMAP email account.',
|
||||||
|
),
|
||||||
|
const _Step(
|
||||||
|
number: '2',
|
||||||
|
title: 'Wait for sync',
|
||||||
|
description:
|
||||||
|
'sharedinbox.de downloads your messages in the background.',
|
||||||
|
),
|
||||||
|
const _Step(
|
||||||
|
number: '3',
|
||||||
|
title: 'Open your inbox',
|
||||||
|
description:
|
||||||
|
'Tap the account to browse mailboxes and read emails.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => context.push('/accounts/add'),
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Add account'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Step extends StatelessWidget {
|
||||||
|
const _Step({
|
||||||
|
required this.number,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String number;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor: theme.colorScheme.primaryContainer,
|
||||||
|
child: Text(
|
||||||
|
number,
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: theme.textTheme.titleSmall),
|
||||||
|
Text(description, style: theme.textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _AccountAction {
|
||||||
|
syncLog,
|
||||||
|
verifySync,
|
||||||
|
forceSync,
|
||||||
|
edit,
|
||||||
|
emailFiltersRemote,
|
||||||
|
emailFiltersLocal,
|
||||||
|
send,
|
||||||
|
delete,
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether to surface the "Email filters" (Sieve) entry for [account].
|
/// Whether to surface the "Email filters" (Sieve) entry for [account].
|
||||||
///
|
///
|
||||||
@@ -245,3 +420,31 @@ bool _sieveSupported(Account account) {
|
|||||||
if (account.type == AccountType.jmap) return true;
|
if (account.type == AccountType.jmap) return true;
|
||||||
return account.manageSieveAvailable != false;
|
return account.manageSieveAvailable != false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shown on Linux desktop when a newer build is available on the server.
|
||||||
|
class _UpdateBanner extends ConsumerWidget {
|
||||||
|
const _UpdateBanner();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final update = ref.watch(updateInfoProvider);
|
||||||
|
return update.when(
|
||||||
|
data: (info) {
|
||||||
|
if (info == null) return const SizedBox.shrink();
|
||||||
|
return MaterialBanner(
|
||||||
|
content: Text('Update available: ${info.latestVersion}'),
|
||||||
|
leading: const Icon(Icons.system_update),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
unawaited(launchUrl(Uri.parse(info.downloadUrl))),
|
||||||
|
child: const Text('Download'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const SizedBox.shrink(),
|
||||||
|
error: (_, __) => const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
|
||||||
|
/// Receiving side of the secure account-sharing flow.
|
||||||
|
///
|
||||||
|
/// Step 1 – generates an X25519 key pair with a 20-minute lifetime and shows
|
||||||
|
/// the public key as a QR code to be scanned by the sender.
|
||||||
|
///
|
||||||
|
/// Step 2 – scans the encrypted-accounts QR code shown by the sender, decrypts
|
||||||
|
/// it using the private key, and imports the accounts.
|
||||||
|
class AccountReceiveScreen extends ConsumerStatefulWidget {
|
||||||
|
const AccountReceiveScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AccountReceiveScreen> createState() =>
|
||||||
|
_AccountReceiveScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
|
||||||
|
|
||||||
|
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||||
|
_Step _step = _Step.generatingKey;
|
||||||
|
ShareKeyMaterial? _keyMaterial;
|
||||||
|
String? _pubKeyQr;
|
||||||
|
String? _errorMessage;
|
||||||
|
bool _scannerActive = false;
|
||||||
|
|
||||||
|
MobileScannerController? _scannerController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
unawaited(_generateKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
final ctrl = _scannerController;
|
||||||
|
if (ctrl != null) unawaited(ctrl.dispose());
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _generateKey() async {
|
||||||
|
try {
|
||||||
|
final repo = ref.read(shareKeyRepositoryProvider);
|
||||||
|
final material = await repo.createKeyPair();
|
||||||
|
final qr = ShareEncryptionService.encodePublicKeyQr(
|
||||||
|
material.keyId,
|
||||||
|
material.publicKeyBytes,
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_keyMaterial = material;
|
||||||
|
_pubKeyQr = qr;
|
||||||
|
_step = _Step.showingPubKey;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = e.toString();
|
||||||
|
_step = _Step.error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startScanning() {
|
||||||
|
setState(() {
|
||||||
|
_step = _Step.scanning;
|
||||||
|
_scannerActive = true;
|
||||||
|
_scannerController = MobileScannerController();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onScanned(String rawValue) async {
|
||||||
|
if (!_scannerActive) return;
|
||||||
|
_scannerActive = false;
|
||||||
|
await _scannerController?.stop();
|
||||||
|
|
||||||
|
setState(() => _step = _Step.importing);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final material = _keyMaterial!;
|
||||||
|
final accounts = await ShareEncryptionService.decryptAccounts(
|
||||||
|
qrString: rawValue,
|
||||||
|
privateKeyBytes: material.privateKeyBytes,
|
||||||
|
publicKeyBytes: material.publicKeyBytes,
|
||||||
|
keyId: material.keyId,
|
||||||
|
);
|
||||||
|
|
||||||
|
final repo = ref.read(accountRepositoryProvider);
|
||||||
|
for (final ap in accounts) {
|
||||||
|
final account = Account.fromJson(ap.accountJson);
|
||||||
|
final newAccount = Account(
|
||||||
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
displayName: account.displayName,
|
||||||
|
email: account.email,
|
||||||
|
username: account.username,
|
||||||
|
type: account.type,
|
||||||
|
imapHost: account.imapHost,
|
||||||
|
imapPort: account.imapPort,
|
||||||
|
imapSsl: account.imapSsl,
|
||||||
|
smtpHost: account.smtpHost,
|
||||||
|
smtpPort: account.smtpPort,
|
||||||
|
smtpSsl: account.smtpSsl,
|
||||||
|
manageSieveHost: account.manageSieveHost,
|
||||||
|
manageSievePort: account.manageSievePort,
|
||||||
|
manageSieveSsl: account.manageSieveSsl,
|
||||||
|
jmapUrl: account.jmapUrl,
|
||||||
|
);
|
||||||
|
await repo.addAccount(newAccount, ap.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _step = _Step.done);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Imported ${accounts.length} account${accounts.length == 1 ? '' : 's'} successfully.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = _friendlyError(e);
|
||||||
|
_scannerActive = false;
|
||||||
|
// Let user retry from the pubkey step.
|
||||||
|
_step = _Step.showingPubKey;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(_friendlyError(e)),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _friendlyError(Object e) {
|
||||||
|
final s = e.toString();
|
||||||
|
if (s.contains('expired') || s.contains('older than')) {
|
||||||
|
return 'The QR code has expired. Ask the sender to generate a new one.';
|
||||||
|
}
|
||||||
|
if (s.contains('Key ID mismatch') || s.contains('Unknown')) {
|
||||||
|
return 'QR code does not match this session. Regenerate the public key and try again.';
|
||||||
|
}
|
||||||
|
if (s.contains('authentication') ||
|
||||||
|
s.contains('mac') ||
|
||||||
|
s.contains('SecretBox')) {
|
||||||
|
return 'Authentication failed — the QR code may have been tampered with.';
|
||||||
|
}
|
||||||
|
return 'Import failed: $s';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Receive accounts')),
|
||||||
|
body: switch (_step) {
|
||||||
|
_Step.generatingKey => const Center(child: CircularProgressIndicator()),
|
||||||
|
_Step.showingPubKey => _buildPubKeyView(context),
|
||||||
|
_Step.scanning => _buildScannerView(context),
|
||||||
|
_Step.importing => const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('Importing accounts…'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_Step.done => const Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_Step.error => Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text('Error: $_errorMessage'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPubKeyView(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Step 1 of 2 — Show this QR code to the sender',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'The sender scans this code, selects the account(s) to transfer, '
|
||||||
|
'and shows an encrypted QR code. Then come back here for step 2.',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
color: Colors.white,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: QrImageView(
|
||||||
|
key: const Key('pubKeyQrCode'),
|
||||||
|
data: _pubKeyQr!,
|
||||||
|
size: 260,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.copy),
|
||||||
|
label: const Text('Copy public key'),
|
||||||
|
onPressed: () {
|
||||||
|
unawaited(Clipboard.setData(ClipboardData(text: _pubKeyQr!)));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Public key copied to clipboard')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const _ExpiryHint(),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
if (_errorMessage != null) ...[
|
||||||
|
Text(
|
||||||
|
_errorMessage!,
|
||||||
|
style: TextStyle(color: theme.colorScheme.error),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const Key('scanEncryptedButton'),
|
||||||
|
icon: const Icon(Icons.qr_code_scanner),
|
||||||
|
label: const Text('Step 2 — Scan encrypted QR code'),
|
||||||
|
onPressed: _startScanning,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildScannerView(BuildContext context) {
|
||||||
|
// On platforms where the camera scanner is not available (Linux desktop),
|
||||||
|
// fall back to a text-input field.
|
||||||
|
if (!_cameraScanSupported()) {
|
||||||
|
return _buildTextFallbackView(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
MobileScanner(
|
||||||
|
controller: _scannerController!,
|
||||||
|
onDetect: (capture) {
|
||||||
|
final raw = capture.barcodes.firstOrNull?.rawValue;
|
||||||
|
if (raw != null) unawaited(_onScanned(raw));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black54,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||||
|
child: const Text(
|
||||||
|
'Point the camera at the encrypted QR code from the sender\'s device',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 32,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: OutlinedButton(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
final ctrl = _scannerController;
|
||||||
|
if (ctrl != null) unawaited(ctrl.dispose());
|
||||||
|
_scannerController = null;
|
||||||
|
setState(() {
|
||||||
|
_scannerActive = false;
|
||||||
|
_step = _Step.showingPubKey;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextFallbackView(BuildContext context) {
|
||||||
|
final ctrl = TextEditingController();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Paste the encrypted code from the sender\'s device',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
key: const Key('encryptedCodeField'),
|
||||||
|
controller: ctrl,
|
||||||
|
maxLines: 6,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Encrypted code',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
hintText: 'sharedinbox.de:encrypted-accounts:v1:…',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final text = ctrl.text.trim();
|
||||||
|
if (text.isNotEmpty) unawaited(_onScanned(text));
|
||||||
|
},
|
||||||
|
child: const Text('Import'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => setState(() {
|
||||||
|
_scannerActive = false;
|
||||||
|
_step = _Step.showingPubKey;
|
||||||
|
}),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _cameraScanSupported() =>
|
||||||
|
Platform.isAndroid ||
|
||||||
|
Platform.isIOS ||
|
||||||
|
Platform.isMacOS ||
|
||||||
|
Platform.isWindows;
|
||||||
|
|
||||||
|
class _ExpiryHint extends StatelessWidget {
|
||||||
|
const _ExpiryHint();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'This key expires in 20 minutes',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
|
||||||
|
/// Sending side of the secure account-sharing flow.
|
||||||
|
///
|
||||||
|
/// Step 1 – scans (or pastes) the receiver's public-key QR code.
|
||||||
|
///
|
||||||
|
/// Step 2 – if more than one account exists, the user selects which accounts
|
||||||
|
/// to transfer (auto-selected when only one account is present).
|
||||||
|
///
|
||||||
|
/// Step 3 – shows the encrypted-accounts QR code for the receiver to scan.
|
||||||
|
class AccountSendScreen extends ConsumerStatefulWidget {
|
||||||
|
const AccountSendScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AccountSendScreen> createState() => _AccountSendScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _Step { scanning, selectAccounts, showEncrypted, error }
|
||||||
|
|
||||||
|
class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
||||||
|
_Step _step = _Step.scanning;
|
||||||
|
|
||||||
|
// Set after scanning the pubkey QR.
|
||||||
|
Uint8List? _recipientKeyId;
|
||||||
|
Uint8List? _recipientPublicKey;
|
||||||
|
|
||||||
|
// All available accounts + the selection (for step 2).
|
||||||
|
List<Account> _accounts = [];
|
||||||
|
final Set<String> _selectedIds = {};
|
||||||
|
|
||||||
|
// Set after encryption (step 3).
|
||||||
|
String? _encryptedQr;
|
||||||
|
String? _errorMessage;
|
||||||
|
bool _scannerActive = true;
|
||||||
|
|
||||||
|
MobileScannerController? _scannerController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (_cameraScanSupported()) {
|
||||||
|
_scannerController = MobileScannerController();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
final ctrl = _scannerController;
|
||||||
|
if (ctrl != null) unawaited(ctrl.dispose());
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 1: scan pubkey QR ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _onPubKeyScanned(String rawValue) async {
|
||||||
|
if (!_scannerActive) return;
|
||||||
|
_scannerActive = false;
|
||||||
|
await _scannerController?.stop();
|
||||||
|
|
||||||
|
final parsed = ShareEncryptionService.parsePublicKeyQr(rawValue);
|
||||||
|
if (parsed == null) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Not a valid sharedinbox.de public-key QR code. '
|
||||||
|
'Ask the receiver to show step 1 of "Receive accounts".',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Allow retry.
|
||||||
|
setState(() => _scannerActive = true);
|
||||||
|
await _scannerController?.start();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all available accounts.
|
||||||
|
final accounts =
|
||||||
|
await ref.read(accountRepositoryProvider).observeAccounts().first;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (accounts.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'No accounts to send.';
|
||||||
|
_step = _Step.error;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_recipientKeyId = parsed.keyId;
|
||||||
|
_recipientPublicKey = parsed.publicKeyBytes;
|
||||||
|
_accounts = accounts;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (accounts.length == 1) {
|
||||||
|
// Auto-select the only account; skip the selection step.
|
||||||
|
_selectedIds.add(accounts.first.id);
|
||||||
|
await _encryptAndShow();
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_selectedIds.addAll(accounts.map((a) => a.id));
|
||||||
|
_step = _Step.selectAccounts;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: account selection ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _encryptAndShow() async {
|
||||||
|
final repo = ref.read(accountRepositoryProvider);
|
||||||
|
final selected = _accounts.where((a) => _selectedIds.contains(a.id));
|
||||||
|
|
||||||
|
final payloads = <AccountPayload>[];
|
||||||
|
for (final account in selected) {
|
||||||
|
final password = await repo.getPassword(account.id);
|
||||||
|
payloads.add(
|
||||||
|
AccountPayload(
|
||||||
|
accountJson: account.toJson(),
|
||||||
|
password: password,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final qr = await ShareEncryptionService.encryptAccounts(
|
||||||
|
recipientKeyId: _recipientKeyId!,
|
||||||
|
recipientPublicKeyBytes: _recipientPublicKey!,
|
||||||
|
accounts: payloads,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_encryptedQr = qr;
|
||||||
|
_step = _Step.showEncrypted;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = e.toString();
|
||||||
|
_step = _Step.error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Send accounts')),
|
||||||
|
body: switch (_step) {
|
||||||
|
_Step.scanning => _buildScanStep(context),
|
||||||
|
_Step.selectAccounts => _buildSelectStep(context),
|
||||||
|
_Step.showEncrypted => _buildEncryptedQrStep(context),
|
||||||
|
_Step.error => Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text('Error: $_errorMessage'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildScanStep(BuildContext context) {
|
||||||
|
if (!_cameraScanSupported()) {
|
||||||
|
return _buildTextFallbackView(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
MobileScanner(
|
||||||
|
controller: _scannerController!,
|
||||||
|
onDetect: (capture) {
|
||||||
|
final raw = capture.barcodes.firstOrNull?.rawValue;
|
||||||
|
if (raw != null) unawaited(_onPubKeyScanned(raw));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black54,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||||
|
child: const Text(
|
||||||
|
'Point the camera at the public-key QR code shown by the receiver',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextFallbackView(BuildContext context) {
|
||||||
|
final ctrl = TextEditingController();
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Paste the public key shown by the receiver\'s "Receive accounts" screen.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
key: const Key('pubKeyInputField'),
|
||||||
|
controller: ctrl,
|
||||||
|
maxLines: 4,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Public key',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
hintText: 'sharedinbox.de:pubkey:v1:…',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final text = ctrl.text.trim();
|
||||||
|
if (text.isNotEmpty) unawaited(_onPubKeyScanned(text));
|
||||||
|
},
|
||||||
|
child: const Text('Continue'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectStep(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'Select accounts to send',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
children: _accounts.map((account) {
|
||||||
|
final selected = _selectedIds.contains(account.id);
|
||||||
|
return CheckboxListTile(
|
||||||
|
value: selected,
|
||||||
|
title: Text(account.displayName),
|
||||||
|
subtitle: Text(account.email),
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() {
|
||||||
|
if (v == true) {
|
||||||
|
_selectedIds.add(account.id);
|
||||||
|
} else {
|
||||||
|
_selectedIds.remove(account.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: FilledButton(
|
||||||
|
key: const Key('sendSelectedButton'),
|
||||||
|
onPressed: _selectedIds.isEmpty
|
||||||
|
? null
|
||||||
|
: () => unawaited(_encryptAndShow()),
|
||||||
|
child: const Text('Encrypt & show QR'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEncryptedQrStep(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Step 3 — Show this QR code to the receiver',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'The receiver taps "Step 2 — Scan encrypted QR code" and scans this.',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
color: Colors.white,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: QrImageView(
|
||||||
|
key: const Key('encryptedAccountsQrCode'),
|
||||||
|
data: _encryptedQr!,
|
||||||
|
size: 280,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
key: const Key('copyEncryptedButton'),
|
||||||
|
icon: const Icon(Icons.copy),
|
||||||
|
label: const Text('Copy encrypted code'),
|
||||||
|
onPressed: () {
|
||||||
|
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Encrypted code copied to clipboard',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'This code contains encrypted account data. It is safe to display '
|
||||||
|
'briefly — only the receiver\'s device can decrypt it.',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _cameraScanSupported() =>
|
||||||
|
Platform.isAndroid ||
|
||||||
|
Platform.isIOS ||
|
||||||
|
Platform.isMacOS ||
|
||||||
|
Platform.isWindows;
|
||||||
@@ -295,6 +295,13 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
|||||||
onPressed: _detectAccount,
|
onPressed: _detectAccount,
|
||||||
child: const Text('Continue'),
|
child: const Text('Continue'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
key: const Key('importAccountButton'),
|
||||||
|
icon: const Icon(Icons.qr_code_scanner),
|
||||||
|
label: const Text('Receive account'),
|
||||||
|
onPressed: () => context.push('/accounts/receive'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -408,7 +415,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
|||||||
_field(_passwordCtrl, 'Password', obscure: true),
|
_field(_passwordCtrl, 'Password', obscure: true),
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
Text('IMAP', style: Theme.of(context).textTheme.titleSmall),
|
Text('IMAP', style: Theme.of(context).textTheme.titleSmall),
|
||||||
_field(_imapHostCtrl, 'Host'),
|
_field(_imapHostCtrl, 'Host', validator: validateHostname),
|
||||||
_field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number),
|
_field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number),
|
||||||
if (isLocalhost(_imapHostCtrl.text.trim()))
|
if (isLocalhost(_imapHostCtrl.text.trim()))
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
@@ -418,7 +425,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
|||||||
),
|
),
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
|
Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
|
||||||
_field(_smtpHostCtrl, 'Host'),
|
_field(_smtpHostCtrl, 'Host', validator: validateHostname),
|
||||||
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
|
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
|
||||||
if (isLocalhost(_smtpHostCtrl.text.trim()))
|
if (isLocalhost(_smtpHostCtrl.text.trim()))
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
@@ -475,6 +482,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
|||||||
bool obscure = false,
|
bool obscure = false,
|
||||||
bool required = true,
|
bool required = true,
|
||||||
TextInputType? keyboardType,
|
TextInputType? keyboardType,
|
||||||
|
String? Function(String?)? validator,
|
||||||
}) {
|
}) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
@@ -486,9 +494,10 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
|||||||
labelText: label,
|
labelText: label,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
validator: required
|
validator: validator ??
|
||||||
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
|
(required
|
||||||
: null,
|
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
|
||||||
|
: null),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart' show rootBundle;
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class ChangeLogScreen extends StatelessWidget {
|
class ChangeLogScreen extends StatelessWidget {
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ class ComposeScreen extends ConsumerStatefulWidget {
|
|||||||
class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||||
final _to = TextEditingController();
|
final _to = TextEditingController();
|
||||||
final _cc = TextEditingController();
|
final _cc = TextEditingController();
|
||||||
|
final _toFocus = FocusNode();
|
||||||
|
final _ccFocus = FocusNode();
|
||||||
final _subject = TextEditingController();
|
final _subject = TextEditingController();
|
||||||
final _body = TextEditingController();
|
final _body = TextEditingController();
|
||||||
String? _accountId;
|
String? _accountId;
|
||||||
@@ -139,6 +141,8 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
c.removeListener(_onTextChanged);
|
c.removeListener(_onTextChanged);
|
||||||
c.dispose();
|
c.dispose();
|
||||||
}
|
}
|
||||||
|
_toFocus.dispose();
|
||||||
|
_ccFocus.dispose();
|
||||||
// Flush any pending save synchronously — we can't await in dispose, but
|
// Flush any pending save synchronously — we can't await in dispose, but
|
||||||
// scheduling a microtask still runs before the isolate exits.
|
// scheduling a microtask still runs before the isolate exits.
|
||||||
if (_draftDirty) {
|
if (_draftDirty) {
|
||||||
@@ -192,7 +196,12 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text('Failed to open file: $e')));
|
).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
content: Text('Failed to open file: $e'),
|
||||||
|
),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _opening = false);
|
if (mounted) setState(() => _opening = false);
|
||||||
}
|
}
|
||||||
@@ -206,7 +215,12 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
if (_accountId == null) {
|
if (_accountId == null) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(const SnackBar(content: Text('Select an account first')));
|
).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text('Select an account first'),
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _sending = true);
|
setState(() => _sending = true);
|
||||||
@@ -243,7 +257,12 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text('Send failed: $e')));
|
).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
content: Text('Send failed: $e'),
|
||||||
|
),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _sending = false);
|
if (mounted) setState(() => _sending = false);
|
||||||
}
|
}
|
||||||
@@ -315,8 +334,8 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_field(_to, 'To', keyboardType: TextInputType.emailAddress),
|
_addressField(_to, _toFocus, 'To'),
|
||||||
_field(_cc, 'Cc', keyboardType: TextInputType.emailAddress),
|
_addressField(_cc, _ccFocus, 'Cc'),
|
||||||
_field(_subject, 'Subject'),
|
_field(_subject, 'Subject'),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
@@ -369,6 +388,96 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _addressField(
|
||||||
|
TextEditingController ctrl,
|
||||||
|
FocusNode focusNode,
|
||||||
|
String label,
|
||||||
|
) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: RawAutocomplete<EmailAddress>(
|
||||||
|
textEditingController: ctrl,
|
||||||
|
focusNode: focusNode,
|
||||||
|
displayStringForOption: (option) {
|
||||||
|
final text = ctrl.text;
|
||||||
|
final lastComma = text.lastIndexOf(',');
|
||||||
|
final prefix =
|
||||||
|
lastComma >= 0 ? '${text.substring(0, lastComma + 1)} ' : '';
|
||||||
|
return '$prefix${option.email}, ';
|
||||||
|
},
|
||||||
|
optionsBuilder: (value) async {
|
||||||
|
final text = value.text;
|
||||||
|
final lastComma = text.lastIndexOf(',');
|
||||||
|
final token = lastComma >= 0
|
||||||
|
? text.substring(lastComma + 1).trim()
|
||||||
|
: text.trim();
|
||||||
|
if (token.length < 2) return const [];
|
||||||
|
final results = await ref
|
||||||
|
.read(emailRepositoryProvider)
|
||||||
|
.searchAddresses(null, token);
|
||||||
|
// Guard: if focus left the field while the query was running,
|
||||||
|
// return empty so RawAutocomplete doesn't call show() after hide()
|
||||||
|
// has already been called — that races into an assertion in overlay.dart.
|
||||||
|
if (!focusNode.hasFocus) return const [];
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
fieldViewBuilder: (ctx, fieldCtrl, fieldFocusNode, onFieldSubmitted) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: fieldCtrl,
|
||||||
|
focusNode: fieldFocusNode,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onFieldSubmitted: (_) => onFieldSubmitted(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
optionsViewBuilder: (ctx, onSelected, options) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: Material(
|
||||||
|
elevation: 4,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 200),
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: options.length,
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final option = options.elementAt(i);
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => onSelected(option),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: option.name != null
|
||||||
|
? Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(option.name!),
|
||||||
|
Text(
|
||||||
|
option.email,
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Text(option.email),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _field(
|
Widget _field(
|
||||||
TextEditingController ctrl,
|
TextEditingController ctrl,
|
||||||
String label, {
|
String label, {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
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:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class CrashScreen extends StatelessWidget {
|
class CrashScreen extends StatelessWidget {
|
||||||
@@ -12,6 +15,26 @@ class CrashScreen extends StatelessWidget {
|
|||||||
final Object exception;
|
final Object exception;
|
||||||
final StackTrace? stackTrace;
|
final StackTrace? stackTrace;
|
||||||
|
|
||||||
|
static const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||||
|
|
||||||
|
Future<String> _buildReport() async {
|
||||||
|
String version = 'unknown';
|
||||||
|
try {
|
||||||
|
final info = await PackageInfo.fromPlatform();
|
||||||
|
version = '${info.version}+${info.buildNumber}';
|
||||||
|
} catch (_) {}
|
||||||
|
final platform =
|
||||||
|
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||||
|
final gitLine = _gitHash.isNotEmpty
|
||||||
|
? 'Git Commit: [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)\n'
|
||||||
|
: '';
|
||||||
|
return 'App Version: $version\n'
|
||||||
|
'$gitLine'
|
||||||
|
'Platform: $platform\n\n'
|
||||||
|
'Error:\n```\n$exception\n```\n\n'
|
||||||
|
'Stack Trace:\n```\n$stackTrace\n```';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
@@ -20,39 +43,22 @@ class CrashScreen extends StatelessWidget {
|
|||||||
title: const Text('Something went wrong'),
|
title: const Text('Something went wrong'),
|
||||||
backgroundColor: Theme.of(context).colorScheme.errorContainer,
|
backgroundColor: Theme.of(context).colorScheme.errorContainer,
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: Builder(
|
||||||
padding: const EdgeInsets.all(16),
|
builder: (ctx) => SingleChildScrollView(
|
||||||
child: Column(
|
padding: const EdgeInsets.all(16),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
const Icon(Icons.error_outline, color: Colors.red, size: 64),
|
children: [
|
||||||
const SizedBox(height: 16),
|
const Icon(Icons.error_outline, color: Colors.red, size: 64),
|
||||||
Text(
|
|
||||||
'SharedInbox encountered an unexpected error and needs to be restarted.',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
const Text(
|
|
||||||
'Error Details:',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[200],
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
exception.toString(),
|
|
||||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (stackTrace != null) ...[
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'sharedinbox.de encountered an unexpected error and needs to be restarted.',
|
||||||
|
style: Theme.of(ctx).textTheme.titleMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
const Text(
|
const Text(
|
||||||
'Stack Trace:',
|
'Error Details:',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -63,64 +69,120 @@ class CrashScreen extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
stackTrace.toString(),
|
exception.toString(),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: 10,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
if (stackTrace != null) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 16),
|
||||||
FilledButton.icon(
|
const Text(
|
||||||
onPressed: () async {
|
'Stack Trace:',
|
||||||
final data = 'Error: $exception\n\nStack Trace:\n$stackTrace';
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
await Clipboard.setData(ClipboardData(text: data));
|
),
|
||||||
if (context.mounted) {
|
const SizedBox(height: 8),
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
Container(
|
||||||
const SnackBar(content: Text('Copied to clipboard')),
|
padding: const EdgeInsets.all(12),
|
||||||
);
|
decoration: BoxDecoration(
|
||||||
}
|
color: Colors.grey[200],
|
||||||
},
|
borderRadius: BorderRadius.circular(8),
|
||||||
icon: const Icon(Icons.copy),
|
),
|
||||||
label: const Text('Copy to Clipboard'),
|
child: Text(
|
||||||
),
|
stackTrace.toString(),
|
||||||
const SizedBox(height: 16),
|
style: const TextStyle(
|
||||||
OutlinedButton.icon(
|
fontFamily: 'monospace',
|
||||||
onPressed: () async {
|
fontSize: 10,
|
||||||
final title = Uri.encodeComponent(
|
),
|
||||||
'Crash: ${exception.toString().split('\n').first}',
|
),
|
||||||
);
|
),
|
||||||
final body = Uri.encodeComponent(
|
],
|
||||||
'Error: $exception\n\nStack Trace:\n$stackTrace',
|
if (_gitHash.isNotEmpty) ...[
|
||||||
);
|
const SizedBox(height: 16),
|
||||||
final url = Uri.parse(
|
const Text(
|
||||||
'https://codeberg.org/guettli/sharedinbox/issues/new?title=$title&body=$body',
|
'Git Commit:',
|
||||||
);
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
try {
|
),
|
||||||
final launched = await launchUrl(
|
const SizedBox(height: 4),
|
||||||
url,
|
GestureDetector(
|
||||||
mode: LaunchMode.externalApplication,
|
onTap: () async {
|
||||||
);
|
final url = Uri.parse(
|
||||||
if (!launched && context.mounted) {
|
'https://codeberg.org/guettli/sharedinbox/commit/$_gitHash',
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
);
|
||||||
|
await launchUrl(
|
||||||
|
url,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
_gitHash,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.blue,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
final data = await _buildReport();
|
||||||
|
await Clipboard.setData(ClipboardData(text: data));
|
||||||
|
if (ctx.mounted) {
|
||||||
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Could not open browser.'),
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text('Copied to clipboard'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
},
|
||||||
if (context.mounted) {
|
icon: const Icon(Icons.copy),
|
||||||
ScaffoldMessenger.of(
|
label: const Text('Copy to Clipboard'),
|
||||||
context,
|
),
|
||||||
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
// URL carries only the title to avoid exceeding browser
|
||||||
|
// URL-length limits — long stack traces caused "create
|
||||||
|
// issue failed" (#146). Use "Copy to Clipboard" first to
|
||||||
|
// get the full report, then paste it in the issue body.
|
||||||
|
final title = Uri.encodeComponent(
|
||||||
|
'Crash: ${exception.toString().split('\n').first}',
|
||||||
|
);
|
||||||
|
final url = Uri.parse(
|
||||||
|
'https://codeberg.org/guettli/sharedinbox/issues/new?title=$title',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final launched = await launchUrl(
|
||||||
|
url,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
|
if (!launched && ctx.mounted) {
|
||||||
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text('Could not open browser.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (ctx.mounted) {
|
||||||
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
content: Text('Error: $e'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
icon: const Icon(Icons.bug_report),
|
||||||
icon: const Icon(Icons.bug_report),
|
label: const Text('Report Issue on Codeberg'),
|
||||||
label: const Text('Report Issue on Codeberg'),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
bool _tryTesting = false;
|
bool _tryTesting = false;
|
||||||
String? _tryOk;
|
String? _tryOk;
|
||||||
String? _tryErr;
|
String? _tryErr;
|
||||||
bool _resyncing = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -171,43 +170,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _forceResync() async {
|
|
||||||
final confirmed = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('Force full sync?'),
|
|
||||||
content: const Text(
|
|
||||||
'This clears all locally-cached emails and mailboxes for this '
|
|
||||||
'account and immediately re-downloads everything from the server. '
|
|
||||||
'Previously viewed email content will not need to be re-downloaded.',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () => Navigator.of(ctx).pop(true),
|
|
||||||
child: const Text('Force sync'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (confirmed != true || !mounted) return;
|
|
||||||
setState(() => _resyncing = true);
|
|
||||||
try {
|
|
||||||
await ref.read(syncManagerProvider).forceResync(widget.accountId);
|
|
||||||
if (mounted) context.pop();
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_resyncing = false;
|
|
||||||
_errorMessage = 'Force sync failed: $e';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
final password = _passwordCtrl.text.isNotEmpty ? _passwordCtrl.text : null;
|
final password = _passwordCtrl.text.isNotEmpty ? _passwordCtrl.text : null;
|
||||||
@@ -268,7 +230,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Edit account')),
|
appBar: AppBar(title: const Text('Edit account')),
|
||||||
body: _loading || _saving || _resyncing
|
body: _loading || _saving
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _buildForm(),
|
: _buildForm(),
|
||||||
);
|
);
|
||||||
@@ -324,11 +286,11 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
'IMAP (SSL/TLS)',
|
'IMAP (SSL/TLS)',
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
),
|
),
|
||||||
_field(_imapHostCtrl, 'Host'),
|
_field(_imapHostCtrl, 'Host', validator: validateHostname),
|
||||||
_field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number),
|
_field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number),
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
|
Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
|
||||||
_field(_smtpHostCtrl, 'Host'),
|
_field(_smtpHostCtrl, 'Host', validator: validateHostname),
|
||||||
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
|
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
|
||||||
if (isLocalhost(_smtpHostCtrl.text.trim()))
|
if (isLocalhost(_smtpHostCtrl.text.trim()))
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
@@ -348,6 +310,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
_sieveHostCtrl,
|
_sieveHostCtrl,
|
||||||
'Host (leave blank to use IMAP host)',
|
'Host (leave blank to use IMAP host)',
|
||||||
required: false,
|
required: false,
|
||||||
|
validator: validateOptionalHostname,
|
||||||
),
|
),
|
||||||
_field(
|
_field(
|
||||||
_sievePortCtrl,
|
_sievePortCtrl,
|
||||||
@@ -386,15 +349,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
FilledButton(onPressed: _save, child: const Text('Save')),
|
FilledButton(onPressed: _save, child: const Text('Save')),
|
||||||
const SizedBox(height: 8),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
icon: const Icon(Icons.sync_problem),
|
|
||||||
label: const Text('Force full sync'),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: Theme.of(context).colorScheme.error,
|
|
||||||
),
|
|
||||||
onPressed: _forceResync,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -408,6 +362,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
bool obscure = false,
|
bool obscure = false,
|
||||||
bool required = true,
|
bool required = true,
|
||||||
TextInputType? keyboardType,
|
TextInputType? keyboardType,
|
||||||
|
String? Function(String?)? validator,
|
||||||
}) {
|
}) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
@@ -420,9 +375,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
labelText: label,
|
labelText: label,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
validator: required
|
validator: validator ??
|
||||||
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
|
(required
|
||||||
: null,
|
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
|
||||||
|
: null),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_html/flutter_html.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:open_filex/open_filex.dart';
|
import 'package:open_filex/open_filex.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.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/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';
|
||||||
|
|
||||||
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
|
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
|
||||||
|
|
||||||
@@ -25,144 +31,153 @@ class EmailDetailScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||||
late final Future<(Email?, EmailBody)> _dataFuture;
|
|
||||||
bool _isFlagged = false;
|
bool _isFlagged = false;
|
||||||
bool _loadRemoteImages = false;
|
bool _loadRemoteImages = false;
|
||||||
final Set<String> _downloading = {};
|
final Set<String> _downloading = {};
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
|
||||||
_dataFuture = Future.wait([
|
|
||||||
repo.getEmail(widget.emailId),
|
|
||||||
repo.getEmailBody(widget.emailId),
|
|
||||||
]).then((results) {
|
|
||||||
final email = results[0] as Email?;
|
|
||||||
if (email != null && mounted) {
|
|
||||||
setState(() => _isFlagged = email.isFlagged);
|
|
||||||
}
|
|
||||||
return (email, results[1] as EmailBody);
|
|
||||||
});
|
|
||||||
unawaited(repo.setFlag(widget.emailId, seen: true));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final repo = ref.watch(emailRepositoryProvider);
|
final repo = ref.watch(emailRepositoryProvider);
|
||||||
return FutureBuilder<(Email?, EmailBody)>(
|
final detail = ref.watch(emailDetailProvider(widget.emailId));
|
||||||
future: _dataFuture,
|
|
||||||
builder: (ctx, snap) {
|
|
||||||
final header = snap.data?.$1;
|
|
||||||
final body = snap.data?.$2;
|
|
||||||
|
|
||||||
return Scaffold(
|
ref.listen<AsyncValue<(Email?, EmailBody)>>(
|
||||||
appBar: AppBar(
|
emailDetailProvider(widget.emailId),
|
||||||
title: Text(
|
(_, next) {
|
||||||
header?.subject ?? '(loading…)',
|
final email = next.valueOrNull?.$1;
|
||||||
overflow: TextOverflow.ellipsis,
|
if (email != null && mounted) {
|
||||||
|
setState(() => _isFlagged = email.isFlagged);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final header = detail.valueOrNull?.$1;
|
||||||
|
final body = detail.valueOrNull?.$2;
|
||||||
|
|
||||||
|
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
|
||||||
|
defaultTargetPlatform == TargetPlatform.iOS;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
automaticallyImplyLeading: !isMobile,
|
||||||
|
title: Text(
|
||||||
|
header?.subject ?? '(loading…)',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.reply),
|
||||||
|
tooltip: 'Reply',
|
||||||
|
onPressed: header == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
unawaited(_reply(context, header, body, replyAll: false));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.reply_all),
|
||||||
|
tooltip: 'Reply all',
|
||||||
|
onPressed: header == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
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,
|
||||||
),
|
),
|
||||||
actions: [
|
tooltip: _isFlagged ? 'Unflag' : 'Flag',
|
||||||
IconButton(
|
onPressed: () async {
|
||||||
icon: const Icon(Icons.reply),
|
final next = !_isFlagged;
|
||||||
tooltip: 'Reply',
|
await repo.setFlag(widget.emailId, flagged: next);
|
||||||
onPressed: header == null
|
if (mounted) setState(() => _isFlagged = next);
|
||||||
? null
|
},
|
||||||
: () => _reply(context, header, body, replyAll: false),
|
),
|
||||||
),
|
IconButton(
|
||||||
IconButton(
|
icon: const Icon(Icons.drive_file_move_outline),
|
||||||
icon: const Icon(Icons.reply_all),
|
tooltip: 'Move to folder',
|
||||||
tooltip: 'Reply all',
|
onPressed: header == null ? null : () => _moveTo(context, header),
|
||||||
onPressed: header == null
|
),
|
||||||
? null
|
IconButton(
|
||||||
: () => _reply(context, header, body, replyAll: true),
|
icon: const Icon(Icons.access_time),
|
||||||
),
|
tooltip: 'Snooze',
|
||||||
IconButton(
|
onPressed: header == null ? null : () => _snooze(context, header),
|
||||||
icon: const Icon(Icons.forward),
|
),
|
||||||
tooltip: 'Forward',
|
IconButton(
|
||||||
onPressed: header == null
|
icon: const Icon(Icons.delete),
|
||||||
? null
|
tooltip: 'Delete',
|
||||||
: () => _forward(context, header, body),
|
onPressed: () async {
|
||||||
),
|
final destPath = await repo.deleteEmail(widget.emailId);
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.mark_email_unread_outlined),
|
|
||||||
tooltip: 'Mark as unread',
|
|
||||||
onPressed: () async {
|
|
||||||
await repo.setFlag(widget.emailId, seen: false);
|
|
||||||
if (context.mounted) context.pop();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
_isFlagged ? Icons.star : Icons.star_border,
|
|
||||||
color: _isFlagged ? Colors.amber : null,
|
|
||||||
),
|
|
||||||
tooltip: _isFlagged ? 'Unflag' : 'Flag',
|
|
||||||
onPressed: () async {
|
|
||||||
final next = !_isFlagged;
|
|
||||||
await repo.setFlag(widget.emailId, flagged: next);
|
|
||||||
if (mounted) setState(() => _isFlagged = next);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.drive_file_move_outline),
|
|
||||||
tooltip: 'Move to folder',
|
|
||||||
onPressed:
|
|
||||||
header == null ? null : () => _moveTo(context, header),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.access_time),
|
|
||||||
tooltip: 'Snooze',
|
|
||||||
onPressed:
|
|
||||||
header == null ? null : () => _snooze(context, header),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete),
|
|
||||||
tooltip: 'Delete',
|
|
||||||
onPressed: () async {
|
|
||||||
final destPath = await repo.deleteEmail(widget.emailId);
|
|
||||||
|
|
||||||
if (header != null) {
|
if (header != null) {
|
||||||
unawaited(
|
unawaited(
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
UndoAction(
|
UndoAction(
|
||||||
id: DateTime.now().toIso8601String(),
|
id: DateTime.now().toIso8601String(),
|
||||||
accountId: header.accountId,
|
accountId: header.accountId,
|
||||||
type: UndoType.delete,
|
type: UndoType.delete,
|
||||||
emailIds: [widget.emailId],
|
emailIds: [widget.emailId],
|
||||||
sourceMailboxPath: header.mailboxPath,
|
sourceMailboxPath: header.mailboxPath,
|
||||||
destinationMailboxPath: destPath,
|
destinationMailboxPath: destPath,
|
||||||
originalEmails: [header],
|
originalEmails: [header],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.mounted) context.pop();
|
if (context.mounted) context.pop();
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
itemBuilder: (ctx) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'headers',
|
||||||
|
child: Text('Show Mail Headers'),
|
||||||
),
|
),
|
||||||
PopupMenuButton<String>(
|
const PopupMenuItem(
|
||||||
itemBuilder: (ctx) => [
|
value: 'structure',
|
||||||
const PopupMenuItem(
|
child: Text('Show Mail Structure'),
|
||||||
value: 'headers',
|
),
|
||||||
child: Text('Show Mail Headers'),
|
const PopupMenuItem(
|
||||||
),
|
value: 'rfc',
|
||||||
],
|
child: Text('Show Raw Email'),
|
||||||
onSelected: (value) {
|
|
||||||
if (value == 'headers' && body != null) {
|
|
||||||
_showHeaders(context, body);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
onSelected: (value) {
|
||||||
|
if (value == 'headers' && body != null) {
|
||||||
|
_showHeaders(context, body);
|
||||||
|
} else if (value == 'structure' && body != null) {
|
||||||
|
_showStructure(context, body);
|
||||||
|
} else if (value == 'rfc') {
|
||||||
|
unawaited(_showRaw(context, header));
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
body: snap.connectionState == ConnectionState.waiting
|
],
|
||||||
? const Center(child: CircularProgressIndicator())
|
),
|
||||||
: snap.hasError
|
body: detail.when(
|
||||||
? Center(child: Text('Error: ${snap.error}'))
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
: _buildBody(ctx, header, body!),
|
error: (e, _) => Center(child: Text('Error: $e')),
|
||||||
);
|
data: (d) => _buildBody(context, d.$1, d.$2),
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,9 +200,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Html(
|
SecureEmailWebView(
|
||||||
data: body.htmlBody!,
|
htmlBody: body.htmlBody!,
|
||||||
extensions: [if (!_loadRemoteImages) _BlockRemoteImagesExtension()],
|
loadRemoteImages: _loadRemoteImages,
|
||||||
),
|
),
|
||||||
] else
|
] else
|
||||||
SelectableText(
|
SelectableText(
|
||||||
@@ -267,30 +282,40 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
_dateFmt.format(email.sentAt!),
|
_dateFmt.format(email.sentAt!),
|
||||||
style: Theme.of(ctx).textTheme.bodySmall,
|
style: Theme.of(ctx).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
|
if (email.listUnsubscribeHeader != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: _UnsubscribeChip(header: email.listUnsubscribeHeader!),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _quotedBody(Email header, EmailBody? body) {
|
Future<String> _quotedBody(Email header, EmailBody? body) async {
|
||||||
final date = header.sentAt != null ? _dateFmt.format(header.sentAt!) : '';
|
final date = header.sentAt != null ? _dateFmt.format(header.sentAt!) : '';
|
||||||
final from =
|
final from =
|
||||||
header.from.isNotEmpty ? header.from.first.toString() : '(unknown)';
|
header.from.isNotEmpty ? header.from.first.toString() : '(unknown)';
|
||||||
final text = body?.textBody ?? htmlToPlain(body?.htmlBody ?? '');
|
final rawText = body?.textBody;
|
||||||
|
final text = (rawText != null && rawText.isNotEmpty)
|
||||||
|
? rawText
|
||||||
|
: await compute(htmlToPlain, body?.htmlBody ?? '');
|
||||||
final quoted = text.trim().split('\n').map((l) => '> $l').join('\n');
|
final quoted = text.trim().split('\n').map((l) => '> $l').join('\n');
|
||||||
return '\n\n— On $date, $from wrote:\n$quoted';
|
return '\n\n— On $date, $from wrote:\n$quoted';
|
||||||
}
|
}
|
||||||
|
|
||||||
void _reply(
|
Future<void> _reply(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Email header,
|
Email header,
|
||||||
EmailBody? body, {
|
EmailBody? body, {
|
||||||
required bool replyAll,
|
required bool replyAll,
|
||||||
}) {
|
}) async {
|
||||||
final to = header.from.isNotEmpty ? header.from.first.email : '';
|
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 cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
|
||||||
|
final quoted = await _quotedBody(header, body);
|
||||||
|
if (!context.mounted) return;
|
||||||
unawaited(
|
unawaited(
|
||||||
context.push(
|
context.push(
|
||||||
'/compose',
|
'/compose',
|
||||||
@@ -298,23 +323,29 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
'replyToEmailId': widget.emailId,
|
'replyToEmailId': widget.emailId,
|
||||||
'prefillTo': to,
|
'prefillTo': to,
|
||||||
'prefillSubject': subject,
|
'prefillSubject': subject,
|
||||||
'prefillBody': _quotedBody(header, body),
|
'prefillBody': quoted,
|
||||||
if (cc.isNotEmpty) 'prefillCc': cc,
|
if (cc.isNotEmpty) 'prefillCc': cc,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _forward(BuildContext context, Email header, EmailBody? body) {
|
Future<void> _forward(
|
||||||
|
BuildContext context,
|
||||||
|
Email header,
|
||||||
|
EmailBody? body,
|
||||||
|
) async {
|
||||||
final subject = (header.subject?.startsWith('Fwd:') ?? false)
|
final subject = (header.subject?.startsWith('Fwd:') ?? false)
|
||||||
? header.subject!
|
? header.subject!
|
||||||
: 'Fwd: ${header.subject ?? ''}';
|
: 'Fwd: ${header.subject ?? ''}';
|
||||||
|
final quoted = await _quotedBody(header, body);
|
||||||
|
if (!context.mounted) return;
|
||||||
unawaited(
|
unawaited(
|
||||||
context.push(
|
context.push(
|
||||||
'/compose',
|
'/compose',
|
||||||
extra: {
|
extra: {
|
||||||
'prefillSubject': subject,
|
'prefillSubject': subject,
|
||||||
'prefillBody': _quotedBody(header, body),
|
'prefillBody': quoted,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -394,6 +425,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
content: Text(
|
content: Text(
|
||||||
'Snoozed until ${DateFormat('MMM d, HH:mm').format(until)}',
|
'Snoozed until ${DateFormat('MMM d, HH:mm').format(until)}',
|
||||||
),
|
),
|
||||||
@@ -403,10 +435,121 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showRaw(BuildContext context, Email? header) async {
|
||||||
|
final String raw;
|
||||||
|
try {
|
||||||
|
raw = await ref
|
||||||
|
.read(emailRepositoryProvider)
|
||||||
|
.fetchRawRfc822(widget.emailId);
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to fetch raw email: $e')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
unawaited(
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Raw Email'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
fmtSize(raw.length),
|
||||||
|
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(ctx).colorScheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Flexible(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: SelectableText(
|
||||||
|
raw,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await Clipboard.setData(ClipboardData(text: raw));
|
||||||
|
if (ctx.mounted) {
|
||||||
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Copied to clipboard')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Copy'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await _downloadRaw(ctx, header, raw);
|
||||||
|
if (ctx.mounted) Navigator.pop(ctx);
|
||||||
|
},
|
||||||
|
child: const Text('Download'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _downloadRaw(
|
||||||
|
BuildContext context,
|
||||||
|
Email? header,
|
||||||
|
String raw,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final dir = await getTemporaryDirectory();
|
||||||
|
final subject = (header?.subject ?? 'email')
|
||||||
|
.replaceAll(RegExp(r'[^\w\s-]'), '_')
|
||||||
|
.trim();
|
||||||
|
final filename = '$subject.eml';
|
||||||
|
final file = File('${dir.path}/$filename');
|
||||||
|
await file.writeAsString(raw);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Saved $filename'),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'Share',
|
||||||
|
onPressed: () => SharePlus.instance.share(
|
||||||
|
ShareParams(files: [XFile(file.path)]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('Download failed: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showHeaders(BuildContext context, EmailBody body) {
|
void _showHeaders(BuildContext context, EmailBody body) {
|
||||||
if (body.headers.isEmpty) {
|
if (body.headers.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
content: Text('No headers available. Try re-syncing the email.'),
|
content: Text('No headers available. Try re-syncing the email.'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -460,20 +603,119 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class _BlockRemoteImagesExtension extends HtmlExtension {
|
void _showStructure(BuildContext context, EmailBody body) {
|
||||||
@override
|
final tree = body.mimeTree;
|
||||||
Set<String> get supportedTags => {'img'};
|
if (tree == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text(
|
||||||
|
'Structure not available. Try re-syncing the email.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
final rows = <_MimeRow>[];
|
||||||
bool matches(ExtensionContext context) {
|
_flattenMimeTree(tree, 0, rows);
|
||||||
if (context.elementName != 'img') return false;
|
|
||||||
final src = context.attributes['src'] ?? '';
|
unawaited(
|
||||||
return src.startsWith('http://') || src.startsWith('https://');
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Mail Structure'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: rows.length,
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final row = rows[i];
|
||||||
|
return Container(
|
||||||
|
color: i.isEven
|
||||||
|
? Theme.of(ctx).colorScheme.surfaceContainerHighest
|
||||||
|
: Theme.of(ctx).colorScheme.surface,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 4,
|
||||||
|
horizontal: 8,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(width: row.depth * 16.0),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
row.label,
|
||||||
|
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MimeRow {
|
||||||
|
const _MimeRow(this.depth, this.label);
|
||||||
|
final int depth;
|
||||||
|
final String label;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _flattenMimeTree(MimePart part, int depth, List<_MimeRow> out) {
|
||||||
|
final parts = <String>[part.contentType];
|
||||||
|
if (part.filename != null) parts.add('"${part.filename}"');
|
||||||
|
if (part.size != null) parts.add(fmtSize(part.size!));
|
||||||
|
if (part.encoding != null) parts.add(part.encoding!);
|
||||||
|
out.add(_MimeRow(depth, parts.join(' ')));
|
||||||
|
for (final child in part.children) {
|
||||||
|
_flattenMimeTree(child, depth + 1, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a List-Unsubscribe header and returns the first usable URI.
|
||||||
|
/// Prefers mailto: so unsubscribing sends an email; falls back to https:.
|
||||||
|
Uri? _parseUnsubscribeUri(String header) {
|
||||||
|
final matches = RegExp(r'<([^>]+)>').allMatches(header);
|
||||||
|
Uri? fallback;
|
||||||
|
for (final m in matches) {
|
||||||
|
final raw = m.group(1)!.trim();
|
||||||
|
final uri = Uri.tryParse(raw);
|
||||||
|
if (uri == null) continue;
|
||||||
|
if (uri.scheme == 'mailto') return uri;
|
||||||
|
if ((uri.scheme == 'https' || uri.scheme == 'http') && fallback == null) {
|
||||||
|
fallback = uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UnsubscribeChip extends StatelessWidget {
|
||||||
|
const _UnsubscribeChip({required this.header});
|
||||||
|
final String header;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
InlineSpan build(ExtensionContext context) =>
|
Widget build(BuildContext context) {
|
||||||
const WidgetSpan(child: SizedBox.shrink());
|
final uri = _parseUnsubscribeUri(header);
|
||||||
|
if (uri == null) return const SizedBox.shrink();
|
||||||
|
return ActionChip(
|
||||||
|
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
|
||||||
|
label: const Text('Unsubscribe'),
|
||||||
|
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,19 @@ 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/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/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';
|
||||||
|
|
||||||
final _dateFmt = DateFormat('MMM d');
|
final _dateFmt = DateFormat('MMM d');
|
||||||
|
// Cache formatted dates by local calendar day so DateFormat.format is called
|
||||||
|
// at most once per unique date rather than once per list item per rebuild.
|
||||||
|
final _formattedDates = <int, String>{};
|
||||||
|
|
||||||
|
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
|
||||||
|
|
||||||
|
String _fmtDate(DateTime dt) =>
|
||||||
|
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
|
||||||
|
|
||||||
class EmailListScreen extends ConsumerStatefulWidget {
|
class EmailListScreen extends ConsumerStatefulWidget {
|
||||||
const EmailListScreen({
|
const EmailListScreen({
|
||||||
@@ -44,6 +53,10 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
List<EmailThread> _currentThreads = [];
|
List<EmailThread> _currentThreads = [];
|
||||||
// Individual email selection used in search results.
|
// Individual email selection used in search results.
|
||||||
final Set<String> _selectedSearchIds = {};
|
final Set<String> _selectedSearchIds = {};
|
||||||
|
|
||||||
|
// Pagination: number of threads currently requested from the DB.
|
||||||
|
static const _pageSize = 50;
|
||||||
|
int _limit = _pageSize;
|
||||||
bool get _selecting =>
|
bool get _selecting =>
|
||||||
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
||||||
|
|
||||||
@@ -81,6 +94,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
_selectedSearchIds.clear();
|
_selectedSearchIds.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void _selectAll() {
|
||||||
|
setState(() {
|
||||||
|
if (_searching) {
|
||||||
|
_selectedSearchIds.addAll(_searchResults?.map((e) => e.id) ?? []);
|
||||||
|
} else {
|
||||||
|
_selectedThreadIds.addAll(_currentThreads.map((t) => t.threadId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void _toggleSearchSelection(String emailId) {
|
void _toggleSearchSelection(String emailId) {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (_selectedSearchIds.contains(emailId)) {
|
if (_selectedSearchIds.contains(emailId)) {
|
||||||
@@ -165,7 +188,13 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
? Text('$selectionCount selected')
|
? Text('$selectionCount selected')
|
||||||
: Text(widget.mailboxPath),
|
: Text(widget.mailboxPath),
|
||||||
actions: _selecting
|
actions: _selecting
|
||||||
? []
|
? [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.select_all),
|
||||||
|
tooltip: 'Select all',
|
||||||
|
onPressed: _selectAll,
|
||||||
|
),
|
||||||
|
]
|
||||||
: [
|
: [
|
||||||
accountAsync.when(
|
accountAsync.when(
|
||||||
loading: () => const SizedBox.shrink(),
|
loading: () => const SizedBox.shrink(),
|
||||||
@@ -180,22 +209,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
_buildSyncButton(emailRepo),
|
||||||
icon: const Icon(Icons.sync),
|
|
||||||
onPressed: () async {
|
|
||||||
try {
|
|
||||||
await emailRepo.syncEmails(
|
|
||||||
widget.accountId,
|
|
||||||
widget.mailboxPath,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text('Sync failed: $e')));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.edit),
|
icon: const Icon(Icons.edit),
|
||||||
onPressed: () => context.push(
|
onPressed: () => context.push(
|
||||||
@@ -203,6 +217,22 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
extra: {'accountId': widget.accountId},
|
extra: {'accountId': widget.accountId},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
onSelected: (value) async {
|
||||||
|
if (value == 'mark_all_read') {
|
||||||
|
await emailRepo.markAllAsRead(
|
||||||
|
widget.accountId,
|
||||||
|
widget.mailboxPath,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => const [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'mark_all_read',
|
||||||
|
child: Text('Mark all as read'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
bottom: PreferredSize(
|
bottom: PreferredSize(
|
||||||
preferredSize: const Size.fromHeight(60),
|
preferredSize: const Size.fromHeight(60),
|
||||||
@@ -229,6 +259,47 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSyncButton(EmailRepository emailRepo) {
|
||||||
|
final isSyncing =
|
||||||
|
ref.watch(isSyncingProvider(widget.accountId)).valueOrNull ?? false;
|
||||||
|
final hasError =
|
||||||
|
ref.watch(syncLastErrorProvider(widget.accountId)).valueOrNull != null;
|
||||||
|
return IconButton(
|
||||||
|
tooltip: isSyncing
|
||||||
|
? 'Syncing…'
|
||||||
|
: hasError
|
||||||
|
? 'Sync error'
|
||||||
|
: 'Sync',
|
||||||
|
icon: isSyncing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: hasError
|
||||||
|
? const Icon(Icons.sync_problem, color: Colors.red)
|
||||||
|
: const Icon(Icons.sync),
|
||||||
|
onPressed: isSyncing
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
try {
|
||||||
|
await emailRepo.syncEmails(
|
||||||
|
widget.accountId,
|
||||||
|
widget.mailboxPath,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
content: Text('Sync failed: $e'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _selectionBottomBar() {
|
Widget _selectionBottomBar() {
|
||||||
return BottomAppBar(
|
return BottomAppBar(
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -302,6 +373,12 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
},
|
},
|
||||||
child: const Text('Retry'),
|
child: const Text('Retry'),
|
||||||
),
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.push(
|
||||||
|
'/accounts/${widget.accountId}/sync-log',
|
||||||
|
),
|
||||||
|
child: const Text('View log'),
|
||||||
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => setState(() => _dismissedError = error),
|
onPressed: () => setState(() => _dismissedError = error),
|
||||||
child: const Text('Dismiss'),
|
child: const Text('Dismiss'),
|
||||||
@@ -319,7 +396,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
await emailRepo.syncEmails(widget.accountId, widget.mailboxPath);
|
await emailRepo.syncEmails(widget.accountId, widget.mailboxPath);
|
||||||
},
|
},
|
||||||
child: StreamBuilder<List<EmailThread>>(
|
child: StreamBuilder<List<EmailThread>>(
|
||||||
stream: emailRepo.observeThreads(widget.accountId, widget.mailboxPath),
|
stream: emailRepo.observeThreads(
|
||||||
|
widget.accountId,
|
||||||
|
widget.mailboxPath,
|
||||||
|
limit: _limit,
|
||||||
|
),
|
||||||
builder: (ctx, snap) {
|
builder: (ctx, snap) {
|
||||||
if (!snap.hasData) {
|
if (!snap.hasData) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
@@ -349,7 +430,12 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
if (mailbox == null) {
|
if (mailbox == null) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text(notFoundMessage)));
|
).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
content: Text(notFoundMessage),
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
@@ -380,8 +466,36 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
Future<void> _batchArchive() =>
|
Future<void> _batchArchive() =>
|
||||||
_batchMoveToRole('archive', 'No archive folder found');
|
_batchMoveToRole('archive', 'No archive folder found');
|
||||||
|
|
||||||
|
Future<void> _refreshSearchAndPopIfEmpty() async {
|
||||||
|
if (!mounted || !_searching) return;
|
||||||
|
final query = _searchController.text.trim();
|
||||||
|
final remaining = await ref
|
||||||
|
.read(emailRepositoryProvider)
|
||||||
|
.searchEmails(widget.accountId, widget.mailboxPath, query);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (remaining.isEmpty) {
|
||||||
|
if (context.canPop()) {
|
||||||
|
context.pop();
|
||||||
|
} else {
|
||||||
|
_searchController.clear();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setState(() => _searchResults = remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openSearchResultAndRefresh(String emailId) async {
|
||||||
|
await context.push(
|
||||||
|
'/accounts/${widget.accountId}/mailboxes'
|
||||||
|
'/${Uri.encodeComponent(widget.mailboxPath)}'
|
||||||
|
'/emails/${Uri.encodeComponent(emailId)}',
|
||||||
|
);
|
||||||
|
await _refreshSearchAndPopIfEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _batchDelete() async {
|
Future<void> _batchDelete() async {
|
||||||
final ids = _selectedEmailIds;
|
final ids = _selectedEmailIds;
|
||||||
|
final wasSearching = _searching;
|
||||||
_clearSelection();
|
_clearSelection();
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
|
||||||
@@ -408,6 +522,25 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
originalEmails: originalEmails,
|
originalEmails: originalEmails,
|
||||||
);
|
);
|
||||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
|
||||||
|
if (wasSearching && mounted) {
|
||||||
|
// Filter deleted emails out of the local results immediately.
|
||||||
|
// Calling searchEmails here would hit the IMAP server, which still has
|
||||||
|
// the emails because the delete is only enqueued — not yet applied.
|
||||||
|
final deletedIds = ids.toSet();
|
||||||
|
final remaining = (_searchResults ?? [])
|
||||||
|
.where((e) => !deletedIds.contains(e.id))
|
||||||
|
.toList();
|
||||||
|
if (remaining.isEmpty) {
|
||||||
|
if (context.canPop()) {
|
||||||
|
context.pop();
|
||||||
|
} else {
|
||||||
|
_searchController.clear();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setState(() => _searchResults = remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _batchMarkSpam() =>
|
Future<void> _batchMarkSpam() =>
|
||||||
@@ -507,6 +640,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
content: Text(
|
content: Text(
|
||||||
'Snoozed ${ids.length} email${ids.length == 1 ? '' : 's'} until ${DateFormat('MMM d, HH:mm').format(until)}',
|
'Snoozed ${ids.length} email${ids.length == 1 ? '' : 's'} until ${DateFormat('MMM d, HH:mm').format(until)}',
|
||||||
),
|
),
|
||||||
@@ -515,9 +649,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildThreadList(List<EmailThread> threads) {
|
Widget _buildThreadList(List<EmailThread> threads) {
|
||||||
|
final hasMore = threads.length == _limit;
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: threads.length,
|
itemCount: threads.length + (hasMore ? 1 : 0),
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
|
if (i == threads.length) {
|
||||||
|
return TextButton(
|
||||||
|
onPressed: () => setState(() => _limit += _pageSize),
|
||||||
|
child: const Text('Load more'),
|
||||||
|
);
|
||||||
|
}
|
||||||
final t = threads[i];
|
final t = threads[i];
|
||||||
final isSelected = _selectedThreadIds.contains(t.threadId);
|
final isSelected = _selectedThreadIds.contains(t.threadId);
|
||||||
final senderNames =
|
final senderNames =
|
||||||
@@ -586,7 +727,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
const Icon(Icons.star, color: Colors.amber, size: 16),
|
const Icon(Icons.star, color: Colors.amber, size: 16),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
_dateFmt.format(t.latestDate),
|
_fmtDate(t.latestDate),
|
||||||
style: Theme.of(ctx).textTheme.bodySmall,
|
style: Theme.of(ctx).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -688,10 +829,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
final e = emails[i];
|
final e = emails[i];
|
||||||
final isSelected = _selectedSearchIds.contains(e.id);
|
final isSelected = _selectedSearchIds.contains(e.id);
|
||||||
final sender = e.from.isNotEmpty
|
return EmailTile(
|
||||||
? (e.from.first.name ?? e.from.first.email)
|
email: e,
|
||||||
: '(unknown)';
|
selected: isSelected,
|
||||||
return ListTile(
|
|
||||||
leading: SizedBox(
|
leading: SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
child: _selecting
|
child: _selecting
|
||||||
@@ -699,31 +839,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
value: isSelected,
|
value: isSelected,
|
||||||
onChanged: (_) => _toggleSearchSelection(e.id),
|
onChanged: (_) => _toggleSearchSelection(e.id),
|
||||||
)
|
)
|
||||||
: Icon(
|
: null,
|
||||||
e.isSeen ? Icons.mail_outline : Icons.mail,
|
|
||||||
color: e.isSeen ? null : Theme.of(ctx).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
sender,
|
|
||||||
style:
|
|
||||||
e.isSeen ? null : const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
e.subject ?? '(no subject)',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
selected: isSelected,
|
|
||||||
trailing: Text(
|
|
||||||
e.sentAt != null ? _dateFmt.format(e.sentAt!) : '',
|
|
||||||
style: Theme.of(ctx).textTheme.bodySmall,
|
|
||||||
),
|
),
|
||||||
onTap: _selecting
|
onTap: _selecting
|
||||||
? () => _toggleSearchSelection(e.id)
|
? () => _toggleSearchSelection(e.id)
|
||||||
: () => context.push(
|
: () => unawaited(_openSearchResultAndRefresh(e.id)),
|
||||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(e.id)}',
|
|
||||||
),
|
|
||||||
onLongPress: () => _toggleSearchSelection(e.id),
|
onLongPress: () => _toggleSearchSelection(e.id),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ 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/utils/logger.dart';
|
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';
|
||||||
|
|
||||||
|
final _searchHistoryProvider =
|
||||||
|
FutureProvider.autoDispose<List<String>>((ref) async {
|
||||||
|
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Returns true if [text] contains a word that starts with [query].
|
||||||
|
/// "foo" matches "foobar" or "My Foobar" but NOT "blafoo".
|
||||||
|
bool _hasWordPrefix(String text, String query) =>
|
||||||
|
RegExp(r'\b' + RegExp.escape(query), caseSensitive: false).hasMatch(text);
|
||||||
|
|
||||||
class SearchScreen extends ConsumerStatefulWidget {
|
class SearchScreen extends ConsumerStatefulWidget {
|
||||||
const SearchScreen({super.key, this.accountId});
|
const SearchScreen({super.key, this.accountId});
|
||||||
@@ -19,13 +30,24 @@ class SearchScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
final _ctrl = TextEditingController();
|
final _ctrl = TextEditingController();
|
||||||
|
final _focusNode = FocusNode();
|
||||||
Timer? _debounce;
|
Timer? _debounce;
|
||||||
_SearchResults? _results;
|
_SearchResults? _results;
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
|
bool _fieldFocused = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_focusNode.addListener(() {
|
||||||
|
if (mounted) setState(() => _fieldFocused = _focusNode.hasFocus);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_ctrl.dispose();
|
_ctrl.dispose();
|
||||||
|
_focusNode.dispose();
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -44,6 +66,12 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
|
|
||||||
Future<void> _search(String query) async {
|
Future<void> _search(String query) async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(searchHistoryRepositoryProvider)
|
||||||
|
.saveSearch(query)
|
||||||
|
.then((_) => ref.invalidate(_searchHistoryProvider)),
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
final emailRepo = ref.read(emailRepositoryProvider);
|
final emailRepo = ref.read(emailRepositoryProvider);
|
||||||
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||||
@@ -56,7 +84,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
).wait;
|
).wait;
|
||||||
|
|
||||||
final matchedMailboxes = allMailboxes
|
final matchedMailboxes = allMailboxes
|
||||||
.where((m) => m.name.toLowerCase().contains(ql))
|
.where((m) => _hasWordPrefix(m.name, ql))
|
||||||
.toList()
|
.toList()
|
||||||
..sort(compareMailboxes);
|
..sort(compareMailboxes);
|
||||||
|
|
||||||
@@ -68,8 +96,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
for (final addr in [...email.from, ...email.to, ...email.cc]) {
|
for (final addr in [...email.from, ...email.to, ...email.cc]) {
|
||||||
final key = '${email.accountId}:${addr.email}';
|
final key = '${email.accountId}:${addr.email}';
|
||||||
if (seen.contains(key)) continue;
|
if (seen.contains(key)) continue;
|
||||||
final matchesEmail = addr.email.toLowerCase().contains(ql);
|
final matchesEmail = _hasWordPrefix(addr.email, ql);
|
||||||
final matchesName = addr.name?.toLowerCase().contains(ql) ?? false;
|
final matchesName =
|
||||||
|
addr.name != null && _hasWordPrefix(addr.name!, ql);
|
||||||
if (!matchesEmail && !matchesName) continue;
|
if (!matchesEmail && !matchesName) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
final addrEmail = addr.email;
|
final addrEmail = addr.email;
|
||||||
@@ -111,6 +140,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: TextField(
|
title: TextField(
|
||||||
controller: _ctrl,
|
controller: _ctrl,
|
||||||
|
focusNode: _focusNode,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: 'Search folders, addresses, emails…',
|
hintText: 'Search folders, addresses, emails…',
|
||||||
@@ -136,6 +166,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
if (_loading) return const Center(child: CircularProgressIndicator());
|
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||||
if (_results == null) {
|
if (_results == null) {
|
||||||
|
if (_fieldFocused && _ctrl.text.isEmpty) {
|
||||||
|
return _buildHistoryPanel();
|
||||||
|
}
|
||||||
return const Center(child: Text('Type 3+ characters to search'));
|
return const Center(child: Text('Type 3+ characters to search'));
|
||||||
}
|
}
|
||||||
final r = _results!;
|
final r = _results!;
|
||||||
@@ -155,11 +188,79 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
if (r.emails.isNotEmpty) ...[
|
if (r.emails.isNotEmpty) ...[
|
||||||
const _SectionHeader('Messages'),
|
const _SectionHeader('Messages'),
|
||||||
for (final e in r.emails)
|
for (final e in r.emails)
|
||||||
_EmailTile(email: e, accountId: e.accountId),
|
EmailTile(
|
||||||
|
email: e,
|
||||||
|
showLocation: true,
|
||||||
|
onTap: () => context.push(
|
||||||
|
'/accounts/${e.accountId}/mailboxes'
|
||||||
|
'/${Uri.encodeComponent(e.mailboxPath)}'
|
||||||
|
'/emails/${Uri.encodeComponent(e.id)}',
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildHistoryPanel() {
|
||||||
|
final history = ref.watch(_searchHistoryProvider);
|
||||||
|
return history.when(
|
||||||
|
loading: () => const Center(child: Text('Type 3+ characters to search')),
|
||||||
|
error: (_, __) =>
|
||||||
|
const Center(child: Text('Type 3+ characters to search')),
|
||||||
|
data: (terms) {
|
||||||
|
if (terms.isEmpty) {
|
||||||
|
return const Center(child: Text('Type 3+ characters to search'));
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Recent searches',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await ref
|
||||||
|
.read(searchHistoryRepositoryProvider)
|
||||||
|
.clearHistory();
|
||||||
|
ref.invalidate(_searchHistoryProvider);
|
||||||
|
},
|
||||||
|
child: const Text('Clear'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: [
|
||||||
|
for (final term in terms)
|
||||||
|
ActionChip(
|
||||||
|
label: Text(term),
|
||||||
|
onPressed: () {
|
||||||
|
_ctrl.text = term;
|
||||||
|
_ctrl.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: term.length),
|
||||||
|
);
|
||||||
|
unawaited(_search(term));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SearchResults {
|
class _SearchResults {
|
||||||
@@ -246,42 +347,3 @@ class _AddressTile extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EmailTile extends StatelessWidget {
|
|
||||||
const _EmailTile({required this.email, required this.accountId});
|
|
||||||
final Email email;
|
|
||||||
final String accountId;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final sender = email.from.isNotEmpty
|
|
||||||
? (email.from.first.name ?? email.from.first.email)
|
|
||||||
: '(unknown)';
|
|
||||||
return ListTile(
|
|
||||||
leading: Icon(
|
|
||||||
email.isSeen ? Icons.mail_outline : Icons.mail,
|
|
||||||
color: email.isSeen ? null : Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
title: Text(sender),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
email.subject ?? '(no subject)',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'$accountId • ${email.mailboxPath}',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () => context.push(
|
|
||||||
'/accounts/$accountId/mailboxes'
|
|
||||||
'/${Uri.encodeComponent(email.mailboxPath)}'
|
|
||||||
'/emails/${Uri.encodeComponent(email.id)}',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.accountId,
|
required this.accountId,
|
||||||
this.script,
|
this.script,
|
||||||
|
this.isLocal = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String accountId;
|
final String accountId;
|
||||||
@@ -18,6 +19,9 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
|||||||
/// Null when creating a new script.
|
/// Null when creating a new script.
|
||||||
final SieveScript? script;
|
final SieveScript? script;
|
||||||
|
|
||||||
|
/// True for locally-executed scripts; false for server-side (ManageSieve/JMAP).
|
||||||
|
final bool isLocal;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<SieveScriptEditScreen> createState() =>
|
ConsumerState<SieveScriptEditScreen> createState() =>
|
||||||
_SieveScriptEditScreenState();
|
_SieveScriptEditScreenState();
|
||||||
@@ -50,9 +54,13 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
Future<void> _loadContent() async {
|
Future<void> _loadContent() async {
|
||||||
setState(() => _loadingContent = true);
|
setState(() => _loadingContent = true);
|
||||||
try {
|
try {
|
||||||
final content = await ref
|
final content = widget.isLocal
|
||||||
.read(sieveRepositoryProvider)
|
? await ref
|
||||||
.getScriptContent(widget.accountId, widget.script!.blobId);
|
.read(localSieveRepositoryProvider)
|
||||||
|
.getScriptContent(widget.accountId, widget.script!.blobId)
|
||||||
|
: await ref
|
||||||
|
.read(sieveRepositoryProvider)
|
||||||
|
.getScriptContent(widget.accountId, widget.script!.blobId);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_contentController.text = content;
|
_contentController.text = content;
|
||||||
setState(() => _loadingContent = false);
|
setState(() => _loadingContent = false);
|
||||||
@@ -78,12 +86,21 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await ref.read(sieveRepositoryProvider).saveScript(
|
if (widget.isLocal) {
|
||||||
widget.accountId,
|
await ref.read(localSieveRepositoryProvider).saveScript(
|
||||||
id: widget.script?.id,
|
widget.accountId,
|
||||||
name: name,
|
id: widget.script?.id,
|
||||||
content: _contentController.text,
|
name: name,
|
||||||
);
|
content: _contentController.text,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await ref.read(sieveRepositoryProvider).saveScript(
|
||||||
|
widget.accountId,
|
||||||
|
id: widget.script?.id,
|
||||||
|
name: name,
|
||||||
|
content: _contentController.text,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (mounted) Navigator.of(context).pop();
|
if (mounted) Navigator.of(context).pop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
@@ -8,10 +8,17 @@ import 'package:sharedinbox/core/models/sieve_script.dart';
|
|||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
|
||||||
class SieveScriptsScreen extends ConsumerStatefulWidget {
|
class SieveScriptsScreen extends ConsumerStatefulWidget {
|
||||||
const SieveScriptsScreen({super.key, required this.accountId});
|
const SieveScriptsScreen({
|
||||||
|
super.key,
|
||||||
|
required this.accountId,
|
||||||
|
this.isLocal = false,
|
||||||
|
});
|
||||||
|
|
||||||
final String accountId;
|
final String accountId;
|
||||||
|
|
||||||
|
/// True for locally-executed scripts; false for server-side (ManageSieve/JMAP).
|
||||||
|
final bool isLocal;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<SieveScriptsScreen> createState() => _SieveScriptsScreenState();
|
ConsumerState<SieveScriptsScreen> createState() => _SieveScriptsScreenState();
|
||||||
}
|
}
|
||||||
@@ -21,6 +28,10 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
String? _error;
|
String? _error;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
|
||||||
|
String get _editRoute => widget.isLocal
|
||||||
|
? '/accounts/${widget.accountId}/sieve/local/edit'
|
||||||
|
: '/accounts/${widget.accountId}/sieve/edit';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -33,8 +44,13 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final scripts =
|
final scripts = widget.isLocal
|
||||||
await ref.read(sieveRepositoryProvider).listScripts(widget.accountId);
|
? await ref
|
||||||
|
.read(localSieveRepositoryProvider)
|
||||||
|
.listScripts(widget.accountId)
|
||||||
|
: await ref
|
||||||
|
.read(sieveRepositoryProvider)
|
||||||
|
.listScripts(widget.accountId);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_scripts = scripts;
|
_scripts = scripts;
|
||||||
@@ -53,15 +69,24 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
|
|
||||||
Future<void> _activate(SieveScript script) async {
|
Future<void> _activate(SieveScript script) async {
|
||||||
try {
|
try {
|
||||||
await ref
|
if (widget.isLocal) {
|
||||||
.read(sieveRepositoryProvider)
|
await ref
|
||||||
.activateScript(widget.accountId, script.id);
|
.read(localSieveRepositoryProvider)
|
||||||
|
.activateScript(widget.accountId, script.id);
|
||||||
|
} else {
|
||||||
|
await ref
|
||||||
|
.read(sieveRepositoryProvider)
|
||||||
|
.activateScript(widget.accountId, script.id);
|
||||||
|
}
|
||||||
await _load();
|
await _load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(
|
||||||
).showSnackBar(SnackBar(content: Text('Failed to activate: $e')));
|
duration: const Duration(seconds: 5),
|
||||||
|
content: Text('Failed to activate: $e'),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,15 +111,24 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
);
|
);
|
||||||
if (!(confirmed ?? false) || !mounted) return;
|
if (!(confirmed ?? false) || !mounted) return;
|
||||||
try {
|
try {
|
||||||
await ref
|
if (widget.isLocal) {
|
||||||
.read(sieveRepositoryProvider)
|
await ref
|
||||||
.deleteScript(widget.accountId, script.id);
|
.read(localSieveRepositoryProvider)
|
||||||
|
.deleteScript(widget.accountId, script.id);
|
||||||
|
} else {
|
||||||
|
await ref
|
||||||
|
.read(sieveRepositoryProvider)
|
||||||
|
.deleteScript(widget.accountId, script.id);
|
||||||
|
}
|
||||||
await _load();
|
await _load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(
|
||||||
).showSnackBar(SnackBar(content: Text('Failed to delete: $e')));
|
duration: const Duration(seconds: 5),
|
||||||
|
content: Text('Failed to delete: $e'),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,11 +136,15 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Email filters')),
|
appBar: AppBar(
|
||||||
|
title: Text(
|
||||||
|
widget.isLocal ? 'Local Filters' : 'Remote Filters',
|
||||||
|
),
|
||||||
|
),
|
||||||
body: _buildBody(),
|
body: _buildBody(),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await context.push('/accounts/${widget.accountId}/sieve/edit');
|
await context.push(_editRoute);
|
||||||
await _load();
|
await _load();
|
||||||
},
|
},
|
||||||
child: const Icon(Icons.add),
|
child: const Icon(Icons.add),
|
||||||
@@ -134,22 +172,69 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final scripts = _scripts ?? [];
|
final scripts = _scripts ?? [];
|
||||||
if (scripts.isEmpty) {
|
return Column(
|
||||||
return const Center(
|
children: [
|
||||||
child: Text('No Sieve scripts. Tap + to create one.'),
|
_SieveSourceBanner(isLocal: widget.isLocal),
|
||||||
);
|
Expanded(
|
||||||
}
|
child: scripts.isEmpty
|
||||||
return RefreshIndicator(
|
? const Center(
|
||||||
onRefresh: _load,
|
child: Text('No filters yet. Tap + to create one.'),
|
||||||
child: ListView.builder(
|
)
|
||||||
itemCount: scripts.length,
|
: RefreshIndicator(
|
||||||
itemBuilder: (ctx, i) => _ScriptTile(
|
onRefresh: _load,
|
||||||
script: scripts[i],
|
child: ListView.builder(
|
||||||
accountId: widget.accountId,
|
itemCount: scripts.length,
|
||||||
onActivate: () => _activate(scripts[i]),
|
itemBuilder: (ctx, i) => _ScriptTile(
|
||||||
onDelete: () => _delete(scripts[i]),
|
script: scripts[i],
|
||||||
onEdited: _load,
|
accountId: widget.accountId,
|
||||||
|
editRoute: _editRoute,
|
||||||
|
onActivate: () => _activate(scripts[i]),
|
||||||
|
onDelete: () => _delete(scripts[i]),
|
||||||
|
onEdited: _load,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SieveSourceBanner extends StatelessWidget {
|
||||||
|
const _SieveSourceBanner({required this.isLocal});
|
||||||
|
|
||||||
|
final bool isLocal;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final text = isLocal
|
||||||
|
? 'Local Filters run Sieve scripts directly on this device. '
|
||||||
|
'Remote Filters, which run on the mail server, are configured separately.'
|
||||||
|
: 'Remote Filters run Sieve scripts on the mail server '
|
||||||
|
'(ManageSieve or JMAP). '
|
||||||
|
'Local Filters, which run on this device, are configured separately.';
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isLocal ? Icons.phone_android : Icons.dns,
|
||||||
|
size: 18,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -159,6 +244,7 @@ class _ScriptTile extends StatelessWidget {
|
|||||||
const _ScriptTile({
|
const _ScriptTile({
|
||||||
required this.script,
|
required this.script,
|
||||||
required this.accountId,
|
required this.accountId,
|
||||||
|
required this.editRoute,
|
||||||
required this.onActivate,
|
required this.onActivate,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
required this.onEdited,
|
required this.onEdited,
|
||||||
@@ -166,6 +252,7 @@ class _ScriptTile extends StatelessWidget {
|
|||||||
|
|
||||||
final SieveScript script;
|
final SieveScript script;
|
||||||
final String accountId;
|
final String accountId;
|
||||||
|
final String editRoute;
|
||||||
final VoidCallback onActivate;
|
final VoidCallback onActivate;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
final VoidCallback onEdited;
|
final VoidCallback onEdited;
|
||||||
@@ -183,10 +270,7 @@ class _ScriptTile extends StatelessWidget {
|
|||||||
onSelected: (action) async {
|
onSelected: (action) async {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case _ScriptAction.edit:
|
case _ScriptAction.edit:
|
||||||
await context.push(
|
await context.push(editRoute, extra: script);
|
||||||
'/accounts/$accountId/sieve/edit',
|
|
||||||
extra: script,
|
|
||||||
);
|
|
||||||
onEdited();
|
onEdited();
|
||||||
case _ScriptAction.activate:
|
case _ScriptAction.activate:
|
||||||
onActivate();
|
onActivate();
|
||||||
@@ -209,7 +293,7 @@ class _ScriptTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await context.push('/accounts/$accountId/sieve/edit', extra: script);
|
await context.push(editRoute, extra: script);
|
||||||
onEdited();
|
onEdited();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import 'package:sharedinbox/di.dart';
|
|||||||
|
|
||||||
final _timeFmt = DateFormat('MMM d, HH:mm:ss');
|
final _timeFmt = DateFormat('MMM d, HH:mm:ss');
|
||||||
|
|
||||||
|
String _fmtDuration(Duration d) {
|
||||||
|
final ms = d.inMilliseconds;
|
||||||
|
return ms < 1000 ? '${ms}ms' : '${(ms / 1000).toStringAsFixed(1)}s';
|
||||||
|
}
|
||||||
|
|
||||||
String _fmtBytes(int bytes) {
|
String _fmtBytes(int bytes) {
|
||||||
if (bytes <= 0) return '0 B';
|
if (bytes <= 0) return '0 B';
|
||||||
if (bytes < 1024) return '$bytes B';
|
if (bytes < 1024) return '$bytes B';
|
||||||
@@ -104,9 +109,7 @@ class _SyncLogTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final ms = entry.duration.inMilliseconds;
|
final durationLabel = _fmtDuration(entry.duration);
|
||||||
final durationLabel =
|
|
||||||
ms < 1000 ? '${ms}ms' : '${(ms / 1000).toStringAsFixed(1)}s';
|
|
||||||
final proto =
|
final proto =
|
||||||
entry.protocol.isEmpty ? '' : ' · ${entry.protocol.toUpperCase()}';
|
entry.protocol.isEmpty ? '' : ' · ${entry.protocol.toUpperCase()}';
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -154,7 +157,10 @@ class _SyncLogTile extends StatelessWidget {
|
|||||||
for (final m in entry.mailboxStats)
|
for (final m in entry.mailboxStats)
|
||||||
_row(
|
_row(
|
||||||
' ${m.mailboxPath}',
|
' ${m.mailboxPath}',
|
||||||
'${m.fetched} new · ${m.skipped} up-to-date',
|
[
|
||||||
|
'${m.fetched} new · ${m.skipped} up-to-date',
|
||||||
|
if (m.duration != null) _fmtDuration(m.duration!),
|
||||||
|
].join(' · '),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (entry.errorMessage != null)
|
if (entry.errorMessage != null)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_html/flutter_html.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
@@ -10,6 +9,7 @@ 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/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';
|
||||||
|
|
||||||
final _dateFmt = DateFormat('EEE, MMM d, HH:mm');
|
final _dateFmt = DateFormat('EEE, MMM d, HH:mm');
|
||||||
|
|
||||||
@@ -163,11 +163,9 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
setState(() => _loadRemoteImages = true),
|
setState(() => _loadRemoteImages = true),
|
||||||
),
|
),
|
||||||
Html(
|
SecureEmailWebView(
|
||||||
data: body.htmlBody!,
|
htmlBody: body.htmlBody!,
|
||||||
extensions: [
|
loadRemoteImages: _loadRemoteImages,
|
||||||
if (!_loadRemoteImages) _BlockRemoteImagesExtension(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
] else
|
] else
|
||||||
SelectableText(
|
SelectableText(
|
||||||
@@ -248,6 +246,7 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (!mounted) return;
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
// Fetch data first for IMAP undo support
|
// Fetch data first for IMAP undo support
|
||||||
@@ -255,6 +254,7 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
|
|
||||||
final destPath = await repo.deleteEmail(widget.email.id);
|
final destPath = await repo.deleteEmail(widget.email.id);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
if (original != null) {
|
if (original != null) {
|
||||||
unawaited(
|
unawaited(
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
@@ -273,19 +273,3 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BlockRemoteImagesExtension extends HtmlExtension {
|
|
||||||
@override
|
|
||||||
Set<String> get supportedTags => {'img'};
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool matches(ExtensionContext context) {
|
|
||||||
if (context.elementName != 'img') return false;
|
|
||||||
final src = context.attributes['src'] ?? '';
|
|
||||||
return src.startsWith('http://') || src.startsWith('https://');
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
InlineSpan build(ExtensionContext context) =>
|
|
||||||
const WidgetSpan(child: SizedBox.shrink());
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -86,7 +86,12 @@ class _UndoActionTile extends ConsumerWidget {
|
|||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(const SnackBar(content: Text('Action undone.')));
|
).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text('Action undone.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Undo'),
|
child: const Text('Undo'),
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
|
||||||
|
final _dateFmt = DateFormat('MMM d');
|
||||||
|
|
||||||
|
/// A flat list tile for an individual [email].
|
||||||
|
///
|
||||||
|
/// Used in search-result lists and the per-mailbox search overlay.
|
||||||
|
/// Pass a custom [leading] widget to support selection-mode checkboxes.
|
||||||
|
class EmailTile extends StatelessWidget {
|
||||||
|
const EmailTile({
|
||||||
|
super.key,
|
||||||
|
required this.email,
|
||||||
|
required this.onTap,
|
||||||
|
this.leading,
|
||||||
|
this.selected = false,
|
||||||
|
this.onLongPress,
|
||||||
|
this.showLocation = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Email email;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final Widget? leading;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback? onLongPress;
|
||||||
|
|
||||||
|
/// When true, appends `accountId • mailboxPath` as a second subtitle line.
|
||||||
|
final bool showLocation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final sender = email.from.isNotEmpty
|
||||||
|
? (email.from.first.name ?? email.from.first.email)
|
||||||
|
: '(unknown)';
|
||||||
|
final date = email.sentAt != null ? _dateFmt.format(email.sentAt!) : '';
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: leading ??
|
||||||
|
Icon(
|
||||||
|
email.isSeen ? Icons.mail_outline : Icons.mail,
|
||||||
|
color: email.isSeen ? null : Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
sender,
|
||||||
|
style:
|
||||||
|
email.isSeen ? null : const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
email.subject ?? '(no subject)',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (showLocation)
|
||||||
|
Text(
|
||||||
|
'${email.accountId} • ${email.mailboxPath}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: date.isEmpty
|
||||||
|
? null
|
||||||
|
: Text(date, style: Theme.of(context).textTheme.bodySmall),
|
||||||
|
selected: selected,
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,13 +70,21 @@ class FolderDrawer extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.filter_list),
|
leading: const Icon(Icons.dns),
|
||||||
title: const Text('Email filters'),
|
title: const Text('Remote Filters'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
unawaited(context.push('/accounts/$accountId/sieve'));
|
unawaited(context.push('/accounts/$accountId/sieve'));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.phone_android),
|
||||||
|
title: const Text('Local Filters'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
unawaited(context.push('/accounts/$accountId/sieve/local'));
|
||||||
|
},
|
||||||
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: StreamBuilder(
|
child: StreamBuilder(
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
|
/// Builds the full HTML document string for rendering an email body.
|
||||||
|
///
|
||||||
|
/// Forces `color-scheme: light` so that emails with black text remain readable
|
||||||
|
/// when the device is in dark mode — the WebView would otherwise apply a dark
|
||||||
|
/// background while leaving the email's own text colours unchanged.
|
||||||
|
@visibleForTesting
|
||||||
|
String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
|
||||||
|
final imgSrc = loadRemoteImages ? 'https: http: data: blob:' : 'data: blob:';
|
||||||
|
// script-src 'none' blocks page scripts; JS mode stays unrestricted so the
|
||||||
|
// controller can call runJavaScriptReturningResult for height measurement.
|
||||||
|
const cspBase = "default-src 'none'; "
|
||||||
|
"style-src 'unsafe-inline'; "
|
||||||
|
"script-src 'none'; "
|
||||||
|
"object-src 'none'; "
|
||||||
|
"font-src 'none'";
|
||||||
|
final csp = '$cspBase; img-src $imgSrc';
|
||||||
|
|
||||||
|
return '''<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="$csp">
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; color-scheme: light; background-color: #ffffff; color: #000000; }
|
||||||
|
img { max-width: 100%; height: auto; }
|
||||||
|
a { color: #1976D2; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
$htmlBody
|
||||||
|
</body>
|
||||||
|
</html>''';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders an HTML email body securely.
|
||||||
|
///
|
||||||
|
/// On Android the content is displayed in a WebView with JavaScript blocked
|
||||||
|
/// via CSP and remote images blocked until the user opts in. Link taps show
|
||||||
|
/// a confirmation dialog that highlights the registered domain to aid phishing
|
||||||
|
/// detection.
|
||||||
|
///
|
||||||
|
/// On Linux (where webview_flutter has no platform support) the HTML is
|
||||||
|
/// converted to plain text and shown in a [SelectableText] widget.
|
||||||
|
class SecureEmailWebView extends StatefulWidget {
|
||||||
|
const SecureEmailWebView({
|
||||||
|
super.key,
|
||||||
|
required this.htmlBody,
|
||||||
|
this.loadRemoteImages = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String htmlBody;
|
||||||
|
final bool loadRemoteImages;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SecureEmailWebView> createState() => _SecureEmailWebViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
||||||
|
// Null on Linux where WebView is unavailable.
|
||||||
|
WebViewController? _controller;
|
||||||
|
double _height = 300;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (!Platform.isLinux) {
|
||||||
|
final c = WebViewController();
|
||||||
|
unawaited(c.setJavaScriptMode(JavaScriptMode.unrestricted));
|
||||||
|
unawaited(c.setBackgroundColor(Colors.transparent));
|
||||||
|
unawaited(
|
||||||
|
c.setNavigationDelegate(
|
||||||
|
NavigationDelegate(
|
||||||
|
onNavigationRequest: _handleNavigation,
|
||||||
|
onPageFinished: _measureHeight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
unawaited(c.loadHtmlString(_buildHtml()));
|
||||||
|
_controller = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(SecureEmailWebView old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (old.htmlBody != widget.htmlBody ||
|
||||||
|
old.loadRemoteImages != widget.loadRemoteImages) {
|
||||||
|
if (_controller != null) {
|
||||||
|
unawaited(_controller!.loadHtmlString(_buildHtml()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildHtml() => buildEmailHtml(
|
||||||
|
widget.htmlBody,
|
||||||
|
loadRemoteImages: widget.loadRemoteImages,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _measureHeight(String _) async {
|
||||||
|
final result = await _controller!.runJavaScriptReturningResult(
|
||||||
|
'document.documentElement.scrollHeight',
|
||||||
|
);
|
||||||
|
final h = double.tryParse(result.toString());
|
||||||
|
if (h != null && h > 0 && mounted) {
|
||||||
|
setState(() => _height = h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigationDecision _handleNavigation(NavigationRequest req) {
|
||||||
|
final url = req.url;
|
||||||
|
if (url == 'about:blank' || url.startsWith('data:')) {
|
||||||
|
return NavigationDecision.navigate;
|
||||||
|
}
|
||||||
|
unawaited(_showLinkDialog(url));
|
||||||
|
return NavigationDecision.prevent;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showLinkDialog(String url) async {
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null || !mounted) return;
|
||||||
|
|
||||||
|
final host = uri.host;
|
||||||
|
final parts = host.split('.');
|
||||||
|
// Bold the registered domain (last two DNS labels) to aid phishing detection.
|
||||||
|
final boldStart = (parts.length >= 2
|
||||||
|
? host.length -
|
||||||
|
parts.last.length -
|
||||||
|
1 -
|
||||||
|
parts[parts.length - 2].length
|
||||||
|
: 0)
|
||||||
|
.clamp(0, host.length);
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Open link?'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||||
|
children: [
|
||||||
|
TextSpan(text: host.substring(0, boldStart)),
|
||||||
|
TextSpan(
|
||||||
|
text: host.substring(boldStart),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
url,
|
||||||
|
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('Open in browser'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true && mounted) {
|
||||||
|
final launched =
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
if (!launched && mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Could not open: $url')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Linux has no webview_flutter platform implementation — show plain text.
|
||||||
|
if (Platform.isLinux) {
|
||||||
|
return SelectableText(
|
||||||
|
htmlToPlain(widget.htmlBody),
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return SizedBox(
|
||||||
|
height: _height,
|
||||||
|
child: WebViewWidget(controller: _controller!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,12 @@ class UndoShell extends ConsumerWidget {
|
|||||||
ref.listen<List<UndoAction>>(undoServiceProvider, (previous, next) {
|
ref.listen<List<UndoAction>>(undoServiceProvider, (previous, next) {
|
||||||
if (next.isNotEmpty &&
|
if (next.isNotEmpty &&
|
||||||
(previous == null || previous.length < next.length)) {
|
(previous == null || previous.length < next.length)) {
|
||||||
_showUndoSnackbar(context, ref, next.last);
|
final action = next.last;
|
||||||
|
// Don't show a snackbar for actions loaded from persistence on app
|
||||||
|
// startup — only for actions pushed in this session.
|
||||||
|
if (DateTime.now().difference(action.timestamp).inSeconds < 30) {
|
||||||
|
_showUndoSnackbar(context, ref, action);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,6 +34,7 @@ class UndoShell extends ConsumerWidget {
|
|||||||
scaffoldMessenger.clearSnackBars();
|
scaffoldMessenger.clearSnackBars();
|
||||||
scaffoldMessenger.showSnackBar(
|
scaffoldMessenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
content: Text(
|
content: Text(
|
||||||
action.type == UndoType.delete
|
action.type == UndoType.delete
|
||||||
? '${action.emailIds.length} email(s) moved to Trash'
|
? '${action.emailIds.length} email(s) moved to Trash'
|
||||||
|
|||||||
+1329
File diff suppressed because it is too large
Load Diff
+26
-7
@@ -40,10 +40,27 @@ dependencies:
|
|||||||
open_filex: ^4.6.0
|
open_filex: ^4.6.0
|
||||||
mime: ^2.0.0
|
mime: ^2.0.0
|
||||||
|
|
||||||
|
# QR code generation for account sharing
|
||||||
|
qr_flutter: ^4.1.0
|
||||||
|
|
||||||
|
# Public-key encryption for secure account sharing (ECIES: X25519 + AES-256-GCM)
|
||||||
|
cryptography: ^2.7.0
|
||||||
|
|
||||||
|
# QR code scanning (camera) for secure account import
|
||||||
|
mobile_scanner: ^5.0.0
|
||||||
|
|
||||||
# HTML rendering for email bodies
|
# HTML rendering for email bodies
|
||||||
flutter_html: ^3.0.0
|
webview_flutter: ^4.0.0
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
flutter_markdown: ^0.7.7+1
|
flutter_markdown_plus: ^1.0.7
|
||||||
|
|
||||||
|
# Background sync and local notifications
|
||||||
|
flutter_local_notifications: ^18.0.1
|
||||||
|
workmanager: ^0.9.0
|
||||||
|
|
||||||
|
# App version metadata for crash reports
|
||||||
|
package_info_plus: ^8.0.0
|
||||||
|
share_plus: ^12.0.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -56,7 +73,8 @@ dev_dependencies:
|
|||||||
test: ^1.25.0
|
test: ^1.25.0
|
||||||
mockito: ^5.4.4
|
mockito: ^5.4.4
|
||||||
fake_async: ^1.3.1
|
fake_async: ^1.3.1
|
||||||
sqlite3: any # used directly in test/unit/db_test_helper.dart
|
path_provider_platform_interface: ^2.1.2
|
||||||
|
sqlite3: ^3.1.5 # used directly in test/unit/db_test_helper.dart; 3.x required for Database.close()
|
||||||
url_launcher_platform_interface: ^2.3.2
|
url_launcher_platform_interface: ^2.3.2
|
||||||
plugin_platform_interface: ^2.1.8
|
plugin_platform_interface: ^2.1.8
|
||||||
|
|
||||||
@@ -66,7 +84,8 @@ flutter:
|
|||||||
- assets/
|
- assets/
|
||||||
|
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
# path_provider_android 2.3+ uses package:jni which crashes on startup
|
# path_provider_android 2.2.21 updated to Pigeon 26, which causes a
|
||||||
# (SIGSEGV in libdartjni.so FindClassUnchecked — JNI env not ready when
|
# channel-error on startup on some Android devices. 2.3+ uses package:jni
|
||||||
# the Dart VM first calls into it). Pin to 2.2.x which uses Pigeon instead.
|
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
|
||||||
path_provider_android: ">=2.2.0 <2.3.0"
|
# stable Pigeon and is known to work reliably.
|
||||||
|
path_provider_android: ">=2.2.0 <2.2.21"
|
||||||
|
|||||||
Executable
+598
@@ -0,0 +1,598 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
agent_loop.py — called from cron every 10 minutes.
|
||||||
|
|
||||||
|
Flow
|
||||||
|
----
|
||||||
|
1. Agent already running?
|
||||||
|
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
|
||||||
|
b. Age ≤ 1 h → print status, exit 0 (let it keep working)
|
||||||
|
2. No agent running → extract pending_issue from state (if any), then check CI
|
||||||
|
a. CI is running → save pending-ci state, exit 0
|
||||||
|
b. Latest CI failed → start fix-CI agent (preserving pending_issue), exit 0
|
||||||
|
c. CI ok + pending_issue → close the issue (CI passed), exit 0
|
||||||
|
d. CI ok (or no run yet) → find oldest Ready issue, start issue agent,
|
||||||
|
save state, exit 0
|
||||||
|
e. No Ready issues → print "nothing to do", exit 0
|
||||||
|
|
||||||
|
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
|
||||||
|
|
||||||
|
State file: ~/.sharedinbox-agent-state.json
|
||||||
|
{ "pid": 12345, "issue": 91,
|
||||||
|
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue" }
|
||||||
|
|
||||||
|
Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
|
||||||
|
Resume the Claude conversation afterward with:
|
||||||
|
|
||||||
|
claude --resume issue-91
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Cron runs with a minimal PATH; ensure Nix profile binaries (tea, claude) and ~/go/bin (fgj) are found.
|
||||||
|
os.environ["PATH"] = (
|
||||||
|
f"{Path.home()}/.nix-profile/bin"
|
||||||
|
f":{Path.home()}/go/bin"
|
||||||
|
f":{os.environ.get('PATH', '/usr/bin:/bin')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── configuration ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
REPO = "guettli/sharedinbox"
|
||||||
|
REPO_URL = f"https://codeberg.org/{REPO}"
|
||||||
|
STATE_FILE = Path.home() / ".sharedinbox-agent-state.json"
|
||||||
|
MAX_AGENT_AGE_SECONDS = 3600 # 1 hour
|
||||||
|
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" / (
|
||||||
|
"-" + str(Path.home())[1:].replace("/", "-")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Labels used by the workflow.
|
||||||
|
LABEL_READY = "State/Ready"
|
||||||
|
LABEL_IN_PROGRESS = "State/InProgress"
|
||||||
|
LABEL_QUESTION = "State/Question"
|
||||||
|
LABEL_PRIO_HIGH = "Prio/High"
|
||||||
|
|
||||||
|
# Only pick up issues filed by these accounts.
|
||||||
|
ALLOWED_ISSUE_AUTHORS = {"guettli", "guettlibot", "guettlibot2"}
|
||||||
|
|
||||||
|
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_url(number: int) -> str:
|
||||||
|
return f"{REPO_URL}/issues/{number}"
|
||||||
|
|
||||||
|
|
||||||
|
def _ci_run_url(run_id: int) -> str:
|
||||||
|
return f"{REPO_URL}/actions/runs/{run_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fgj(*args: str) -> None:
|
||||||
|
"""Run a fgj command, raising on failure."""
|
||||||
|
cmd = ["fgj", "--hostname", "codeberg.org", *args]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"fgj {' '.join(args)} failed:\n{result.stderr or result.stdout}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tea_get(path: str) -> dict | list | None:
|
||||||
|
"""Run a tea api GET and return parsed JSON. Only use for reads — tea PATCH/PUT
|
||||||
|
silently fails (exits 0) when unauthenticated, so writes must go via fgj."""
|
||||||
|
cmd = ["tea", "api", path]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"tea api {path} failed:\n{result.stderr or result.stdout}"
|
||||||
|
)
|
||||||
|
out = result.stdout.strip()
|
||||||
|
if not out:
|
||||||
|
return None
|
||||||
|
data = json.loads(out)
|
||||||
|
if isinstance(data, dict) and "message" in data and "url" in data:
|
||||||
|
raise RuntimeError(f"tea api {path} returned error: {data['message']}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _set_labels(issue: int, add: list[str], remove: list[str]) -> None:
|
||||||
|
"""Add/remove labels on an issue via fgj."""
|
||||||
|
cmd = ["issue", "edit", str(issue), "--repo", REPO]
|
||||||
|
for label in add:
|
||||||
|
cmd += ["--add-label", label]
|
||||||
|
for label in remove:
|
||||||
|
cmd += ["--remove-label", label]
|
||||||
|
_fgj(*cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def _close_issue(issue: int) -> None:
|
||||||
|
_fgj("issue", "close", str(issue), "--repo", REPO)
|
||||||
|
_set_labels(issue, add=[], remove=[LABEL_IN_PROGRESS])
|
||||||
|
|
||||||
|
|
||||||
|
def _comment_issue(issue: int, body: str) -> None:
|
||||||
|
_fgj("issue", "comment", str(issue), "--repo", REPO, "--body", body)
|
||||||
|
|
||||||
|
|
||||||
|
def _ready_issues() -> list[dict]:
|
||||||
|
"""Return open issues with State/Ready, Prio/High first, then oldest."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["fgj", "--hostname", "codeberg.org", "issue", "list",
|
||||||
|
"--repo", REPO, "--state", "open", "--json"],
|
||||||
|
capture_output=True, text=True, check=True,
|
||||||
|
)
|
||||||
|
data = json.loads(result.stdout) if result.stdout.strip() else []
|
||||||
|
ready = [
|
||||||
|
i for i in data
|
||||||
|
if any(lbl["name"] == LABEL_READY for lbl in i.get("labels", []))
|
||||||
|
and i.get("user", {}).get("login", "") in ALLOWED_ISSUE_AUTHORS
|
||||||
|
]
|
||||||
|
ready.sort(key=lambda i: (
|
||||||
|
0 if any(lbl["name"] == LABEL_PRIO_HIGH for lbl in i.get("labels", [])) else 1,
|
||||||
|
i["number"],
|
||||||
|
))
|
||||||
|
return ready
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_ci_run() -> dict | None:
|
||||||
|
data = _tea_get(f"repos/{REPO}/actions/runs?limit=1")
|
||||||
|
runs = (data or {}).get("workflow_runs", [])
|
||||||
|
return runs[0] if runs else None
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
||||||
|
"""Return the latest CI run for a specific branch, or None.
|
||||||
|
|
||||||
|
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
|
||||||
|
else:
|
||||||
|
if run.get("prettyref") == branch:
|
||||||
|
return run
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_pr_for_branch(branch: str) -> dict | None:
|
||||||
|
"""Return the first open PR whose head branch matches, or None."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["fgj", "--hostname", "codeberg.org", "pr", "list",
|
||||||
|
"--repo", REPO, "--state", "open", "--json"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0 or not result.stdout.strip():
|
||||||
|
return None
|
||||||
|
prs = json.loads(result.stdout)
|
||||||
|
for pr in prs:
|
||||||
|
head = pr.get("head", {})
|
||||||
|
ref = head.get("ref") or head.get("label", "").split(":")[-1]
|
||||||
|
if ref == branch:
|
||||||
|
return pr
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_pr(pr_number: int) -> None:
|
||||||
|
"""Squash-merge a PR via fgj."""
|
||||||
|
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
|
||||||
|
|
||||||
|
|
||||||
|
# ── state file ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _read_state() -> dict | None:
|
||||||
|
if STATE_FILE.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(STATE_FILE.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_state(pid: int | None, issue: int | None, kind: str, issue_title: str | None = None, session_name: str | None = None, ci_run_id: int | None = None) -> None:
|
||||||
|
data: dict = {
|
||||||
|
"pid": pid,
|
||||||
|
"issue": issue,
|
||||||
|
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"type": kind,
|
||||||
|
}
|
||||||
|
if issue_title is not None:
|
||||||
|
data["issue_title"] = issue_title
|
||||||
|
if session_name is not None:
|
||||||
|
data["session_name"] = session_name
|
||||||
|
if ci_run_id is not None:
|
||||||
|
data["ci_run_id_at_start"] = ci_run_id
|
||||||
|
STATE_FILE.write_text(json.dumps(data, indent=2))
|
||||||
|
STATE_FILE.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_state() -> None:
|
||||||
|
STATE_FILE.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── agent launcher ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _start_agent(prompt: str, session_name: str) -> int:
|
||||||
|
"""Start Claude Code as a detached background process and return its PID."""
|
||||||
|
log_dir = Path.home() / ".sharedinbox-agent-logs"
|
||||||
|
log_dir.mkdir(mode=0o700, exist_ok=True)
|
||||||
|
log_dir.chmod(0o700) # fix permissions if dir already existed with wrong mode
|
||||||
|
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||||
|
log_file = log_dir / f"{session_name}-{ts}.log"
|
||||||
|
|
||||||
|
log_fh = open(log_file, "w", opener=lambda p, f: os.open(p, f, 0o600))
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"claude",
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
|
"--name", session_name,
|
||||||
|
"-p", prompt,
|
||||||
|
],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=log_fh,
|
||||||
|
stderr=log_fh,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
log_fh.close() # Parent closes its copy; the child retains the fd.
|
||||||
|
# Answer the workspace-trust dialog; after this the pipe hits EOF.
|
||||||
|
proc.stdin.write(b"\n")
|
||||||
|
proc.stdin.close()
|
||||||
|
|
||||||
|
print(f"Started agent pid={proc.pid}, log={log_file}")
|
||||||
|
print(f" Resume: claude --resume {shlex.quote(session_name)}")
|
||||||
|
return proc.pid
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_alive(state: dict) -> bool:
|
||||||
|
"""Return True if the agent process is still running."""
|
||||||
|
pid = state.get("pid")
|
||||||
|
if pid is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_age_seconds(state: dict) -> float:
|
||||||
|
"""Seconds elapsed since the agent was launched, from the state file timestamp."""
|
||||||
|
try:
|
||||||
|
started_at = datetime.fromisoformat(state["started_at"])
|
||||||
|
return (datetime.now(timezone.utc) - started_at).total_seconds()
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _git_summary() -> str:
|
||||||
|
"""Return a one-line summary of the latest commit and whether it's been pushed."""
|
||||||
|
try:
|
||||||
|
commit = subprocess.run(
|
||||||
|
["git", "log", "--oneline", "-1"],
|
||||||
|
capture_output=True, text=True, check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
ahead = subprocess.run(
|
||||||
|
["git", "rev-list", "--count", "HEAD@{u}..HEAD"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if ahead.returncode == 0 and ahead.stdout.strip() != "0":
|
||||||
|
push_status = f"not pushed ({ahead.stdout.strip()} ahead)"
|
||||||
|
elif ahead.returncode == 0:
|
||||||
|
push_status = "pushed"
|
||||||
|
else:
|
||||||
|
push_status = "no upstream"
|
||||||
|
return f"{commit} [{push_status}]"
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_agent(state: dict) -> None:
|
||||||
|
"""Forcefully stop the running agent."""
|
||||||
|
pid = state.get("pid")
|
||||||
|
if pid:
|
||||||
|
try:
|
||||||
|
os.kill(pid, 9)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── subcommands ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list() -> int:
|
||||||
|
"""List recent agent-loop sessions, newest first."""
|
||||||
|
if not CLAUDE_PROJECTS_DIR.exists():
|
||||||
|
print(f"No sessions found (directory missing: {CLAUDE_PROJECTS_DIR})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
sessions = []
|
||||||
|
for jsonl in CLAUDE_PROJECTS_DIR.glob("*.jsonl"):
|
||||||
|
agent_name = None
|
||||||
|
session_id = None
|
||||||
|
try:
|
||||||
|
with jsonl.open() as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
d = json.loads(line)
|
||||||
|
if d.get("type") == "agent-name":
|
||||||
|
agent_name = d.get("agentName")
|
||||||
|
session_id = d.get("sessionId")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if agent_name:
|
||||||
|
sessions.append((jsonl.stat().st_mtime, agent_name, session_id))
|
||||||
|
|
||||||
|
if not sessions:
|
||||||
|
print("No agent sessions found.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
sessions.sort(reverse=True)
|
||||||
|
total = len(sessions)
|
||||||
|
print(f" {'DATE':<16} {'NAME':<20} UUID (use with: claude --resume <uuid>)")
|
||||||
|
print(f" {'-'*16} {'-'*20} {'-'*36}")
|
||||||
|
for mtime, name, sid in sessions[:20]:
|
||||||
|
ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
|
||||||
|
print(f" {ts:<16} {name:<20} {sid}")
|
||||||
|
if total > 20:
|
||||||
|
print(f" ... ({total - 20} more)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── main flow ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _run_loop() -> int:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
print(f"---------------------- Starting {now.strftime('%Y-%m-%d %H:%MZ')}")
|
||||||
|
|
||||||
|
state = _read_state()
|
||||||
|
|
||||||
|
# ── 1. Agent already running? ─────────────────────────────────────────────
|
||||||
|
if state and _agent_alive(state):
|
||||||
|
age = _agent_age_seconds(state)
|
||||||
|
issue = state.get("issue")
|
||||||
|
kind = state.get("type", "issue")
|
||||||
|
pid = state.get("pid", "?")
|
||||||
|
|
||||||
|
issue_title = state.get("issue_title", "")
|
||||||
|
issue_ref = (
|
||||||
|
f"{_issue_url(issue)} {issue_title}".strip() if issue else str(issue)
|
||||||
|
)
|
||||||
|
|
||||||
|
if age > MAX_AGENT_AGE_SECONDS:
|
||||||
|
print(
|
||||||
|
f"Agent pid={pid!r} ({issue_ref}) "
|
||||||
|
f"has been running for {age/60:.0f} min — aborting."
|
||||||
|
)
|
||||||
|
_kill_agent(state)
|
||||||
|
_clear_state()
|
||||||
|
if issue:
|
||||||
|
_set_labels(issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||||
|
_comment_issue(
|
||||||
|
issue,
|
||||||
|
f"Agent (pid {pid}) was killed after running for {age/60:.0f} min "
|
||||||
|
f"(limit: {MAX_AGENT_AGE_SECONDS//60} min). "
|
||||||
|
"Please investigate and resume manually.",
|
||||||
|
)
|
||||||
|
print(f"Set {_issue_url(issue)} to State/Question.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
session_name = state.get("session_name")
|
||||||
|
resume_cmd = f"claude --resume {shlex.quote(session_name)}" if session_name else ""
|
||||||
|
git_info = _git_summary()
|
||||||
|
parts = [
|
||||||
|
f"Agent pid={pid!r} ({kind}, {issue_ref}) still running ({age/60:.0f} min). Waiting.",
|
||||||
|
]
|
||||||
|
if resume_cmd:
|
||||||
|
parts.append(f" Resume: {resume_cmd}")
|
||||||
|
if git_info:
|
||||||
|
parts.append(f" Commit: {git_info}")
|
||||||
|
print("\n".join(parts))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Agent not running (or no state) — extract any pending issue, then clean up.
|
||||||
|
pending_issue: int | None = None
|
||||||
|
ci_run_id_at_start: int | None = None
|
||||||
|
if state:
|
||||||
|
pending_issue = state.get("issue")
|
||||||
|
ci_run_id_at_start = state.get("ci_run_id_at_start")
|
||||||
|
_clear_state()
|
||||||
|
|
||||||
|
# ── 2. Check for a PR opened by the agent ────────────────────────────────
|
||||||
|
if pending_issue:
|
||||||
|
branch = f"issue-{pending_issue}-fix"
|
||||||
|
pr = _find_pr_for_branch(branch)
|
||||||
|
if pr:
|
||||||
|
pr_number = pr["number"]
|
||||||
|
pr_url = f"{REPO_URL}/pulls/{pr_number}"
|
||||||
|
print(f"Found PR #{pr_number} ({pr_url}) for issue #{pending_issue}.")
|
||||||
|
pr_run = _latest_ci_run_for_branch(branch)
|
||||||
|
|
||||||
|
if pr_run and pr_run.get("status") == "running":
|
||||||
|
print(f"CI run {_ci_run_url(pr_run['id'])} on branch {branch!r} is running. Waiting.")
|
||||||
|
_write_state(None, pending_issue, "pending-ci")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if pr_run and pr_run.get("status") in ("failure", "error"):
|
||||||
|
print(f"CI run {_ci_run_url(pr_run['id'])} on branch {branch!r} failed — starting fix agent.")
|
||||||
|
prompt = (
|
||||||
|
f"The Codeberg CI for guettli/sharedinbox just failed on branch {branch!r} "
|
||||||
|
f"(PR #{pr_number}). "
|
||||||
|
f"CI run: {_ci_run_url(pr_run['id'])}. "
|
||||||
|
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
|
||||||
|
"Identify the failure, fix it, commit, and push to the same branch. "
|
||||||
|
"Do NOT push to main, do NOT close the issue, do NOT merge the PR. "
|
||||||
|
"Verify locally with 'task check' before pushing. "
|
||||||
|
"When done, stop."
|
||||||
|
)
|
||||||
|
session_name = f"ci-fix-pr-{pr_number}"
|
||||||
|
pid = _start_agent(prompt, session_name)
|
||||||
|
_write_state(pid, pending_issue, "ci-fix", session_name=session_name)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not pr_run:
|
||||||
|
# No CI run yet — might be that CI hasn't triggered yet.
|
||||||
|
# Wait up to 15 min before giving up.
|
||||||
|
pr_created_at = pr.get("created_at", "")
|
||||||
|
try:
|
||||||
|
created = datetime.fromisoformat(pr_created_at.replace("Z", "+00:00"))
|
||||||
|
age_s = (datetime.now(timezone.utc) - created).total_seconds()
|
||||||
|
except Exception:
|
||||||
|
age_s = 999999
|
||||||
|
if age_s < 900:
|
||||||
|
print(
|
||||||
|
f"PR #{pr_number} has no CI run yet (created {age_s/60:.0f} min ago). Waiting."
|
||||||
|
)
|
||||||
|
_write_state(None, pending_issue, "pending-ci")
|
||||||
|
return 0
|
||||||
|
print(
|
||||||
|
f"No CI run for branch {branch!r} after {age_s/60:.0f} min — "
|
||||||
|
"agent may not have pushed. Setting to State/Question."
|
||||||
|
)
|
||||||
|
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||||
|
_comment_issue(
|
||||||
|
pending_issue,
|
||||||
|
f"Agent opened PR #{pr_number} but no CI run appeared on branch `{branch}` "
|
||||||
|
f"after {age_s/60:.0f} min. The agent may not have pushed any commits. "
|
||||||
|
"Please investigate and resume manually.",
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# CI passed on the PR branch — squash-merge and close.
|
||||||
|
print(f"CI passed on branch {branch!r} — merging PR #{pr_number}.")
|
||||||
|
_merge_pr(pr_number)
|
||||||
|
_close_issue(pending_issue)
|
||||||
|
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ── 3. Global CI check (agent pushed to main, or no pending issue) ────────
|
||||||
|
run = _latest_ci_run()
|
||||||
|
|
||||||
|
if run and run.get("status") == "running":
|
||||||
|
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
|
||||||
|
if pending_issue:
|
||||||
|
_write_state(None, pending_issue, "pending-ci")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if run and run.get("status") in ("failure", "error"):
|
||||||
|
print(f"CI run {_ci_run_url(run['id'])} failed — starting fix agent.")
|
||||||
|
prompt = (
|
||||||
|
"The Codeberg CI for guettli/sharedinbox just failed. "
|
||||||
|
f"The CI run ID is {run['id']}. "
|
||||||
|
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
|
||||||
|
"Identify the failure, fix it, commit, and push. "
|
||||||
|
"Verify locally with 'task check' before pushing. "
|
||||||
|
"When done, stop."
|
||||||
|
)
|
||||||
|
pid = _start_agent(prompt, "ci-fix")
|
||||||
|
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# CI is ok (or no run).
|
||||||
|
if pending_issue:
|
||||||
|
latest_run_id = run["id"] if run else None
|
||||||
|
if ci_run_id_at_start is not None and latest_run_id == ci_run_id_at_start:
|
||||||
|
# CI run hasn't changed since the agent was launched → agent pushed nothing
|
||||||
|
# (likely crashed or hit a rate limit).
|
||||||
|
print(
|
||||||
|
f"No new CI run since agent started for {_issue_url(pending_issue)} "
|
||||||
|
f"(run id {latest_run_id}) — agent did nothing. Setting to State/Question."
|
||||||
|
)
|
||||||
|
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||||
|
_comment_issue(
|
||||||
|
pending_issue,
|
||||||
|
"The agent exited without pushing any changes (no new CI run was triggered). "
|
||||||
|
"This usually means the agent hit a rate limit or crashed at startup. "
|
||||||
|
"The issue has been set to State/Question — please review the agent log and retry.",
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
_close_issue(pending_issue)
|
||||||
|
print(f"CI passed — closed {_issue_url(pending_issue)}.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Find a Ready issue.
|
||||||
|
issues = _ready_issues()
|
||||||
|
if not issues:
|
||||||
|
print("No issues with State/Ready. Nothing to do.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
issue = issues[0]
|
||||||
|
issue_number = issue["number"]
|
||||||
|
issue_title = issue["title"]
|
||||||
|
issue_body = issue.get("body", "")
|
||||||
|
|
||||||
|
print(f"Starting agent for {_issue_url(issue_number)} {issue_title}")
|
||||||
|
|
||||||
|
# Mark InProgress before starting so the next cron tick sees it even if
|
||||||
|
# the agent hasn't had time to do so yet.
|
||||||
|
_set_labels(
|
||||||
|
issue_number,
|
||||||
|
add=[LABEL_IN_PROGRESS],
|
||||||
|
remove=[LABEL_READY],
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = f"""Work on Codeberg issue #{issue_number} in the guettli/sharedinbox repository.
|
||||||
|
|
||||||
|
Issue title: {issue_title}
|
||||||
|
|
||||||
|
Issue body:
|
||||||
|
{issue_body}
|
||||||
|
|
||||||
|
Instructions:
|
||||||
|
- Understand the issue thoroughly before writing any code.
|
||||||
|
- Implement the required change, following the existing code style.
|
||||||
|
- Write or update tests as appropriate.
|
||||||
|
- Run 'task check' locally and fix any failures before committing.
|
||||||
|
- Commit with a descriptive message referencing the issue number (e.g. "feat: ... (#{issue_number})").
|
||||||
|
- Create a branch named `issue-{issue_number}-fix`, push your changes there, and open a PR against main:
|
||||||
|
git checkout -b issue-{issue_number}-fix
|
||||||
|
git push -u origin issue-{issue_number}-fix
|
||||||
|
fgj pr create --title "fix: <short description> (#{issue_number})" \\
|
||||||
|
--head issue-{issue_number}-fix --base main --repo {REPO}
|
||||||
|
- Do NOT push to main, do NOT close the issue, and do NOT merge the PR — the loop handles that after CI passes.
|
||||||
|
- If you hit a blocker you cannot resolve, set the issue label to State/Question
|
||||||
|
and stop (do NOT close the issue).
|
||||||
|
- When the work is pushed and the PR is opened, stop. The loop will merge the PR and close the issue after CI passes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
session_name = f"issue-{issue_number}"
|
||||||
|
pid = _start_agent(prompt, session_name)
|
||||||
|
current_run_id = run["id"] if run else None
|
||||||
|
_write_state(pid, issue_number, "issue", issue_title, session_name=session_name, ci_run_id=current_run_id)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="agent_loop")
|
||||||
|
sub = parser.add_subparsers(dest="cmd")
|
||||||
|
sub.add_parser("list", help="List recent agent sessions")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.cmd == "list":
|
||||||
|
return cmd_list()
|
||||||
|
return _run_loop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -15,8 +15,10 @@ const _noCode = {
|
|||||||
'lib/core/repositories/draft_repository.dart',
|
'lib/core/repositories/draft_repository.dart',
|
||||||
'lib/core/repositories/email_repository.dart',
|
'lib/core/repositories/email_repository.dart',
|
||||||
'lib/core/repositories/mailbox_repository.dart',
|
'lib/core/repositories/mailbox_repository.dart',
|
||||||
|
'lib/core/repositories/share_key_repository.dart',
|
||||||
'lib/core/repositories/sync_log_repository.dart',
|
'lib/core/repositories/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/models/undo_action.dart',
|
'lib/core/models/undo_action.dart',
|
||||||
'lib/core/storage/secure_storage.dart',
|
'lib/core/storage/secure_storage.dart',
|
||||||
};
|
};
|
||||||
@@ -32,6 +34,8 @@ const _excluded = {
|
|||||||
'lib/main.dart',
|
'lib/main.dart',
|
||||||
'lib/ui/router.dart',
|
'lib/ui/router.dart',
|
||||||
'lib/ui/screens/account_list_screen.dart',
|
'lib/ui/screens/account_list_screen.dart',
|
||||||
|
'lib/ui/screens/account_receive_screen.dart',
|
||||||
|
'lib/ui/screens/account_send_screen.dart',
|
||||||
'lib/ui/screens/add_account_screen.dart',
|
'lib/ui/screens/add_account_screen.dart',
|
||||||
'lib/ui/screens/address_emails_screen.dart',
|
'lib/ui/screens/address_emails_screen.dart',
|
||||||
'lib/ui/screens/changelog_screen.dart',
|
'lib/ui/screens/changelog_screen.dart',
|
||||||
@@ -48,18 +52,25 @@ const _excluded = {
|
|||||||
'lib/ui/screens/thread_detail_screen.dart',
|
'lib/ui/screens/thread_detail_screen.dart',
|
||||||
'lib/ui/screens/undo_log_screen.dart',
|
'lib/ui/screens/undo_log_screen.dart',
|
||||||
'lib/ui/widgets/folder_drawer.dart',
|
'lib/ui/widgets/folder_drawer.dart',
|
||||||
|
'lib/ui/widgets/secure_email_webview.dart',
|
||||||
'lib/ui/widgets/snooze_picker.dart',
|
'lib/ui/widgets/snooze_picker.dart',
|
||||||
'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/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/reliability_runner.dart',
|
'lib/core/sync/reliability_runner.dart',
|
||||||
'lib/data/jmap/jmap_client.dart',
|
'lib/data/jmap/jmap_client.dart',
|
||||||
'lib/data/jmap/sieve_repository.dart',
|
'lib/data/jmap/sieve_repository.dart',
|
||||||
'lib/data/repositories/account_repository_impl.dart',
|
'lib/data/repositories/account_repository_impl.dart',
|
||||||
'lib/data/repositories/email_repository_impl.dart',
|
'lib/data/repositories/email_repository_impl.dart',
|
||||||
'lib/data/repositories/mailbox_repository_impl.dart',
|
'lib/data/repositories/mailbox_repository_impl.dart',
|
||||||
|
'lib/data/repositories/share_key_repository_impl.dart',
|
||||||
'lib/data/repositories/sync_log_repository_impl.dart',
|
'lib/data/repositories/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/core/services/update_service.dart',
|
||||||
};
|
};
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verify that all *.mocks.dart files are up to date.
|
||||||
|
# Re-runs build_runner and fails if any generated mock differs from what is committed.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
echo "check-mocks: regenerating..."
|
||||||
|
tmp=$(mktemp)
|
||||||
|
trap 'rm -f "$tmp"' EXIT
|
||||||
|
if fvm flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1; then
|
||||||
|
grep -vE '^\[' "$tmp" || true
|
||||||
|
else
|
||||||
|
cat "$tmp"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CHANGED=$(git diff --name-only -- '*.mocks.dart')
|
||||||
|
if [ -n "$CHANGED" ]; then
|
||||||
|
echo "ERROR: The following mock files are out of date:"
|
||||||
|
echo "$CHANGED"
|
||||||
|
echo "Run 'task codegen' and commit the regenerated mocks."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "check-mocks: all mock files are up to date."
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Fail if binary files (other than images and fonts) are staged for commit.
|
||||||
|
# Prevents accidental inclusion of build artifacts, databases, compiled binaries.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ALLOWED_EXTENSIONS='(png|jpg|jpeg|gif|webp|svg|ico|ttf|otf|woff|woff2)'
|
||||||
|
|
||||||
|
# git diff --numstat shows "- - path" for binary files
|
||||||
|
BINARY=$(git diff --cached --numstat | awk '$1=="-" && $2=="-" {print $3}')
|
||||||
|
|
||||||
|
if [ -z "$BINARY" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
BLOCKED=''
|
||||||
|
while IFS= read -r f; do
|
||||||
|
if ! echo "$f" | grep -qiE "\.$ALLOWED_EXTENSIONS$"; then
|
||||||
|
BLOCKED="$BLOCKED\n $f"
|
||||||
|
fi
|
||||||
|
done <<< "$BINARY"
|
||||||
|
|
||||||
|
if [ -n "$BLOCKED" ]; then
|
||||||
|
echo "Binary files staged for commit (not allowed):"
|
||||||
|
echo -e "$BLOCKED"
|
||||||
|
echo ""
|
||||||
|
echo "If this is intentional, add the extension to ALLOWED_EXTENSIONS in scripts/check_no_binary.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
+86
-25
@@ -4,14 +4,78 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from google.auth.transport.requests import AuthorizedSession
|
||||||
from google.oauth2 import service_account
|
from google.oauth2 import service_account
|
||||||
from googleapiclient.discovery import build
|
|
||||||
from googleapiclient.http import MediaFileUpload
|
|
||||||
|
|
||||||
PACKAGE_NAME = "de.sharedinbox.mua"
|
PACKAGE_NAME = "de.sharedinbox.mua"
|
||||||
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
||||||
TRACK = "internal"
|
TRACK = "internal"
|
||||||
|
_TIMEOUT = 300 # seconds — AAB uploads can be large
|
||||||
|
_MAX_UPLOAD_ATTEMPTS = 3
|
||||||
|
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
|
||||||
|
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_session(config_json: str) -> AuthorizedSession:
|
||||||
|
creds = service_account.Credentials.from_service_account_info(
|
||||||
|
json.loads(config_json),
|
||||||
|
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
||||||
|
)
|
||||||
|
return AuthorizedSession(creds)
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_aab(session: AuthorizedSession, edit_id: str) -> int:
|
||||||
|
"""Resumable upload of the AAB. Returns the version code."""
|
||||||
|
file_size = os.path.getsize(AAB_PATH)
|
||||||
|
|
||||||
|
with open(AAB_PATH, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
|
||||||
|
last_exc = None
|
||||||
|
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
|
||||||
|
try:
|
||||||
|
# Each attempt needs a fresh resumable upload URL — the previous URL expires on failure.
|
||||||
|
init_resp = session.post(
|
||||||
|
f"{_UPLOAD_BASE}/{PACKAGE_NAME}/edits/{edit_id}/bundles",
|
||||||
|
params={"uploadType": "resumable"},
|
||||||
|
headers={
|
||||||
|
"X-Upload-Content-Type": "application/octet-stream",
|
||||||
|
"X-Upload-Content-Length": str(file_size),
|
||||||
|
},
|
||||||
|
json={},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if not init_resp.ok:
|
||||||
|
print(f"Init attempt {attempt + 1} failed: HTTP {init_resp.status_code}: {init_resp.text[:500]}")
|
||||||
|
init_resp.raise_for_status()
|
||||||
|
upload_url = init_resp.headers["Location"]
|
||||||
|
|
||||||
|
upload_resp = session.put(
|
||||||
|
upload_url,
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
"Content-Length": str(file_size),
|
||||||
|
},
|
||||||
|
timeout=_TIMEOUT,
|
||||||
|
)
|
||||||
|
if not upload_resp.ok:
|
||||||
|
print(f"Upload attempt {attempt + 1} failed: HTTP {upload_resp.status_code}: {upload_resp.text[:500]}")
|
||||||
|
upload_resp.raise_for_status()
|
||||||
|
return upload_resp.json()["versionCode"]
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
|
||||||
|
delay = 10 * (2 ** attempt)
|
||||||
|
print(f"Attempt {attempt + 1} failed ({exc}), retrying in {delay}s…")
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
|
||||||
|
) from last_exc
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -24,34 +88,31 @@ def main():
|
|||||||
print(f"Error: AAB not found at {AAB_PATH}", file=sys.stderr)
|
print(f"Error: AAB not found at {AAB_PATH}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
creds = service_account.Credentials.from_service_account_info(
|
session = _make_session(config_json)
|
||||||
json.loads(config_json),
|
|
||||||
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
edit_resp = session.post(
|
||||||
|
f"{_BASE}/{PACKAGE_NAME}/edits",
|
||||||
|
json={},
|
||||||
|
timeout=30,
|
||||||
)
|
)
|
||||||
|
edit_resp.raise_for_status()
|
||||||
|
edit_id = edit_resp.json()["id"]
|
||||||
|
|
||||||
service = build("androidpublisher", "v3", credentials=creds)
|
version_code = _upload_aab(session, edit_id)
|
||||||
|
|
||||||
edit = service.edits().insert(body={}, packageName=PACKAGE_NAME).execute()
|
|
||||||
edit_id = edit["id"]
|
|
||||||
|
|
||||||
media = MediaFileUpload(AAB_PATH, mimetype="application/octet-stream", resumable=True)
|
|
||||||
bundle = (
|
|
||||||
service.edits()
|
|
||||||
.bundles()
|
|
||||||
.upload(packageName=PACKAGE_NAME, editId=edit_id, media_body=media)
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
version_code = bundle["versionCode"]
|
|
||||||
print(f"Uploaded AAB, version code: {version_code}")
|
print(f"Uploaded AAB, version code: {version_code}")
|
||||||
|
|
||||||
service.edits().tracks().update(
|
tracks_resp = session.put(
|
||||||
packageName=PACKAGE_NAME,
|
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
|
||||||
editId=edit_id,
|
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
||||||
track=TRACK,
|
timeout=30,
|
||||||
body={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
)
|
||||||
).execute()
|
tracks_resp.raise_for_status()
|
||||||
|
|
||||||
service.edits().commit(packageName=PACKAGE_NAME, editId=edit_id).execute()
|
commit_resp = session.post(
|
||||||
|
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
commit_resp.raise_for_status()
|
||||||
print(f"Deployed version {version_code} to {TRACK} track")
|
print(f"Deployed version {version_code} to {TRACK} track")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate Hugo markdown pages listing builds fetched from the server.
|
||||||
|
|
||||||
|
Reads build artifacts under public_html/builds/ on the deployment server via SSH,
|
||||||
|
parses the git hash from each filename, fetches the commit title from the
|
||||||
|
Codeberg API, then writes Hugo content pages to website/content/builds/.
|
||||||
|
|
||||||
|
Covers two platforms:
|
||||||
|
- Linux: sharedinbox-linux-amd64-<hash>.tar.gz
|
||||||
|
- Android: sharedinbox-mua-<hash>.apk
|
||||||
|
|
||||||
|
At most MAX_BUILDS_PER_PLATFORM of the most-recent builds are shown per platform.
|
||||||
|
|
||||||
|
These generated pages are not tracked in git (see .gitignore).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CODEBERG_REPO = "guettli/sharedinbox"
|
||||||
|
REMOTE_BUILDS_DIR = "public_html/builds"
|
||||||
|
CONTENT_DIR = Path("website/content/builds")
|
||||||
|
BASE_URL = "https://sharedinbox.de"
|
||||||
|
CODEBERG_BASE = "https://codeberg.org"
|
||||||
|
MAX_BUILDS_PER_PLATFORM = 30
|
||||||
|
|
||||||
|
|
||||||
|
def list_remote_files(ssh_user: str, ssh_host: str, pattern: str) -> list[str]:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"ssh",
|
||||||
|
"-v",
|
||||||
|
"-o", "StrictHostKeyChecking=no",
|
||||||
|
"-i", "/root/.ssh/id_ed25519",
|
||||||
|
f"{ssh_user}@{ssh_host}",
|
||||||
|
f"find {REMOTE_BUILDS_DIR} -name '{pattern}' -type f | sort",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(
|
||||||
|
f"WARNING: ssh exit {result.returncode} listing {pattern} on {ssh_user}@{ssh_host}"
|
||||||
|
" — build history will be empty for this pattern",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(result.stderr, file=sys.stderr)
|
||||||
|
return []
|
||||||
|
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_commit_info(hash_val: str) -> tuple[str, str]:
|
||||||
|
"""Return (title, datetime_iso) for the given commit hash."""
|
||||||
|
url = f"https://codeberg.org/api/v1/repos/{CODEBERG_REPO}/git/commits/{hash_val}"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "sharedinbox-ci"})
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
title = data.get("commit", {}).get("message", "").split("\n")[0]
|
||||||
|
dt = data.get("commit", {}).get("committer", {}).get("date", "")
|
||||||
|
return title, dt
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" warning: could not fetch commit info for {hash_val}: {exc}", file=sys.stderr)
|
||||||
|
return hash_val, ""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_builds(
|
||||||
|
paths: list[str],
|
||||||
|
path_re: re.Pattern, # type: ignore[type-arg]
|
||||||
|
) -> dict[str, list[tuple[str, str, str, str]]]:
|
||||||
|
"""Parse build file paths into {date_key: [(hash, url, title, dt), ...]}."""
|
||||||
|
limited = paths[-MAX_BUILDS_PER_PLATFORM:] if len(paths) > MAX_BUILDS_PER_PLATFORM else paths
|
||||||
|
days: dict[str, list[tuple[str, str, str, str]]] = {}
|
||||||
|
for path in limited:
|
||||||
|
m = path_re.match(path)
|
||||||
|
if not m:
|
||||||
|
print(f" skipping unexpected path: {path}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
year, month, day, filename, hash_val = m.groups()
|
||||||
|
date_key = f"{year}/{month}/{day}"
|
||||||
|
download_url = f"{BASE_URL}/builds/{year}/{month}/{day}/{filename}"
|
||||||
|
commit_title, commit_dt = get_commit_info(hash_val)
|
||||||
|
days.setdefault(date_key, []).append((hash_val, download_url, commit_title, commit_dt))
|
||||||
|
return days
|
||||||
|
|
||||||
|
|
||||||
|
def render_entries(
|
||||||
|
entries: list[tuple[str, str, str, str]],
|
||||||
|
link_label: str,
|
||||||
|
) -> str:
|
||||||
|
lines = []
|
||||||
|
for hash_val, download_url, commit_title, commit_dt in entries:
|
||||||
|
commit_url = f"{CODEBERG_BASE}/{CODEBERG_REPO}/commit/{hash_val}"
|
||||||
|
dt_str = f" · {commit_dt}" if commit_dt else ""
|
||||||
|
lines.append(
|
||||||
|
f"- [{commit_title}]({commit_url}){dt_str} \n"
|
||||||
|
f" [{link_label}]({download_url}) (`{hash_val}`)\n"
|
||||||
|
)
|
||||||
|
return "".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ssh_user = os.environ.get("SSH_USER", "")
|
||||||
|
ssh_host = os.environ.get("SSH_HOST", "")
|
||||||
|
if not ssh_user or not ssh_host:
|
||||||
|
print("SSH_USER and SSH_HOST must be set", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Listing Linux builds on {ssh_host}…")
|
||||||
|
linux_paths = list_remote_files(ssh_user, ssh_host, "sharedinbox-linux-amd64-*.tar.gz")
|
||||||
|
print(f"Found {len(linux_paths)} Linux build(s)")
|
||||||
|
linux_re = re.compile(
|
||||||
|
r"public_html/builds/(\d{4})/(\d{2})/(\d{2})/(sharedinbox-linux-amd64-(.+)\.tar\.gz)$"
|
||||||
|
)
|
||||||
|
linux_days = parse_builds(linux_paths, linux_re)
|
||||||
|
|
||||||
|
print(f"Listing Android APKs on {ssh_host}…")
|
||||||
|
apk_paths = list_remote_files(ssh_user, ssh_host, "*.apk")
|
||||||
|
print(f"Found {len(apk_paths)} APK(s)")
|
||||||
|
apk_re = re.compile(
|
||||||
|
r"public_html/builds/(\d{4})/(\d{2})/(\d{2})/(sharedinbox-mua-(.+)\.apk)$"
|
||||||
|
)
|
||||||
|
android_days = parse_builds(apk_paths, apk_re)
|
||||||
|
|
||||||
|
CONTENT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# _index.md: platform sections, newest-first within each
|
||||||
|
index_lines = ["---\ntitle: Builds\n---\n\n"]
|
||||||
|
|
||||||
|
index_lines.append(f"## Linux (last {MAX_BUILDS_PER_PLATFORM})\n\n")
|
||||||
|
if linux_days:
|
||||||
|
for date_key in sorted(linux_days, reverse=True):
|
||||||
|
year, month, day = date_key.split("/")
|
||||||
|
index_lines.append(f"### {year}-{month}-{day}\n\n")
|
||||||
|
index_lines.append(render_entries(linux_days[date_key], "Download"))
|
||||||
|
index_lines.append("\n")
|
||||||
|
else:
|
||||||
|
index_lines.append("_No Linux builds yet._\n\n")
|
||||||
|
|
||||||
|
index_lines.append(f"## Android (last {MAX_BUILDS_PER_PLATFORM})\n\n")
|
||||||
|
if android_days:
|
||||||
|
for date_key in sorted(android_days, reverse=True):
|
||||||
|
year, month, day = date_key.split("/")
|
||||||
|
index_lines.append(f"### {year}-{month}-{day}\n\n")
|
||||||
|
index_lines.append(render_entries(android_days[date_key], "Download APK"))
|
||||||
|
index_lines.append("\n")
|
||||||
|
else:
|
||||||
|
index_lines.append("_No Android builds yet._\n\n")
|
||||||
|
|
||||||
|
(CONTENT_DIR / "_index.md").write_text("".join(index_lines), encoding="utf-8")
|
||||||
|
|
||||||
|
# Per-day pages (combined)
|
||||||
|
all_days = set(linux_days) | set(android_days)
|
||||||
|
for date_key in sorted(all_days):
|
||||||
|
year, month, day = date_key.split("/")
|
||||||
|
date_iso = f"{year}-{month}-{day}"
|
||||||
|
day_dir = CONTENT_DIR / year / month
|
||||||
|
day_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
lines = [f"---\ntitle: 'Builds for {date_iso}'\ndate: {date_iso}T00:00:00Z\n---\n\n"]
|
||||||
|
if date_key in linux_days:
|
||||||
|
lines.append("## Linux\n\n")
|
||||||
|
lines.append(render_entries(linux_days[date_key], "Download"))
|
||||||
|
lines.append("\n")
|
||||||
|
if date_key in android_days:
|
||||||
|
lines.append("## Android\n\n")
|
||||||
|
lines.append(render_entries(android_days[date_key], "Download APK"))
|
||||||
|
lines.append("\n")
|
||||||
|
(day_dir / f"{day}.md").write_text("".join(lines), encoding="utf-8")
|
||||||
|
|
||||||
|
total_linux = sum(len(v) for v in linux_days.values())
|
||||||
|
total_android = sum(len(v) for v in android_days.values())
|
||||||
|
print(
|
||||||
|
f"Generated pages: {total_linux} Linux build(s), {total_android} Android build(s) "
|
||||||
|
f"across {len(all_days)} day(s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs the Firebase Test Lab Dagger pipeline with Gradle/Dagger noise filtered out.
|
||||||
|
# Retries up to 3 times on transient Dagger engine connectivity errors.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
OUT=$(mktemp)
|
||||||
|
RC_FILE=$(mktemp)
|
||||||
|
trap 'rm -f "$OUT" "$RC_FILE"' EXIT
|
||||||
|
|
||||||
|
_strip_ansi() {
|
||||||
|
sed 's/\x1b\[[0-9;]*[mGKHFJ]//g'
|
||||||
|
}
|
||||||
|
|
||||||
|
_filter_noise() {
|
||||||
|
grep -vE \
|
||||||
|
'> Task :.+(UP-TO-DATE|NO-SOURCE|SKIPPED)'\
|
||||||
|
'|[0-9]+ files found for path '\''lib/'\
|
||||||
|
'|^Inputs:'\
|
||||||
|
'|^[[:space:]]+-[[:space:]]/'\
|
||||||
|
'|\[Incubating\]'\
|
||||||
|
'|Deprecated Gradle features'\
|
||||||
|
'|warning-mode all'\
|
||||||
|
'|please refer to https://docs\.gradle'\
|
||||||
|
'|[0-9]+ actionable tasks'\
|
||||||
|
'|^warning: \[options\]'\
|
||||||
|
'|^Note: Some input files'\
|
||||||
|
'|Starting a Gradle Daemon'\
|
||||||
|
'|Have questions, feedback, or issues'\
|
||||||
|
'|https://firebase\.google\.com/support'\
|
||||||
|
'|^\s*[┆│]\s*$' \
|
||||||
|
|| true
|
||||||
|
}
|
||||||
|
|
||||||
|
_run() {
|
||||||
|
: > "$OUT" ; : > "$RC_FILE"
|
||||||
|
{
|
||||||
|
dagger call --progress=plain -q -m ci --source=. test-android-firebase \
|
||||||
|
--service-account-key env:FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY \
|
||||||
|
--project-id "$FIREBASE_PROJECT_ID"
|
||||||
|
echo $? > "$RC_FILE"
|
||||||
|
} 2>&1 | tee "$OUT" | _strip_ansi | _filter_noise
|
||||||
|
}
|
||||||
|
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
_run && break
|
||||||
|
RC=$(cat "$RC_FILE" 2>/dev/null || echo 1)
|
||||||
|
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|No Dagger server responded" "$OUT"; then
|
||||||
|
echo "[firebase] dagger connectivity error on attempt $attempt/3, retrying..." >&2
|
||||||
|
else
|
||||||
|
exit "$RC"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
exit "$(cat "$RC_FILE" 2>/dev/null || echo 0)"
|
||||||
Executable
+74
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Establishes a secure tunnel to a remote Dagger Engine via stunnel.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "${DAGGER_STUNNEL_URL:-}" ]; then
|
||||||
|
echo "Error: DAGGER_STUNNEL_URL must be set."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse host and port (e.g., example.com:8774 or just example.com)
|
||||||
|
host=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f1)
|
||||||
|
port=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f2)
|
||||||
|
if [ "$host" == "$port" ]; then
|
||||||
|
port="8774"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Probing $host:$port..."
|
||||||
|
if ! nc -zw 3 "$host" "$port" 2>/dev/null; then
|
||||||
|
echo "Error: No Dagger server responded on $host:$port"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Found active Dagger server on $host:$port"
|
||||||
|
|
||||||
|
# 2. Setup TLS credentials (passed as env vars from secrets)
|
||||||
|
mkdir -p /tmp/dagger-tls
|
||||||
|
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
|
||||||
|
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
|
||||||
|
echo "$DAGGER_CLIENT_KEY" > /tmp/dagger-tls/client.key
|
||||||
|
chmod 600 /tmp/dagger-tls/client.key
|
||||||
|
|
||||||
|
# 3. Configure and start stunnel
|
||||||
|
STUNNEL_CONF="/tmp/stunnel-dagger.conf"
|
||||||
|
cat << EOF > "$STUNNEL_CONF"
|
||||||
|
client = yes
|
||||||
|
foreground = yes
|
||||||
|
pid = /tmp/stunnel.pid
|
||||||
|
debug = warning
|
||||||
|
; TCP keepalive on the remote side to prevent NAT/firewall from resetting the connection
|
||||||
|
socket = r:SO_KEEPALIVE=1
|
||||||
|
socket = r:TCP_KEEPIDLE=10
|
||||||
|
socket = r:TCP_KEEPINTVL=5
|
||||||
|
socket = r:TCP_KEEPCNT=3
|
||||||
|
|
||||||
|
[dagger]
|
||||||
|
accept = 127.0.0.1:1774
|
||||||
|
connect = $host:$port
|
||||||
|
CAfile = /tmp/dagger-tls/ca.crt
|
||||||
|
cert = /tmp/dagger-tls/client.crt
|
||||||
|
key = /tmp/dagger-tls/client.key
|
||||||
|
verifyChain = yes
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Start stunnel in the background
|
||||||
|
stunnel "$STUNNEL_CONF" &
|
||||||
|
TUNNEL_PID=$!
|
||||||
|
|
||||||
|
# Give it a moment to establish
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
if ! kill -0 "$TUNNEL_PID" 2>/dev/null; then
|
||||||
|
echo "Error: stunnel failed to start"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Export environment for subsequent CI steps
|
||||||
|
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||||
|
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
|
||||||
|
echo "_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
|
||||||
|
echo "Tunnel established. Dagger is configured to use the remote engine."
|
||||||
|
else
|
||||||
|
export _EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
||||||
|
export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
||||||
|
echo "Tunnel established. Run: export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for agent_loop.py."""
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
import agent_loop
|
||||||
|
|
||||||
|
|
||||||
|
class TestUrlHelpers(unittest.TestCase):
|
||||||
|
def test_issue_url(self):
|
||||||
|
url = agent_loop._issue_url(128)
|
||||||
|
self.assertEqual(url, "https://codeberg.org/guettli/sharedinbox/issues/128")
|
||||||
|
|
||||||
|
def test_ci_run_url(self):
|
||||||
|
url = agent_loop._ci_run_url(4145144)
|
||||||
|
self.assertEqual(url, "https://codeberg.org/guettli/sharedinbox/actions/runs/4145144")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStateFile(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".json")
|
||||||
|
self._tmp.close()
|
||||||
|
self._orig = agent_loop.STATE_FILE
|
||||||
|
agent_loop.STATE_FILE = Path(self._tmp.name)
|
||||||
|
Path(self._tmp.name).unlink() # Start with no state file.
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
agent_loop.STATE_FILE = self._orig
|
||||||
|
Path(self._tmp.name).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def test_write_state_stores_pid(self):
|
||||||
|
agent_loop._write_state(12345, 91, "issue")
|
||||||
|
data = json.loads(Path(self._tmp.name).read_text())
|
||||||
|
self.assertEqual(data["pid"], 12345)
|
||||||
|
self.assertNotIn("tmux_session", data)
|
||||||
|
|
||||||
|
def test_write_state_stores_issue_and_kind(self):
|
||||||
|
agent_loop._write_state(99, 7, "ci-fix")
|
||||||
|
data = json.loads(Path(self._tmp.name).read_text())
|
||||||
|
self.assertEqual(data["issue"], 7)
|
||||||
|
self.assertEqual(data["type"], "ci-fix")
|
||||||
|
self.assertIn("started_at", data)
|
||||||
|
|
||||||
|
def test_read_state_returns_none_when_missing(self):
|
||||||
|
self.assertIsNone(agent_loop._read_state())
|
||||||
|
|
||||||
|
def test_read_and_write_roundtrip(self):
|
||||||
|
agent_loop._write_state(42, 10, "issue")
|
||||||
|
state = agent_loop._read_state()
|
||||||
|
self.assertIsNotNone(state)
|
||||||
|
self.assertEqual(state["pid"], 42)
|
||||||
|
self.assertEqual(state["issue"], 10)
|
||||||
|
|
||||||
|
def test_clear_state_removes_file(self):
|
||||||
|
agent_loop._write_state(1, None, "ci-fix")
|
||||||
|
agent_loop._clear_state()
|
||||||
|
self.assertIsNone(agent_loop._read_state())
|
||||||
|
|
||||||
|
def test_write_state_stores_issue_title(self):
|
||||||
|
agent_loop._write_state(42, 10, "issue", "My Test Issue")
|
||||||
|
data = json.loads(Path(self._tmp.name).read_text())
|
||||||
|
self.assertEqual(data["issue_title"], "My Test Issue")
|
||||||
|
|
||||||
|
def test_write_state_omits_issue_title_when_none(self):
|
||||||
|
agent_loop._write_state(42, None, "ci-fix")
|
||||||
|
data = json.loads(Path(self._tmp.name).read_text())
|
||||||
|
self.assertNotIn("issue_title", data)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentAlive(unittest.TestCase):
|
||||||
|
def test_own_pid_is_alive(self):
|
||||||
|
self.assertTrue(agent_loop._agent_alive({"pid": os.getpid()}))
|
||||||
|
|
||||||
|
def test_nonexistent_pid_is_dead(self):
|
||||||
|
self.assertFalse(agent_loop._agent_alive({"pid": 999999999}))
|
||||||
|
|
||||||
|
def test_missing_pid_returns_false(self):
|
||||||
|
self.assertFalse(agent_loop._agent_alive({}))
|
||||||
|
self.assertFalse(agent_loop._agent_alive({"pid": None}))
|
||||||
|
|
||||||
|
|
||||||
|
class TestKillAgent(unittest.TestCase):
|
||||||
|
def test_kill_sends_sigkill(self):
|
||||||
|
with patch("agent_loop.os.kill") as mock_kill:
|
||||||
|
agent_loop._kill_agent({"pid": 1234})
|
||||||
|
mock_kill.assert_called_once_with(1234, 9)
|
||||||
|
|
||||||
|
def test_kill_ignores_missing_process(self):
|
||||||
|
with patch("agent_loop.os.kill", side_effect=ProcessLookupError):
|
||||||
|
agent_loop._kill_agent({"pid": 1234}) # Should not raise.
|
||||||
|
|
||||||
|
def test_kill_noop_when_no_pid(self):
|
||||||
|
with patch("agent_loop.os.kill") as mock_kill:
|
||||||
|
agent_loop._kill_agent({})
|
||||||
|
mock_kill.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartAgent(unittest.TestCase):
|
||||||
|
def _make_mock_proc(self, pid=42):
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.pid = pid
|
||||||
|
proc.stdin = io.BytesIO()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
def test_start_agent_returns_pid(self):
|
||||||
|
mock_proc = self._make_mock_proc(pid=42)
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with patch("agent_loop.subprocess.Popen", return_value=mock_proc):
|
||||||
|
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
|
||||||
|
result = agent_loop._start_agent("do something", "issue-99")
|
||||||
|
self.assertEqual(result, 42)
|
||||||
|
|
||||||
|
def test_start_agent_uses_popen_not_tmux(self):
|
||||||
|
mock_proc = self._make_mock_proc(pid=7)
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with patch("agent_loop.subprocess.Popen", return_value=mock_proc) as mock_popen:
|
||||||
|
with patch("agent_loop.subprocess.run") as mock_run:
|
||||||
|
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
|
||||||
|
agent_loop._start_agent("prompt", "ci-fix")
|
||||||
|
mock_popen.assert_called_once()
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
def test_start_agent_passes_session_name_to_claude(self):
|
||||||
|
mock_proc = self._make_mock_proc(pid=7)
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with patch("agent_loop.subprocess.Popen", return_value=mock_proc) as mock_popen:
|
||||||
|
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
|
||||||
|
agent_loop._start_agent("prompt", "issue-55")
|
||||||
|
cmd = mock_popen.call_args[0][0]
|
||||||
|
self.assertIn("issue-55", cmd)
|
||||||
|
self.assertIn("claude", cmd[0])
|
||||||
|
|
||||||
|
def test_start_agent_uses_start_new_session(self):
|
||||||
|
mock_proc = self._make_mock_proc(pid=7)
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with patch("agent_loop.subprocess.Popen", return_value=mock_proc) as mock_popen:
|
||||||
|
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
|
||||||
|
agent_loop._start_agent("prompt", "issue-55")
|
||||||
|
kwargs = mock_popen.call_args[1]
|
||||||
|
self.assertTrue(kwargs.get("start_new_session"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain(unittest.TestCase):
|
||||||
|
"""Tests for the main() flow."""
|
||||||
|
|
||||||
|
def _make_mock_proc(self, pid=42):
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.pid = pid
|
||||||
|
proc.stdin = io.BytesIO()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
def _make_issue(self, number=10, title="Do something"):
|
||||||
|
return {"number": number, "title": title, "body": "", "labels": []}
|
||||||
|
|
||||||
|
def test_sets_in_progress_before_starting_agent(self):
|
||||||
|
"""_set_labels(InProgress) must be called before _start_agent."""
|
||||||
|
call_order = []
|
||||||
|
mock_proc = self._make_mock_proc(pid=55)
|
||||||
|
|
||||||
|
def fake_set_labels(issue, add, remove):
|
||||||
|
call_order.append(("set_labels", add, remove))
|
||||||
|
|
||||||
|
def fake_start_agent(prompt, session_name):
|
||||||
|
call_order.append(("start_agent", session_name))
|
||||||
|
return 55
|
||||||
|
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \
|
||||||
|
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
||||||
|
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
|
||||||
|
patch("agent_loop._write_state"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
labels_idx = next(
|
||||||
|
i for i, c in enumerate(call_order) if c[0] == "set_labels"
|
||||||
|
)
|
||||||
|
agent_idx = next(
|
||||||
|
i for i, c in enumerate(call_order) if c[0] == "start_agent"
|
||||||
|
)
|
||||||
|
self.assertLess(labels_idx, agent_idx,
|
||||||
|
"_set_labels must be called before _start_agent")
|
||||||
|
|
||||||
|
def test_sets_in_progress_label_and_removes_ready(self):
|
||||||
|
"""The InProgress label is added and the Ready label is removed."""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_set_labels(issue, add, remove):
|
||||||
|
captured["add"] = add
|
||||||
|
captured["remove"] = remove
|
||||||
|
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \
|
||||||
|
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
||||||
|
patch("agent_loop._start_agent", return_value=99), \
|
||||||
|
patch("agent_loop._write_state"):
|
||||||
|
agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertIn(agent_loop.LABEL_IN_PROGRESS, captured.get("add", []))
|
||||||
|
self.assertIn(agent_loop.LABEL_READY, captured.get("remove", []))
|
||||||
|
|
||||||
|
def test_no_ready_issues_does_nothing(self):
|
||||||
|
"""main() exits cleanly with 0 when there are no ready issues."""
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[]), \
|
||||||
|
patch("agent_loop._set_labels") as mock_labels, \
|
||||||
|
patch("agent_loop._start_agent") as mock_start:
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
mock_labels.assert_not_called()
|
||||||
|
mock_start.assert_not_called()
|
||||||
|
|
||||||
|
def test_prompt_does_not_tell_agent_to_close_issue(self):
|
||||||
|
"""Agents must not close issues; the loop handles closing after CI passes."""
|
||||||
|
captured_prompt = {}
|
||||||
|
|
||||||
|
def fake_start_agent(prompt, session_name):
|
||||||
|
captured_prompt["prompt"] = prompt
|
||||||
|
return 77
|
||||||
|
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \
|
||||||
|
patch("agent_loop._set_labels"), \
|
||||||
|
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
|
||||||
|
patch("agent_loop._write_state"):
|
||||||
|
agent_loop._run_loop()
|
||||||
|
|
||||||
|
prompt = captured_prompt.get("prompt", "")
|
||||||
|
# "do NOT close the issue" (blocker instruction) is fine; what must be
|
||||||
|
# absent is any affirmative instruction to close on completion.
|
||||||
|
self.assertNotIn("close the issue and stop", prompt.lower())
|
||||||
|
|
||||||
|
|
||||||
|
class TestPendingCi(unittest.TestCase):
|
||||||
|
"""Tests for the pending-CI state: issue closed only after CI passes."""
|
||||||
|
|
||||||
|
def _dead_state(self, issue: int, kind: str = "issue") -> dict:
|
||||||
|
return {
|
||||||
|
"pid": 999999999, # non-existent PID
|
||||||
|
"issue": issue,
|
||||||
|
"started_at": "2026-01-01T00:00:00+00:00",
|
||||||
|
"type": kind,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_closes_issue_when_ci_passes_after_agent_finishes(self):
|
||||||
|
"""After issue agent finishes, loop closes the issue once CI is green."""
|
||||||
|
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||||
|
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||||
|
patch("agent_loop._close_issue") as mock_close, \
|
||||||
|
patch("agent_loop._clear_state"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
mock_close.assert_called_once_with(10)
|
||||||
|
|
||||||
|
def test_does_not_close_issue_when_ci_fails(self):
|
||||||
|
"""After issue agent finishes, loop must NOT close the issue if CI failed."""
|
||||||
|
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||||
|
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "failure"}), \
|
||||||
|
patch("agent_loop._close_issue") as mock_close, \
|
||||||
|
patch("agent_loop._start_agent", return_value=55), \
|
||||||
|
patch("agent_loop._write_state"), \
|
||||||
|
patch("agent_loop._clear_state"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
mock_close.assert_not_called()
|
||||||
|
|
||||||
|
def test_saves_pending_ci_state_while_ci_running(self):
|
||||||
|
"""When CI is still running after agent finishes, pending issue is preserved."""
|
||||||
|
written = {}
|
||||||
|
|
||||||
|
def fake_write_state(pid, issue, kind, issue_title=None, 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._latest_ci_run", return_value={"id": 1, "status": "running"}), \
|
||||||
|
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
||||||
|
patch("agent_loop._clear_state"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertEqual(written.get("issue"), 10)
|
||||||
|
self.assertEqual(written.get("kind"), "pending-ci")
|
||||||
|
self.assertIsNone(written.get("pid"))
|
||||||
|
|
||||||
|
def test_ci_fix_preserves_pending_issue_in_state(self):
|
||||||
|
"""When CI fails after agent finishes, ci-fix state includes the pending issue."""
|
||||||
|
written = {}
|
||||||
|
|
||||||
|
def fake_write_state(pid, issue, kind, issue_title=None, 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._latest_ci_run", return_value={"id": 1, "status": "failure"}), \
|
||||||
|
patch("agent_loop._start_agent", return_value=55), \
|
||||||
|
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
||||||
|
patch("agent_loop._clear_state"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertEqual(written.get("issue"), 10)
|
||||||
|
self.assertEqual(written.get("kind"), "ci-fix")
|
||||||
|
|
||||||
|
def test_closes_issue_after_ci_fix_and_ci_passes(self):
|
||||||
|
"""After ci-fix agent finishes and CI passes, the pending issue is closed."""
|
||||||
|
with patch("agent_loop._read_state", return_value=self._dead_state(10, "ci-fix")), \
|
||||||
|
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||||
|
patch("agent_loop._close_issue") as mock_close, \
|
||||||
|
patch("agent_loop._clear_state"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
mock_close.assert_called_once_with(10)
|
||||||
|
|
||||||
|
def test_no_pending_issue_ci_fix_without_issue(self):
|
||||||
|
"""ci-fix for a manual push (no pending issue) does not try to close anything."""
|
||||||
|
with patch("agent_loop._read_state", return_value={
|
||||||
|
"pid": 999999999, "issue": None, "started_at": "2026-01-01T00:00:00+00:00",
|
||||||
|
"type": "ci-fix",
|
||||||
|
}), \
|
||||||
|
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||||
|
patch("agent_loop._close_issue") as mock_close, \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[]), \
|
||||||
|
patch("agent_loop._clear_state"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
mock_close.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestOutputFormat(unittest.TestCase):
|
||||||
|
"""Verify output format: no [agent_loop] prefix, URLs in output."""
|
||||||
|
|
||||||
|
def test_output_starts_with_header(self):
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[]), \
|
||||||
|
contextlib.redirect_stdout(buf):
|
||||||
|
agent_loop._run_loop()
|
||||||
|
first_line = buf.getvalue().splitlines()[0]
|
||||||
|
self.assertTrue(first_line.startswith("---------------------- Starting "),
|
||||||
|
f"Unexpected first line: {first_line!r}")
|
||||||
|
|
||||||
|
def test_no_agent_loop_prefix_in_output(self):
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[]), \
|
||||||
|
contextlib.redirect_stdout(buf):
|
||||||
|
agent_loop._run_loop()
|
||||||
|
self.assertNotIn("[agent_loop]", buf.getvalue())
|
||||||
|
|
||||||
|
def test_ci_run_output_contains_url(self):
|
||||||
|
run = {"id": 4145144, "status": "running"}
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._latest_ci_run", return_value=run), \
|
||||||
|
contextlib.redirect_stdout(buf):
|
||||||
|
agent_loop._run_loop()
|
||||||
|
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144",
|
||||||
|
buf.getvalue())
|
||||||
|
|
||||||
|
def test_issue_output_contains_url_and_title(self):
|
||||||
|
issue = {"number": 128, "title": "Fix something", "body": "", "labels": []}
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[issue]), \
|
||||||
|
patch("agent_loop._set_labels"), \
|
||||||
|
patch("agent_loop._start_agent", return_value=99), \
|
||||||
|
patch("agent_loop._write_state"), \
|
||||||
|
contextlib.redirect_stdout(buf):
|
||||||
|
agent_loop._run_loop()
|
||||||
|
output = buf.getvalue()
|
||||||
|
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/128", output)
|
||||||
|
self.assertIn("Fix something", output)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Executable
+89
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Tests for Firebase CI check patterns used in ci/main.go.
|
||||||
|
# Run directly: bash scripts/test_firebase_check.sh
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
_assert() {
|
||||||
|
local name="$1" expected="$2" actual="$3"
|
||||||
|
if [ "$actual" = "$expected" ]; then
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "FAIL: $name"
|
||||||
|
echo " expected: '$expected'"
|
||||||
|
echo " actual: '$actual'"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- auth stderr filter ---
|
||||||
|
# Lines ignored: "Activated service account credentials for: [...]"
|
||||||
|
# "Updated property [core/project]."
|
||||||
|
_filter_auth() {
|
||||||
|
grep -vF "Activated service account credentials for:" \
|
||||||
|
| grep -vF "Updated property [core/project]." \
|
||||||
|
| grep -v "^$" \
|
||||||
|
|| true
|
||||||
|
}
|
||||||
|
|
||||||
|
_assert "auth: both known messages produce empty output" "" \
|
||||||
|
"$(printf 'Activated service account credentials for: [ci@sa.iam.gserviceaccount.com]\nUpdated property [core/project].\n' | _filter_auth)"
|
||||||
|
|
||||||
|
_assert "auth: only credentials line produces empty output" "" \
|
||||||
|
"$(printf 'Activated service account credentials for: [ci@sa.iam.gserviceaccount.com]\n' | _filter_auth)"
|
||||||
|
|
||||||
|
_assert "auth: only property line produces empty output" "" \
|
||||||
|
"$(printf 'Updated property [core/project].\n' | _filter_auth)"
|
||||||
|
|
||||||
|
_assert "auth: empty input produces empty output" "" \
|
||||||
|
"$(printf '' | _filter_auth)"
|
||||||
|
|
||||||
|
_assert "auth: unexpected line passes through" "some unexpected error" \
|
||||||
|
"$(printf 'some unexpected error\n' | _filter_auth)"
|
||||||
|
|
||||||
|
_assert "auth: unknown line kept alongside known messages" "unexpected line" \
|
||||||
|
"$(printf 'Activated service account credentials for: [x]\nunexpected line\nUpdated property [core/project].\n' | _filter_auth)"
|
||||||
|
|
||||||
|
# --- "error" word detection: grep -qwi 'error' ---
|
||||||
|
# Matches "error" as a whole word (case-insensitive).
|
||||||
|
# Must NOT match "error" as part of another word (e.g. "stderr", "AssertionError").
|
||||||
|
_has_err() { printf '%s\n' "$1" | grep -qwi 'error' && echo yes || echo no; }
|
||||||
|
|
||||||
|
_assert "error: non-retryable error line matched" yes "$(_has_err 'A non-retryable error occurred.')"
|
||||||
|
_assert "error: uppercase ERROR matched" yes "$(_has_err 'ERROR: infrastructure_failure')"
|
||||||
|
_assert "error: mixed-case Error matched" yes "$(_has_err 'Error: something went wrong')"
|
||||||
|
_assert "error: normal pending line not matched" no "$(_has_err 'Test is Pending')"
|
||||||
|
_assert "error: timing line not matched" no "$(_has_err 'Done. Test time = 183 (secs)')"
|
||||||
|
_assert "error: completion line not matched" no "$(_has_err 'Instrumentation testing complete.')"
|
||||||
|
_assert "error: 'stderr' word not matched" no "$(_has_err 'some stderr: gcloud output')"
|
||||||
|
_assert "error: 'AssertionError' not matched" no "$(_has_err 'java.lang.AssertionError: expected true')"
|
||||||
|
|
||||||
|
# --- device count from result table ---
|
||||||
|
# Counts data rows by looking for lines with "│" that contain an outcome word.
|
||||||
|
TABLE_PASS="┌─────────┬───────────────────────┬──────────────┐
|
||||||
|
│ OUTCOME │ TEST_AXIS_VALUE │ TEST_DETAILS │
|
||||||
|
├─────────┼───────────────────────┼──────────────┤
|
||||||
|
│ Passed │ oriole-33-en-portrait │ -- │
|
||||||
|
└─────────┴───────────────────────┴──────────────┘"
|
||||||
|
|
||||||
|
TABLE_FAIL="┌─────────┬───────────────────────┬──────────────┐
|
||||||
|
│ OUTCOME │ TEST_AXIS_VALUE │ TEST_DETAILS │
|
||||||
|
├─────────┼───────────────────────┼──────────────┤
|
||||||
|
│ Failed │ oriole-33-en-portrait │ -- │
|
||||||
|
└─────────┴───────────────────────┴──────────────┘"
|
||||||
|
|
||||||
|
_count() {
|
||||||
|
local n
|
||||||
|
n=$(printf '%s' "$1" | grep "│" | grep -cE "(Passed|Failed|Inconclusive|Skipped)") || n=0
|
||||||
|
printf '%s' "$n"
|
||||||
|
}
|
||||||
|
|
||||||
|
_assert "count: one passing device gives 1" 1 "$(_count "$TABLE_PASS")"
|
||||||
|
_assert "count: one failing device gives 1" 1 "$(_count "$TABLE_FAIL")"
|
||||||
|
_assert "count: no table gives 0" 0 "$(_count 'Test is Pending\nDone.')"
|
||||||
|
_assert "count: plain output gives 0" 0 "$(_count 'Instrumentation testing complete.')"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
|
[ "$FAIL" -eq 0 ] || exit 1
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user