Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4634936ae |
@@ -1,18 +0,0 @@
|
|||||||
# Dagger context ignore file.
|
|
||||||
|
|
||||||
# Version control
|
|
||||||
.git/
|
|
||||||
|
|
||||||
# Build artifacts
|
|
||||||
build/
|
|
||||||
.dart_tool/
|
|
||||||
coverage/
|
|
||||||
linux/flutter/ephemeral/
|
|
||||||
website/public/
|
|
||||||
website/resources/
|
|
||||||
.task/
|
|
||||||
.fvm/
|
|
||||||
|
|
||||||
# Secrets
|
|
||||||
.env*
|
|
||||||
.envrc
|
|
||||||
@@ -14,7 +14,5 @@ PATH_add .fvm/flutter_sdk/bin
|
|||||||
|
|
||||||
PATH_add "$HOME/Android/Sdk/platform-tools"
|
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
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
# Source: https://codeberg.org/guettli/sharedinbox/src/branch/main/.forgejo/Dockerfile
|
|
||||||
# Install at on the act-runner host on: /etc/forgejo/runner/Dockerfile
|
|
||||||
#
|
|
||||||
# In systemd service:
|
|
||||||
# ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner
|
|
||||||
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
|
|
||||||
FROM ghcr.io/catthehacker/ubuntu:go-24.04
|
|
||||||
|
|
||||||
# Infrastructure tools required by CI workflows
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
stunnel4 \
|
|
||||||
netcat-openbsd \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Dagger CLI — pinned to match the engine version on the runner host
|
|
||||||
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
|
||||||
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
|
||||||
|
|
||||||
# Task runner
|
|
||||||
RUN curl -fsSL https://taskfile.dev/install.sh \
|
|
||||||
| sh -s -- -b /usr/local/bin v3.48.0
|
|
||||||
+24
-22
@@ -8,33 +8,35 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
name: Full Project Check
|
name: Full Project Check
|
||||||
runs-on: ubuntu-latest
|
# Match the label of your self-hosted runner
|
||||||
timeout-minutes: 60
|
runs-on: self-hosted
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
fetch-depth: 50
|
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Enable Nix flakes
|
||||||
run: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
mkdir -p ~/.config/nix
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||||
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
|
||||||
env:
|
# Using nix develop ensures the runner doesn't need flutter/dart/stalwart installed globally.
|
||||||
DAGGER_NO_NAG: "1"
|
# 'task check' runs analyze, unit tests, widget tests, and integration tests.
|
||||||
run: task check-dagger
|
run: nix develop --command task check
|
||||||
|
|
||||||
- name: Cleanup TLS credentials
|
build-linux:
|
||||||
if: always()
|
name: Build Linux Release
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
runs-on: self-hosted
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-playstore:
|
||||||
|
name: Build & Deploy to Play Store
|
||||||
|
runs-on: self-hosted
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Enable Nix flakes
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.config/nix
|
||||||
|
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||||
|
|
||||||
|
- name: Install Android SDK (cached on runner between runs)
|
||||||
|
run: |
|
||||||
|
SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
|
||||||
|
if [ ! -d "$SDK/platforms/android-34" ]; then
|
||||||
|
echo "Android SDK not found, installing..."
|
||||||
|
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
|
||||||
|
mkdir -p "$SDK/cmdline-tools"
|
||||||
|
unzip -q /tmp/cmdtools.zip -d "$SDK/cmdline-tools"
|
||||||
|
[ -d "$SDK/cmdline-tools/cmdline-tools" ] && mv "$SDK/cmdline-tools/cmdline-tools" "$SDK/cmdline-tools/latest"
|
||||||
|
yes | "$SDK/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true
|
||||||
|
"$SDK/cmdline-tools/latest/bin/sdkmanager" "platform-tools" "build-tools;34.0.0" "platforms;android-34"
|
||||||
|
else
|
||||||
|
echo "Android SDK cached, skipping install."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Prepare Keystore
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
|
||||||
|
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks
|
||||||
|
else
|
||||||
|
echo "Error: ANDROID_KEYSTORE_BASE64 secret is not set."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build & Deploy to Play Store
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
|
||||||
|
run: nix develop --command task deploy-android-bundle
|
||||||
@@ -12,21 +12,36 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
name: Build & Deploy Website
|
name: Build & Deploy Website
|
||||||
runs-on: ubuntu-latest
|
runs-on: self-hosted
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Build & Deploy Website
|
- name: Enable Nix flakes
|
||||||
env:
|
run: |
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
mkdir -p ~/.config/nix
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
run: task website-deploy
|
|
||||||
|
|
||||||
- name: Verify Website
|
- name: Setup SSH
|
||||||
env:
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.WEBSITE_SSH_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$SSH_PRIVATE_KEY" ]; then
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
||||||
|
chmod 600 ~/.ssh/id_rsa
|
||||||
|
else
|
||||||
|
echo "Error: WEBSITE_SSH_PRIVATE_KEY secret is not set."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
env:
|
||||||
|
SSH_USER: ${{ secrets.WEBSITE_SSH_USER }}
|
||||||
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
||||||
run: scripts/website-verify.sh
|
run: nix develop --command task website-deploy
|
||||||
|
|
||||||
|
- name: Verify
|
||||||
|
run: nix develop --command task website-verify
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
name: Windows Nightly
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 2 * * *'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
windows-nightly:
|
|
||||||
# Disabled until a self-hosted runner with label "windows-runner" is registered.
|
|
||||||
name: Build & Deploy Windows (Nightly)
|
|
||||||
runs-on: windows-runner
|
|
||||||
if: false
|
|
||||||
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
|
|
||||||
+4
-100
@@ -8,7 +8,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
analyze-and-test:
|
analyze-and-test:
|
||||||
name: Analyze & unit test
|
name: Analyze & unit test
|
||||||
runs-on: sharedinbox-runner
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
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: sharedinbox-runner
|
runs-on: ubuntu-latest
|
||||||
# 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: sharedinbox-runner
|
runs-on: ubuntu-latest
|
||||||
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: sharedinbox-runner
|
runs-on: ubuntu-latest
|
||||||
needs: analyze-and-test
|
needs: analyze-and-test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -151,99 +151,3 @@ 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/"
|
|
||||||
|
|||||||
+1
-16
@@ -3,6 +3,7 @@ coverage/
|
|||||||
.dart_tool/
|
.dart_tool/
|
||||||
.dart-tool/
|
.dart-tool/
|
||||||
.packages
|
.packages
|
||||||
|
pubspec.lock
|
||||||
build/
|
build/
|
||||||
*.g.dart
|
*.g.dart
|
||||||
*.freezed.dart
|
*.freezed.dart
|
||||||
@@ -57,10 +58,6 @@ linux/flutter/generated_plugins.cmake
|
|||||||
.flutter-plugins-dependencies
|
.flutter-plugins-dependencies
|
||||||
.metadata
|
.metadata
|
||||||
|
|
||||||
# --- Python ---
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|
||||||
# --- Tools & Cache ---
|
# --- Tools & Cache ---
|
||||||
.fvm/
|
.fvm/
|
||||||
fvm/
|
fvm/
|
||||||
@@ -101,8 +98,6 @@ 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/
|
||||||
@@ -110,14 +105,4 @@ website/content/builds/[0-9]*/
|
|||||||
.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
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "website/themes/PaperMod"]
|
||||||
|
path = website/themes/PaperMod
|
||||||
|
url = https://github.com/adityatelange/hugo-PaperMod.git
|
||||||
@@ -12,12 +12,6 @@ repos:
|
|||||||
|
|
||||||
- repo: local
|
- 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
|
||||||
@@ -30,15 +24,3 @@ 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,40 +1,5 @@
|
|||||||
# 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".
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
# Dagger CI/CD Setup
|
|
||||||
|
|
||||||
This project has migrated from Taskfile-based CI to **Dagger**. This document explains the infrastructure setup for the shared Dagger Server.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
We use a **Shared Dagger Server** approach for both local development and CI. This allows multiple users to share a single Dagger Engine and its cache, significantly speeding up builds.
|
|
||||||
|
|
||||||
- **Container Engine:** Rootless Podman (managed by the `dagger-svc` user).
|
|
||||||
- **Orchestration:** System-wide `systemd` service.
|
|
||||||
- **Access:** Users connect via TCP (localhost) or Unix Socket.
|
|
||||||
|
|
||||||
## Server Setup (Admin)
|
|
||||||
|
|
||||||
### 1. Dedicated Service User
|
|
||||||
A dedicated user `dagger-svc` owns the Dagger Engine and its cache.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo useradd -m -s /bin/bash dagger-svc
|
|
||||||
sudo loginctl enable-linger dagger-svc
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why Lingering?**
|
|
||||||
Lingering is required for rootless users to maintain a persistent background session. It ensures that `/run/user/<UID>` and the user-level Dagger/Podman namespaces are initialized at boot and remain active even when the user is not logged in.
|
|
||||||
|
|
||||||
### 2. Systemd Service
|
|
||||||
The engine is managed by a system-wide systemd service located at `/etc/systemd/system/dagger-engine.service`.
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=Dagger Engine (Shared Server)
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=dagger-svc
|
|
||||||
Group=dagger-svc
|
|
||||||
WorkingDirectory=/home/dagger-svc
|
|
||||||
# Replace 1003 with the actual UID of dagger-svc
|
|
||||||
Environment=DOCKER_HOST=unix:///run/user/1003/podman/podman.sock
|
|
||||||
Environment=XDG_RUNTIME_DIR=/run/user/1003
|
|
||||||
ExecStart=/usr/bin/nix run github:dagger/nix/v0.11.4#dagger -- engine --addr tcp://0.0.0.0:8080
|
|
||||||
Restart=always
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
## Client Configuration
|
|
||||||
|
|
||||||
To connect to the shared engine, users should set the `_DAGGER_RUNNER_HOST` environment variable.
|
|
||||||
|
|
||||||
### Local Development (.env)
|
|
||||||
The project uses a `.env` file to manage the connection string. Ensure your `.env` contains:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
_DAGGER_RUNNER_HOST=tcp://127.0.0.1:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
Once the environment is set up, you can run the Dagger pipeline. For non-interactive environments (CI, LLMs), use `--progress=plain` for readable logs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nix develop --command dagger call --progress=plain -q -m ci --source=. check
|
|
||||||
```
|
|
||||||
|
|
||||||
## Secrets
|
|
||||||
|
|
||||||
All sensitive credentials are passed as `dagger.Secret` (never as plain strings).
|
|
||||||
This prevents values from appearing in Dagger logs or being cached in the engine.
|
|
||||||
|
|
||||||
| Parameter | Functions |
|
|
||||||
|---|---|
|
|
||||||
| `sshKey *dagger.Secret` | `Deployer`, `GenerateBuildHistory`, `BuildWebsite`, `PublishWebsite`, `DeployLinux`, `DeployApk` |
|
|
||||||
| `keystoreBase64 *dagger.Secret` | `setupKeystore`, `BuildAndroidApk`, `DeployApk`, `SignAndroidBundle`, `PublishAndroid` |
|
|
||||||
| `keystorePassword *dagger.Secret` | same as above |
|
|
||||||
| `playStoreConfig *dagger.Secret` | `UploadToPlayStore`, `PublishAndroid` |
|
|
||||||
| `serviceAccountKey *dagger.Secret` | `TestAndroidFirebase` |
|
|
||||||
|
|
||||||
Secrets are injected via `WithMountedSecret` (file-based, e.g. SSH key) or
|
|
||||||
`WithSecretVariable` (env-var-based, e.g. keystore data, Play Store JSON).
|
|
||||||
|
|
||||||
The only credentials not typed as `dagger.Secret` are the test passwords
|
|
||||||
(`STALWART_PASS_B`, `STALWART_PASS_C`) in `WithStalwart`. These are hardcoded
|
|
||||||
development values defined in `stalwart-dev/` — not production secrets.
|
|
||||||
|
|
||||||
## CI Integration (Codeberg/Forgejo)
|
|
||||||
|
|
||||||
The CI workflow in `.forgejo/workflows/ci.yml` is configured to use the Dagger module located in the `ci/` directory.
|
|
||||||
|
|
||||||
- **Check Suite:** Runs analysis and tests in parallel.
|
|
||||||
- **Builds:** Produces Linux and Android artifacts.
|
|
||||||
- **Caching:** When using the shared engine, CI runners benefit from the persistent cache on the host.
|
|
||||||
-190
@@ -1,190 +0,0 @@
|
|||||||
# Development Environment Setup
|
|
||||||
|
|
||||||
This document explains how to set up a development environment for SharedInbox.
|
|
||||||
|
|
||||||
## ⚠️ Security Recommendation: Use a Dedicated Linux User
|
|
||||||
|
|
||||||
For enhanced security, especially when working with autonomous coding agents (like Gemini CLI in YOLO mode), we **strongly recommend** using a dedicated Linux user for this project. This isolates the project environment and prevents any potential accidental damage to your main system.
|
|
||||||
|
|
||||||
### 1. Create a Dedicated User
|
|
||||||
|
|
||||||
Set the user name variable (default is `si` for SharedInbox):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export DEV_USER=si
|
|
||||||
```
|
|
||||||
|
|
||||||
Create the user and add them to the `sudo` group:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo adduser --disabled-password newuser $DEV_USER
|
|
||||||
```
|
|
||||||
|
|
||||||
Set up SSH public key login (replace with your actual public key):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo mkdir -p /home/$DEV_USER/.ssh
|
|
||||||
sudo chmod 700 /home/$DEV_USER/.ssh
|
|
||||||
echo "ssh-ed25519 AAAA... your-key-comment" | sudo tee /home/$DEV_USER/.ssh/authorized_keys
|
|
||||||
sudo chmod 600 /home/$DEV_USER/.ssh/authorized_keys
|
|
||||||
sudo chown -R $DEV_USER:$DEV_USER /home/$DEV_USER/.ssh
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Switch to the Dedicated User
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh $DEV_USER@localhost
|
|
||||||
```
|
|
||||||
|
|
||||||
### Create ssh-keypair
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh-keygen
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Clone the Repository
|
|
||||||
|
|
||||||
Clone the project into your new user's home directory:
|
|
||||||
|
|
||||||
```bash^
|
|
||||||
git clone ssh://git@codeberg.org/guettli/sharedinbox.git
|
|
||||||
|
|
||||||
# Move git directory into $HOME
|
|
||||||
# This user only works on the git repo. Avoid "cd sharedinbox" after each login...
|
|
||||||
mv sharedinbox/* .
|
|
||||||
mv sharedinbox/.??* .
|
|
||||||
rmdir sharedinbox/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3b. Configure Git Identity
|
|
||||||
|
|
||||||
The new user needs a Git identity for commits and some scripts:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git config --global user.name "Your Name"
|
|
||||||
git config --global user.email "your.email@example.com"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Install System Dependencies
|
|
||||||
|
|
||||||
This project uses **Nix** with flakes to manage its toolchain (Flutter, Dart, Stalwart, etc.).
|
|
||||||
|
|
||||||
```
|
|
||||||
mkdir -p .config/nix
|
|
||||||
|
|
||||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
|
||||||
|
|
||||||
nix profile add nixpkgs#direnv
|
|
||||||
|
|
||||||
nix profile add nixpkgs#nix-direnv
|
|
||||||
|
|
||||||
echo 'eval "$(direnv hook bash)"' >> ~/.bashrc
|
|
||||||
source ~/.bashrc
|
|
||||||
|
|
||||||
.config/direnv/direnv.toml
|
|
||||||
```
|
|
||||||
[global]
|
|
||||||
hide_env_diff = true
|
|
||||||
#log_filter = "^$"
|
|
||||||
|
|
||||||
[whitelist]
|
|
||||||
prefix = [ "/home/DEV_USER-CHANGE_THAT" ]
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 4b. Additional Permissions (GUI & Android)
|
|
||||||
|
|
||||||
1. **GUI Access**: To run the Linux app (`task run`) from the `si` user, you must allow it to access your X server. Run this **from your main user terminal**:
|
|
||||||
```bash
|
|
||||||
xhost +local:$DEV_USER
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Android Emulator (KVM)**: If you plan to use the Android emulator, add the user to the `kvm` group:
|
|
||||||
```bash
|
|
||||||
sudo usermod -aG kvm $DEV_USER
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Project Setup
|
|
||||||
|
|
||||||
Once you are in the project directory and have the dependencies installed:
|
|
||||||
|
|
||||||
1. **Initialize Environment**:
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Allow direnv**:
|
|
||||||
```bash
|
|
||||||
direnv allow
|
|
||||||
```
|
|
||||||
*This will trigger Nix to download and set up the environment (Flutter, Android SDK, etc.). It might take some time on the first run.*
|
|
||||||
|
|
||||||
3. **Install Flutter (via FVM)**:
|
|
||||||
Nix provides FVM, which manages the pinned Flutter version.
|
|
||||||
```bash
|
|
||||||
fvm install
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Initial Setup**:
|
|
||||||
Run the comprehensive setup command which handles `pub get`, code generation, and git hooks:
|
|
||||||
```bash
|
|
||||||
task setup
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Verify the Setup
|
|
||||||
|
|
||||||
Run the full check suite to ensure everything is working correctly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
task check
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Running the App
|
|
||||||
|
|
||||||
To run the app on your Linux desktop:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
task run
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Working with VS Code
|
|
||||||
|
|
||||||
To maintain isolation, it is recommended to run VS Code "remotely" on the dedicated development user.
|
|
||||||
|
|
||||||
### Preferred Method: VS Code Remote - SSH
|
|
||||||
|
|
||||||
The most robust way to work with a separate user is using the **VS Code Remote - SSH** extension. This allows you to run the VS Code Server as the `si` user while using your main user's GUI.
|
|
||||||
|
|
||||||
1. **Install the Extension**: Install "Remote - SSH" from the VS Code Marketplace.
|
|
||||||
2. **Enable SSH for the Dev User**:
|
|
||||||
From your main user, copy your SSH public key to the dev user:
|
|
||||||
```bash
|
|
||||||
# As your main user:
|
|
||||||
sudo mkdir -p /home/$DEV_USER/.ssh
|
|
||||||
sudo cp ~/.ssh/id_rsa.pub /home/$DEV_USER/.ssh/authorized_keys
|
|
||||||
sudo chown -R $DEV_USER:$DEV_USER /home/$DEV_USER/.ssh
|
|
||||||
sudo chmod 700 /home/$DEV_USER/.ssh
|
|
||||||
sudo chmod 600 /home/$DEV_USER/.ssh/authorized_keys
|
|
||||||
```
|
|
||||||
3. **Connect**:
|
|
||||||
In VS Code, open the Command Palette (`Ctrl+Shift+P`) and select `Remote-SSH: Connect to Host...`.
|
|
||||||
Enter: `si@localhost` (or `$DEV_USER@localhost`).
|
|
||||||
4. **Install Extensions in the Remote**:
|
|
||||||
Once connected, you will need to install the following extensions *on the remote user*:
|
|
||||||
* **Dart** / **Flutter**
|
|
||||||
* **direnv**: (by mkhl) Highly recommended to automatically load the Nix environment inside VS Code.
|
|
||||||
* **Nix IDE**: For syntax highlighting.
|
|
||||||
|
|
||||||
### Why SSH?
|
|
||||||
Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks. It provides a clean boundary for the VS Code process and any integrated terminal or coding agents, ensuring they cannot access your personal files in `/home/$YOUR_USER`.
|
|
||||||
|
|
||||||
> **Note on Security:** While these instructions add the user to the `sudo` group for convenience during setup, you can remove it later with `sudo gpasswd -d $DEV_USER sudo` to further restrict the user and any coding agents.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Daily Workflow
|
|
||||||
|
|
||||||
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# 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`).
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
# Email Sync Architecture
|
|
||||||
|
|
||||||
This document describes the full lifecycle of an email action — from the moment the user taps
|
|
||||||
a button to server confirmation — covering the IMAP IDLE loop, JMAP push/poll, the pending-change
|
|
||||||
queue, exponential backoff, and the undo/cancel mechanism.
|
|
||||||
|
|
||||||
For the database schema and protocol-level implementation details see [DB-SYNC.md](DB-SYNC.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Components
|
|
||||||
|
|
||||||
| Component | File | Role |
|
|
||||||
|-----------|------|------|
|
|
||||||
| `AccountSyncManager` | `lib/core/sync/account_sync_manager.dart` | Owns one `_SyncLoop` per account; starts, stops, and wakes sync loops |
|
|
||||||
| `_AccountSync` | same file | IMAP sync loop (IDLE + incremental fetch) |
|
|
||||||
| `_JmapAccountSync` | same file | JMAP sync loop (SSE push + poll fallback) |
|
|
||||||
| `EmailRepositoryImpl` | `lib/data/repositories/email_repository_impl.dart` | All DB reads/writes and network calls |
|
|
||||||
| `pending_changes` table | `lib/data/db/database.dart` | Protocol-agnostic outbound mutation queue |
|
|
||||||
| `UndoService` | `lib/core/services/undo_service.dart` | Persisted undo history; cancel-or-reverse logic |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Lifecycle of an email mutation (e.g. "Mark as read")
|
|
||||||
|
|
||||||
```
|
|
||||||
User taps "Mark as read"
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
EmailRepository.setFlag(id, seen: true)
|
|
||||||
│
|
|
||||||
├─ 1. Write optimistic update to local DB
|
|
||||||
│ emails.is_seen = true
|
|
||||||
│
|
|
||||||
└─ 2. Insert row into pending_changes
|
|
||||||
{ type: 'flag_seen', email_id: id, payload: {seen: true} }
|
|
||||||
(IMAP: includes uid + mailboxPath for the STORE command)
|
|
||||||
(JMAP: includes just the flag map for Email/set)
|
|
||||||
|
|
||||||
[UI immediately reflects the change via Drift's reactive streams]
|
|
||||||
|
|
||||||
│
|
|
||||||
▼ (next sync cycle, triggered by IMAP IDLE / JMAP push / wakeUp)
|
|
||||||
_SyncLoop._flush() / flushPendingChanges()
|
|
||||||
│
|
|
||||||
├─ IMAP: open connection → STORE uid +FLAGS (\Seen) → close
|
|
||||||
│
|
|
||||||
└─ JMAP: Email/set { update: { id: { keywords: { "$seen": true } } } }
|
|
||||||
If stateMismatch → clear checkpoint → full re-sync
|
|
||||||
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
pending_changes row deleted on success
|
|
||||||
(on permanent error: retry count incremented; evicted after 5 failures)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. IMAP sync loop
|
|
||||||
|
|
||||||
The IMAP loop runs one coroutine per account (`_AccountSync`):
|
|
||||||
|
|
||||||
```
|
|
||||||
start()
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
[forever loop]
|
|
||||||
├─ flushPendingChanges() ← drain outbound queue first
|
|
||||||
├─ syncMailboxes() ← detect new/removed mailboxes
|
|
||||||
├─ for each mailbox:
|
|
||||||
│ syncEmails() ← incremental: fetch only UIDs > lastUid
|
|
||||||
│ deletion reconciliation: remove rows
|
|
||||||
│ whose UID is absent from the server
|
|
||||||
└─ _idle() ← IMAP IDLE for up to 25 min (RFC 2177)
|
|
||||||
│ Wakes on: server EXISTS/EXPUNGE/FLAGS
|
|
||||||
│ or syncNow() signal from UI
|
|
||||||
└─ repeat
|
|
||||||
```
|
|
||||||
|
|
||||||
**Incremental sync checkpoint** — `sync_state` table stores `(accountId, mailbox, lastUid, uidValidity)`.
|
|
||||||
On each run, only UIDs greater than `lastUid` are fetched. If `uidValidity` changes the full
|
|
||||||
folder is re-scanned and the checkpoint is reset.
|
|
||||||
|
|
||||||
**IDLE cap** — IDLE sessions are limited to 25 minutes per the RFC. The loop also wakes
|
|
||||||
immediately if `syncNow()` is called (e.g. user pulls-to-refresh).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. JMAP sync loop
|
|
||||||
|
|
||||||
The JMAP loop (`_JmapAccountSync`) follows a similar structure but uses HTTP:
|
|
||||||
|
|
||||||
```
|
|
||||||
start()
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
[forever loop]
|
|
||||||
├─ flushPendingChanges() ← Email/set for queued mutations
|
|
||||||
├─ syncMailboxes() ← Mailbox/get or Mailbox/changes
|
|
||||||
├─ for each mailbox:
|
|
||||||
│ syncEmails() ← Email/query + Email/get (first run)
|
|
||||||
│ Email/changes (subsequent runs, state token)
|
|
||||||
└─ _wait()
|
|
||||||
├─ If server advertises eventSourceUrl: subscribe to SSE push
|
|
||||||
│ wake on "Email" change event
|
|
||||||
└─ Otherwise: sleep 30 s (poll fallback)
|
|
||||||
```
|
|
||||||
|
|
||||||
**State tokens** — each `Mailbox/changes` / `Email/changes` call uses the server-provided
|
|
||||||
`state` token stored in `sync_state`. A `stateMismatch` error clears the token and triggers
|
|
||||||
a full re-fetch.
|
|
||||||
|
|
||||||
**JMAP send** — outgoing mail uses `EmailSubmission/set` when the server advertises the
|
|
||||||
`urn:ietf:params:jmap:submission` capability; falls back to SMTP otherwise.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Exponential backoff
|
|
||||||
|
|
||||||
Both loops share the same backoff policy:
|
|
||||||
|
|
||||||
| Outcome | Backoff |
|
|
||||||
|---------|---------|
|
|
||||||
| Sync succeeded | Reset to 5 s |
|
|
||||||
| Network / server error | Double previous backoff, capped at 900 s (15 min) |
|
|
||||||
|
|
||||||
The backoff counter (`_backoffSeconds`) is per-account and per-process; it resets to 5 s
|
|
||||||
on the next successful cycle.
|
|
||||||
|
|
||||||
The last error message is written to `sync_log` and surfaced in the UI via
|
|
||||||
`syncLastErrorProvider` (the red `MaterialBanner` in the email list).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Pending-change queue
|
|
||||||
|
|
||||||
`pending_changes` is a protocol-agnostic table that stores every outbound mutation before it
|
|
||||||
reaches the server:
|
|
||||||
|
|
||||||
| Column | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `id` | Auto-increment primary key |
|
|
||||||
| `email_id` | The email being mutated |
|
|
||||||
| `type` | `flag_seen`, `flag_flagged`, `move`, `delete`, `snooze` |
|
|
||||||
| `payload` | JSON-encoded protocol-specific arguments |
|
|
||||||
| `retry_count` | Incremented on each failed flush attempt |
|
|
||||||
| `created_at` | For ordering and debug |
|
|
||||||
|
|
||||||
**Optimistic UI** — every mutation writes the local change first, then inserts into
|
|
||||||
`pending_changes`. The Drift reactive stream delivers the update to the UI before
|
|
||||||
the network round-trip completes.
|
|
||||||
|
|
||||||
**Conflict resolution** — the server always wins. On the next sync cycle the server's
|
|
||||||
state overwrites local rows. Outbound mutations are retried up to 5 times; after that
|
|
||||||
they are evicted and a `FailedMutation` record is created. Permanent per-item JMAP
|
|
||||||
errors (`notFound`, `forbidden`) skip the retry counter and evict immediately.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Undo and cancel
|
|
||||||
|
|
||||||
When the user triggers an undoable action the UI calls:
|
|
||||||
```
|
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(UndoAction(...))
|
|
||||||
```
|
|
||||||
|
|
||||||
`UndoService` persists the action to the `undo_actions` table (max 10 entries, FIFO).
|
|
||||||
A `SnackBar` with an **Undo** button appears for a few seconds.
|
|
||||||
|
|
||||||
When the user taps Undo, `UndoService.undo()` executes this sequence for each affected email:
|
|
||||||
|
|
||||||
```
|
|
||||||
1. cancelPendingChange(id, originalType)
|
|
||||||
└─ Deletes the pending_changes row if it has not been flushed yet.
|
|
||||||
Returns true if cancelled, false if the server already processed it.
|
|
||||||
|
|
||||||
2. If the email row was hard-deleted (DELETE action):
|
|
||||||
restoreEmails([original])
|
|
||||||
└─ Re-inserts the row with its pre-deletion state,
|
|
||||||
placed in the correct mailbox (source if cancelled, dest otherwise).
|
|
||||||
|
|
||||||
3. moveEmail(id, sourceMailboxPath)
|
|
||||||
└─ Optimistic local move back to the original folder.
|
|
||||||
If step 1 returned false (already sent to server), this enqueues
|
|
||||||
a reverse-move in pending_changes so the server move is undone too.
|
|
||||||
|
|
||||||
4. If step 1 returned true (cancelled before flush):
|
|
||||||
cancelPendingChange(id, 'move')
|
|
||||||
└─ The reverse-move from step 3 is redundant; remove it.
|
|
||||||
```
|
|
||||||
|
|
||||||
The net result is: if the mutation was still in the queue it is silently cancelled with no
|
|
||||||
server round-trip; if it had already been flushed, a compensating move is queued.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Key invariants
|
|
||||||
|
|
||||||
- **Order**: pending changes are flushed before syncing. This prevents the server from
|
|
||||||
overwriting an optimistic local state that the server hasn't seen yet.
|
|
||||||
- **Idempotency**: `flushPendingChanges` is safe to call multiple times. Each row is
|
|
||||||
deleted only after the server acknowledges the change.
|
|
||||||
- **No silent data loss**: permanent server errors surface as `FailedMutation` records
|
|
||||||
visible in the UI (Settings → Failed mutations).
|
|
||||||
- **UI layer isolation**: `lib/ui/` never imports `lib/data/`; all interaction goes
|
|
||||||
through `core/` interfaces. The `check-layers` Taskfile task enforces this.
|
|
||||||
+23
-291
@@ -1,9 +1,6 @@
|
|||||||
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)
|
||||||
@@ -125,16 +122,6 @@ 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]
|
||||||
@@ -174,146 +161,23 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- fvm flutter test
|
- fvm flutter test
|
||||||
|
|
||||||
test-backend:
|
integration:
|
||||||
desc: Backend tests against a local Stalwart mail server (via Dagger)
|
desc: Integration tests against a local Stalwart mail server
|
||||||
|
deps: [_flutter-check]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- test/integration/**/*.dart
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. test-backend
|
- stalwart-dev/test.sh
|
||||||
|
|
||||||
integration-ui:
|
integration-ui:
|
||||||
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
|
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed
|
||||||
|
deps: [_preflight, _linux-deps-check, _pub-get]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- integration_test/app_e2e_test.dart
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. test-integration
|
- stalwart-dev/integration_ui_test.sh
|
||||||
|
|
||||||
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)
|
||||||
@@ -352,79 +216,7 @@ 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 --dart-define=GIT_HASH=$(git rev-parse --short HEAD)
|
- scripts/silent_on_success.sh fvm flutter build linux --release --no-pub
|
||||||
|
|
||||||
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:
|
||||||
@@ -474,19 +266,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 --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||||
|
|
||||||
deploy-android-bundle:
|
deploy-android-bundle:
|
||||||
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
desc: Build release AAB and upload to Play Store internal track
|
||||||
deps: [build-android-bundle-local]
|
deps: [build-android-bundle]
|
||||||
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-local:
|
build-android-bundle:
|
||||||
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
desc: Build a release App Bundle (AAB)
|
||||||
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
||||||
method: timestamp
|
method: timestamp
|
||||||
sources:
|
sources:
|
||||||
@@ -496,7 +288,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) --build-name $(date +%y%m%d-%H%M) --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build appbundle --release --no-pub --build-number $(date +%s) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||||
|
|
||||||
deploy-android:
|
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
|
||||||
@@ -539,12 +331,6 @@ 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:
|
||||||
@@ -564,73 +350,19 @@ 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+widget tests + coverage gate (no build, no integration)
|
desc: Pre-commit checks — analyze + unit tests + widget tests (no build, no integration)
|
||||||
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks]
|
deps: [analyze, test, check-hygiene]
|
||||||
|
|
||||||
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
|
||||||
@@ -650,7 +382,7 @@ tasks:
|
|||||||
internal: true
|
internal: true
|
||||||
run: once
|
run: once
|
||||||
cmds:
|
cmds:
|
||||||
- task: test-backend
|
- task: integration
|
||||||
- task: integration-ui
|
- task: integration-ui
|
||||||
|
|
||||||
ci-logs:
|
ci-logs:
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ android {
|
|||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
isCoreLibraryDesugaringEnabled = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
@@ -36,7 +35,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 = 23
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
@@ -66,8 +65,6 @@ 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,10 +1,5 @@
|
|||||||
<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,3 +1,2 @@
|
|||||||
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
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
/dagger.gen.go linguist-generated
|
|
||||||
/internal/dagger/** linguist-generated
|
|
||||||
/internal/querybuilder/** linguist-generated
|
|
||||||
/internal/telemetry/** linguist-generated
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
/dagger.gen.go
|
|
||||||
/internal/dagger
|
|
||||||
/internal/querybuilder
|
|
||||||
/internal/telemetry
|
|
||||||
/.env
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "ci",
|
|
||||||
"engineVersion": "v0.20.8",
|
|
||||||
"sdk": {
|
|
||||||
"source": "go"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
module dagger/ci
|
|
||||||
|
|
||||||
go 1.26.2
|
|
||||||
|
|
||||||
require (
|
|
||||||
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72
|
|
||||||
github.com/Khan/genqlient v0.8.1
|
|
||||||
github.com/dagger/otel-go v1.43.0
|
|
||||||
github.com/vektah/gqlparser/v2 v2.5.33
|
|
||||||
go.opentelemetry.io/otel v1.43.0
|
|
||||||
go.opentelemetry.io/otel/trace v1.43.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/99designs/gqlgen v0.17.90 // indirect
|
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
|
||||||
github.com/sosodev/duration v1.4.0 // indirect
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.17.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.17.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/log v0.17.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0
|
|
||||||
go.opentelemetry.io/otel/sdk/log v0.17.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
|
||||||
golang.org/x/net v0.52.0 // indirect
|
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
|
||||||
golang.org/x/text v0.35.0 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
|
||||||
google.golang.org/grpc v1.79.3 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
|
||||||
)
|
|
||||||
|
|
||||||
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0
|
|
||||||
|
|
||||||
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0
|
|
||||||
|
|
||||||
replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.16.0
|
|
||||||
|
|
||||||
replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.16.0
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72 h1:s39e07WvaUU6tLhpojK8ZEIoIbOSn5hHOJra0waenxQ=
|
|
||||||
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72/go.mod h1:ZXg8+pQZaZUC8rAw4V/gPP8aKvKARIJZ+pfcV+RC1es=
|
|
||||||
github.com/99designs/gqlgen v0.17.90 h1:wSv6blm/PoplU6QoNw83EcQpNtC0HX3/+44vITJOzpk=
|
|
||||||
github.com/99designs/gqlgen v0.17.90/go.mod h1:GqYrEwYsqCG8VaOsq2kJUCUKwAE1T+u2i+Nj7NtXiVI=
|
|
||||||
github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs=
|
|
||||||
github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU=
|
|
||||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
|
||||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
|
||||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
|
||||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
|
||||||
github.com/dagger/otel-go v1.43.0 h1:AYCnAamWmxtSxigWPTgC+8EWqiWPcDZEegh8y05gdJ8=
|
|
||||||
github.com/dagger/otel-go v1.43.0/go.mod h1:83CTuXi70zcx1kaym5buqmb7RNzg1E9dEiQSFyLbLdU=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
|
||||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
|
||||||
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
|
|
||||||
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
|
||||||
github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E=
|
|
||||||
github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
|
||||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
|
||||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 h1:VO3BL6OZXRQ1yQc8W6EVfJzINeJ35BkiHx4MYfoQf44=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0/go.mod h1:qRDnJ2nv3CQXMK2HUd9K9VtvedsPAce3S+/4LZHjX/s=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 h1:MMrOAN8H1FrvDyq9UJ4lu5/+ss49Qgfgb7Zpm0m8ABo=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0/go.mod h1:Na+2NNASJtF+uT4NxDe0G+NQb+bUgdPDfwxY/6JmS/c=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
|
|
||||||
go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
|
|
||||||
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
|
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
|
||||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
|
||||||
go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
|
|
||||||
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
|
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
|
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
|
||||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|
||||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
|
||||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
|
||||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
-848
@@ -1,848 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"dagger/ci/internal/dagger"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/sync/errgroup"
|
|
||||||
)
|
|
||||||
|
|
||||||
// patchAabScript patches android:versionCode in an AAB's compiled manifest proto.
|
|
||||||
// It strips META-INF/ (old signature) and repacks the ZIP. No external dependencies.
|
|
||||||
const patchAabScript = `#!/usr/bin/env python3
|
|
||||||
import sys, zipfile
|
|
||||||
|
|
||||||
MANIFEST = "base/manifest/AndroidManifest.xml"
|
|
||||||
VERSION_CODE_RID = 0x0101021b
|
|
||||||
|
|
||||||
def _vr(b, p):
|
|
||||||
n = s = 0
|
|
||||||
while True:
|
|
||||||
c = b[p]; p += 1; n |= (c & 127) << s
|
|
||||||
if not (c & 128): return n, p
|
|
||||||
s += 7
|
|
||||||
|
|
||||||
def _ve(n):
|
|
||||||
r = []
|
|
||||||
while n > 127: r.append((n & 127) | 128); n >>= 7
|
|
||||||
return bytes(r + [n])
|
|
||||||
|
|
||||||
def _parse(d):
|
|
||||||
p = 0
|
|
||||||
while p < len(d):
|
|
||||||
tag, p = _vr(d, p); fn, wt = tag >> 3, tag & 7
|
|
||||||
if wt == 0: v, p = _vr(d, p); yield fn, 0, v
|
|
||||||
elif wt == 2: ln, p = _vr(d, p); yield fn, 2, d[p:p+ln]; p += ln
|
|
||||||
elif wt == 5: yield fn, 5, d[p:p+4]; p += 4 # fixed32
|
|
||||||
elif wt == 1: yield fn, 1, d[p:p+8]; p += 8 # fixed64
|
|
||||||
else: raise ValueError(f"wire type {wt}")
|
|
||||||
|
|
||||||
def _enc(fn, wt, v):
|
|
||||||
t = _ve((fn << 3) | wt)
|
|
||||||
if wt == 0: return t + _ve(v)
|
|
||||||
if wt in (1, 5): return t + v # fixed-width, pass bytes as-is
|
|
||||||
return t + _ve(len(v)) + v
|
|
||||||
|
|
||||||
def _patch_prim(d, vc):
|
|
||||||
# Patch int_decimal_value (field 6) or int_hexadecimal_value (field 7),
|
|
||||||
# whichever is present — AAPT2 may use either.
|
|
||||||
out = bytearray()
|
|
||||||
for fn, wt, v in _parse(d):
|
|
||||||
out += _enc(fn, 0, vc) if (fn in (6, 7) and wt == 0) else _enc(fn, wt, v)
|
|
||||||
return bytes(out)
|
|
||||||
|
|
||||||
def _patch_item(d, vc):
|
|
||||||
out = bytearray()
|
|
||||||
for fn, wt, v in _parse(d):
|
|
||||||
out += _enc(7, 2, _patch_prim(v, vc)) if fn == 7 else _enc(fn, wt, v)
|
|
||||||
return bytes(out)
|
|
||||||
|
|
||||||
def _has_rid(d):
|
|
||||||
return any(fn == 5 and wt == 0 and v == VERSION_CODE_RID for fn, wt, v in _parse(d))
|
|
||||||
|
|
||||||
def _patch_attr(d, vc):
|
|
||||||
out = bytearray()
|
|
||||||
for fn, wt, v in _parse(d):
|
|
||||||
if fn == 3 and wt == 2: out += _enc(3, 2, str(vc).encode())
|
|
||||||
elif fn == 6 and wt == 2: out += _enc(6, 2, _patch_item(v, vc))
|
|
||||||
else: out += _enc(fn, wt, v)
|
|
||||||
return bytes(out)
|
|
||||||
|
|
||||||
def _patch_elem(d, vc):
|
|
||||||
out = bytearray()
|
|
||||||
for fn, wt, v in _parse(d):
|
|
||||||
out += _enc(4, 2, _patch_attr(v, vc)) if (fn == 4 and _has_rid(v)) else _enc(fn, wt, v)
|
|
||||||
return bytes(out)
|
|
||||||
|
|
||||||
def _patch_node(d, vc):
|
|
||||||
out = bytearray()
|
|
||||||
for fn, wt, v in _parse(d):
|
|
||||||
out += _enc(1, 2, _patch_elem(v, vc)) if fn == 1 else _enc(fn, wt, v)
|
|
||||||
return bytes(out)
|
|
||||||
|
|
||||||
def _dump_proto(d, depth=0, limit=3):
|
|
||||||
"""Print proto field structure for debugging."""
|
|
||||||
pad = " " * depth
|
|
||||||
for fn, wt, v in _parse(d):
|
|
||||||
if wt == 0:
|
|
||||||
print(f"{pad}[{fn}] varint={v} (0x{v:x})")
|
|
||||||
elif wt == 2:
|
|
||||||
print(f"{pad}[{fn}] bytes len={len(v)}")
|
|
||||||
if depth < limit:
|
|
||||||
_dump_proto(v, depth + 1, limit)
|
|
||||||
elif wt == 5:
|
|
||||||
print(f"{pad}[{fn}] fixed32={v.hex()}")
|
|
||||||
elif wt == 1:
|
|
||||||
print(f"{pad}[{fn}] fixed64={v.hex()}")
|
|
||||||
|
|
||||||
def _read_vc_from_node(d):
|
|
||||||
"""Read versionCode from XmlNode proto bytes. Returns int or None."""
|
|
||||||
for fn, wt, v in _parse(d):
|
|
||||||
if fn == 1 and wt == 2: # XmlElement
|
|
||||||
for efn, ewt, attr in _parse(v):
|
|
||||||
if efn == 4 and ewt == 2 and _has_rid(attr): # XmlAttribute with versionCode RID
|
|
||||||
for afn, awt, item in _parse(attr):
|
|
||||||
if afn == 6 and awt == 2: # compiled_value (Item)
|
|
||||||
for ifn, iwt, prim in _parse(item):
|
|
||||||
if ifn == 7 and iwt == 2: # prim (Primitive)
|
|
||||||
for pfn, pwt, pv in _parse(prim):
|
|
||||||
if pfn in (6, 7) and pwt == 0:
|
|
||||||
return pv
|
|
||||||
return None
|
|
||||||
|
|
||||||
def patch(src, dst, vc):
|
|
||||||
with zipfile.ZipFile(src) as z:
|
|
||||||
mf = z.read(MANIFEST)
|
|
||||||
|
|
||||||
orig_vc = _read_vc_from_node(mf)
|
|
||||||
if orig_vc is None:
|
|
||||||
print("DEBUG: could not find versionCode — dumping manifest proto structure:")
|
|
||||||
_dump_proto(mf, limit=4)
|
|
||||||
sys.exit(f"ERROR: versionCode not found in {MANIFEST}")
|
|
||||||
print(f"Original versionCode in manifest: {orig_vc}")
|
|
||||||
|
|
||||||
patched = _patch_node(mf, vc)
|
|
||||||
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, 'w') as zout:
|
|
||||||
for info in zin.infolist():
|
|
||||||
if info.filename.startswith('META-INF/'):
|
|
||||||
continue # strip old signature; jarsigner re-signs after
|
|
||||||
d = patched if info.filename == MANIFEST else zin.read(info.filename)
|
|
||||||
zi = zipfile.ZipInfo(info.filename, info.date_time)
|
|
||||||
zi.compress_type = info.compress_type
|
|
||||||
zi.external_attr = info.external_attr
|
|
||||||
zout.writestr(zi, d)
|
|
||||||
|
|
||||||
# Verify the patch actually took effect
|
|
||||||
with zipfile.ZipFile(dst) as z:
|
|
||||||
actual = _read_vc_from_node(z.read(MANIFEST))
|
|
||||||
if actual != vc:
|
|
||||||
sys.exit(f"ERROR: versionCode patch failed — wrote {vc} but read back {actual} (original was {orig_vc})")
|
|
||||||
print(f"versionCode={actual} -> {dst}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) != 4:
|
|
||||||
sys.exit(f"usage: {sys.argv[0]} in.aab out.aab versionCode")
|
|
||||||
patch(sys.argv[1], sys.argv[2], int(sys.argv[3]))
|
|
||||||
`
|
|
||||||
|
|
||||||
type Ci struct {
|
|
||||||
Source *dagger.Directory
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(
|
|
||||||
// +defaultPath=".."
|
|
||||||
source *dagger.Directory,
|
|
||||||
) *Ci {
|
|
||||||
return &Ci{
|
|
||||||
Source: source.Filter(dagger.DirectoryFilterOpts{
|
|
||||||
Include: []string{
|
|
||||||
"lib/",
|
|
||||||
"test/",
|
|
||||||
"assets/",
|
|
||||||
"scripts/",
|
|
||||||
"pubspec.yaml",
|
|
||||||
"pubspec.lock",
|
|
||||||
"analysis_options.yaml",
|
|
||||||
"linux/",
|
|
||||||
"android/",
|
|
||||||
"integration_test/",
|
|
||||||
"drift_schemas/",
|
|
||||||
"stalwart-dev/",
|
|
||||||
"website/",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// toolchain returns the Flutter+Android toolchain without any mutable cache mounts.
|
|
||||||
// Its execution cache key is stable until the image, apt packages, or SDK versions change.
|
|
||||||
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
|
|
||||||
func (m *Ci) toolchain() *dagger.Container {
|
|
||||||
return dag.Container().
|
|
||||||
From("ghcr.io/cirruslabs/flutter:3.41.6").
|
|
||||||
WithExec([]string{"apt-get", "-qq", "update"}).
|
|
||||||
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
|
|
||||||
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
|
||||||
WithExec([]string{"/bin/sh", "-c",
|
|
||||||
`flutter_dir=$(dirname $(dirname $(which flutter))); ` +
|
|
||||||
`chown -R ci:ci "$flutter_dir"; ` +
|
|
||||||
`[ -n "$ANDROID_HOME" ] && chown -R ci:ci "$ANDROID_HOME" || true; ` +
|
|
||||||
`mkdir -p /src && chown ci:ci /src`}).
|
|
||||||
WithEnvVariable("PUB_CACHE", "/home/ci/.pub-cache").
|
|
||||||
WithEnvVariable("HOME", "/home/ci").
|
|
||||||
WithUser("ci").
|
|
||||||
WithExec([]string{"/bin/sh", "-c",
|
|
||||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
|
||||||
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
` + "```"
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Minimal OTLP HTTP/protobuf trace receiver for Dagger CI timing.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python3 ci/otel-receiver.py --port-file=/tmp/otel.port
|
|
||||||
|
|
||||||
Caller sets:
|
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:<port>
|
|
||||||
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import signal
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
||||||
|
|
||||||
|
|
||||||
# ── Minimal protobuf binary decoder ─────────────────────────────────────────
|
|
||||||
# Only decodes the fields we need; skips everything else safely.
|
|
||||||
|
|
||||||
def _varint(buf, pos):
|
|
||||||
n, shift = 0, 0
|
|
||||||
while pos < len(buf):
|
|
||||||
b = buf[pos]; pos += 1
|
|
||||||
n |= (b & 0x7F) << shift
|
|
||||||
shift += 7
|
|
||||||
if not (b & 0x80):
|
|
||||||
return n, pos
|
|
||||||
raise ValueError("truncated varint")
|
|
||||||
|
|
||||||
|
|
||||||
def _fields(buf):
|
|
||||||
"""Yield (field_num, wire_type, raw_value) for each field in a message."""
|
|
||||||
pos = 0
|
|
||||||
while pos < len(buf):
|
|
||||||
tag, pos = _varint(buf, pos)
|
|
||||||
wt, fn = tag & 7, tag >> 3
|
|
||||||
if wt == 0: # varint
|
|
||||||
v, pos = _varint(buf, pos)
|
|
||||||
elif wt == 1: # fixed64
|
|
||||||
v = struct.unpack_from("<Q", buf, pos)[0]; pos += 8
|
|
||||||
elif wt == 2: # length-delimited
|
|
||||||
n, pos = _varint(buf, pos)
|
|
||||||
v = buf[pos:pos + n]; pos += n
|
|
||||||
elif wt == 5: # fixed32
|
|
||||||
v = struct.unpack_from("<I", buf, pos)[0]; pos += 4
|
|
||||||
else:
|
|
||||||
break # unknown: stop
|
|
||||||
yield fn, wt, v
|
|
||||||
|
|
||||||
|
|
||||||
def _any_value(buf):
|
|
||||||
"""Parse AnyValue, return (type_tag, python_value)."""
|
|
||||||
for fn, wt, v in _fields(buf):
|
|
||||||
if fn == 1 and wt == 2: # string_value
|
|
||||||
return "str", v.decode("utf-8", errors="replace")
|
|
||||||
if fn == 2 and wt == 0: # bool_value
|
|
||||||
return "bool", bool(v)
|
|
||||||
if fn == 3 and wt == 0: # int_value (sint64)
|
|
||||||
return "int", v
|
|
||||||
if fn == 4 and wt == 1: # double_value
|
|
||||||
return "float", struct.unpack("<d", struct.pack("<Q", v))[0]
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
def _keyvalue(buf):
|
|
||||||
key, tag, val = None, None, None
|
|
||||||
for fn, wt, v in _fields(buf):
|
|
||||||
if fn == 1 and wt == 2:
|
|
||||||
key = v.decode("utf-8", errors="replace")
|
|
||||||
elif fn == 2 and wt == 2:
|
|
||||||
tag, val = _any_value(v)
|
|
||||||
return key, tag, val
|
|
||||||
|
|
||||||
|
|
||||||
def _span(buf):
|
|
||||||
name = ""
|
|
||||||
start_ns = end_ns = 0
|
|
||||||
cached = False
|
|
||||||
for fn, wt, v in _fields(buf):
|
|
||||||
if fn == 5 and wt == 2: # name
|
|
||||||
name = v.decode("utf-8", errors="replace")
|
|
||||||
elif fn == 7 and wt == 1: # start_time_unix_nano
|
|
||||||
start_ns = v
|
|
||||||
elif fn == 8 and wt == 1: # end_time_unix_nano
|
|
||||||
end_ns = v
|
|
||||||
elif fn == 9 and wt == 2: # attributes (repeated)
|
|
||||||
k, tag, val = _keyvalue(v)
|
|
||||||
if tag == "bool" and k and "cached" in k.lower():
|
|
||||||
cached = val
|
|
||||||
return {"name": name, "dur": max(0.0, (end_ns - start_ns) / 1e9), "cached": cached}
|
|
||||||
|
|
||||||
|
|
||||||
def _decode(body):
|
|
||||||
spans = []
|
|
||||||
for fn1, wt1, rs in _fields(body): # resource_spans = 1
|
|
||||||
if fn1 != 1 or wt1 != 2:
|
|
||||||
continue
|
|
||||||
for fn2, wt2, ss in _fields(rs): # scope_spans = 2
|
|
||||||
if fn2 != 2 or wt2 != 2:
|
|
||||||
continue
|
|
||||||
for fn3, wt3, sp in _fields(ss): # spans = 2
|
|
||||||
if fn3 == 2 and wt3 == 2:
|
|
||||||
spans.append(_span(sp))
|
|
||||||
return spans
|
|
||||||
|
|
||||||
|
|
||||||
# ── HTTP receiver ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_spans = []
|
|
||||||
_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
class _Handler(BaseHTTPRequestHandler):
|
|
||||||
protocol_version = "HTTP/1.1"
|
|
||||||
|
|
||||||
def _respond(self, code, body=b""):
|
|
||||||
self.close_connection = True # actually close after response, matching the header
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", "application/x-protobuf")
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.send_header("Connection", "close")
|
|
||||||
self.end_headers()
|
|
||||||
if body:
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
if self.path != "/shutdown":
|
|
||||||
self._respond(404); return
|
|
||||||
self._respond(200, b"shutting down")
|
|
||||||
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
if self.path != "/v1/traces":
|
|
||||||
self._respond(404); return
|
|
||||||
n = int(self.headers.get("Content-Length", 0))
|
|
||||||
body = self.rfile.read(n)
|
|
||||||
try:
|
|
||||||
decoded = _decode(body)
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[otel-receiver] decode error: {exc}", file=sys.stderr, flush=True)
|
|
||||||
self._respond(400, str(exc).encode()); return
|
|
||||||
with _lock:
|
|
||||||
_spans.extend(decoded)
|
|
||||||
self._respond(200)
|
|
||||||
|
|
||||||
def log_message(self, *_):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ── Timing report ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _report():
|
|
||||||
with _lock:
|
|
||||||
if not _spans:
|
|
||||||
print("otel-receiver: no spans received", file=sys.stderr)
|
|
||||||
return
|
|
||||||
rows = sorted(_spans, key=lambda r: r["dur"], reverse=True)
|
|
||||||
NAME_W = 38
|
|
||||||
print(f'\n{"STATUS":<6} {"DURATION":>8} SPAN')
|
|
||||||
print("─" * (6 + 2 + 8 + 2 + NAME_W + 20))
|
|
||||||
for r in rows:
|
|
||||||
status = "CACHED" if r["cached"] else "LIVE"
|
|
||||||
name = r["name"]
|
|
||||||
if len(name) > NAME_W:
|
|
||||||
name = name[: NAME_W - 1] + "…"
|
|
||||||
print(f'{status:<6} {r["dur"]:7.2f}s {name}')
|
|
||||||
print(f"\n{len(rows)} spans total")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--port-file", default="")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
server = HTTPServer(("127.0.0.1", 0), _Handler)
|
|
||||||
if args.port_file:
|
|
||||||
with open(args.port_file, "w") as f:
|
|
||||||
f.write(str(server.server_address[1]))
|
|
||||||
|
|
||||||
def _shutdown(sig, frame):
|
|
||||||
threading.Thread(target=server.shutdown, daemon=True).start()
|
|
||||||
|
|
||||||
signal.signal(signal.SIGTERM, _shutdown)
|
|
||||||
signal.signal(signal.SIGINT, _shutdown)
|
|
||||||
|
|
||||||
server.serve_forever()
|
|
||||||
_report()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -3,48 +3,3 @@
|
|||||||
Installed like explained here:
|
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.
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
|
|
||||||
# Load .env into environment
|
|
||||||
set -a
|
|
||||||
# shellcheck source=.env
|
|
||||||
source "$REPO_DIR/.env"
|
|
||||||
set +a
|
|
||||||
|
|
||||||
# SSH_PRIVATE_KEY must not live in .env (dagger parses .env and chokes on multiline values)
|
|
||||||
export SSH_PRIVATE_KEY=$(cat "$HOME/.ssh/id_ed25519")
|
|
||||||
|
|
||||||
# Add nix profile and nix store tools (task, dagger) to PATH
|
|
||||||
export PATH="$HOME/.nix-profile/bin:$PATH"
|
|
||||||
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger"; 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
@@ -1,147 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Cron deploy script for sharedinbox website.
|
|
||||||
Runs every 5 minutes; skips if origin/main has not changed since last 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
+3
-24
@@ -1,25 +1,5 @@
|
|||||||
{
|
{
|
||||||
"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"
|
||||||
@@ -40,11 +20,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1778737229,
|
"lastModified": 1778430510,
|
||||||
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
|
"narHash": "sha256-Ti+ZBvW6yrWWAg2szExVTwCd4qOJ3KlVr1tFHfyfi8Q=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
|
"rev": "8fd9daa3db09ced9700431c5b7ad0e8ba199b575",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -56,7 +36,6 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"dagger": "dagger",
|
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,12 @@
|
|||||||
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, dagger }:
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
flake-utils.lib.eachDefaultSystem (system:
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
let
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = import nixpkgs { inherit 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
|
||||||
@@ -29,11 +27,7 @@
|
|||||||
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";
|
||||||
@@ -51,13 +45,8 @@
|
|||||||
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
|
||||||
@@ -94,8 +83,7 @@
|
|||||||
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-auth
|
google-api-python-client
|
||||||
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,28 +112,12 @@ void main() {
|
|||||||
late String userPass;
|
late String userPass;
|
||||||
|
|
||||||
setUpAll(() {
|
setUpAll(() {
|
||||||
const required = [
|
imapHost = Platform.environment['STALWART_IMAP_HOST'] ?? '127.0.0.1';
|
||||||
'STALWART_IMAP_HOST',
|
imapPort = int.parse(Platform.environment['STALWART_IMAP_PORT'] ?? '1430');
|
||||||
'STALWART_IMAP_PORT',
|
smtpHost = Platform.environment['STALWART_SMTP_HOST'] ?? '127.0.0.1';
|
||||||
'STALWART_SMTP_HOST',
|
smtpPort = int.parse(Platform.environment['STALWART_SMTP_PORT'] ?? '1025');
|
||||||
'STALWART_SMTP_PORT',
|
userEmail = Platform.environment['STALWART_USER_B'] ?? 'alice@example.com';
|
||||||
'STALWART_USER_B',
|
userPass = Platform.environment['STALWART_PASS_B'] ?? 'secret';
|
||||||
'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(
|
||||||
@@ -146,12 +130,17 @@ void main() {
|
|||||||
addTearDown(tester.view.resetPhysicalSize);
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
addTearDown(tester.view.resetDevicePixelRatio);
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
// Capture the test binding's error recorder and error-widget builder
|
// On Android, the keyboard-dismiss / window-resize cycle can trigger
|
||||||
// BEFORE app.main() so teardown can restore both. app.main() overwrites
|
// one final layout pass on already-disposed render objects (DEFUNCT).
|
||||||
// FlutterError.onError (crash-screen handler) and ErrorWidget.builder;
|
// These spurious overflow errors have no effect on real functionality;
|
||||||
// the test binding verifies both are unchanged after the test completes.
|
// filter them so they don't fail the test.
|
||||||
final bindingError = FlutterError.onError;
|
final prevError = FlutterError.onError;
|
||||||
final bindingErrorWidgetBuilder = ErrorWidget.builder;
|
FlutterError.onError = (details) {
|
||||||
|
final msg = details.toString();
|
||||||
|
if (msg.contains('DEFUNCT') || msg.contains('DISPOSED')) return;
|
||||||
|
prevError?.call(details);
|
||||||
|
};
|
||||||
|
addTearDown(() => FlutterError.onError = prevError);
|
||||||
|
|
||||||
_log('app start');
|
_log('app start');
|
||||||
app.main(
|
app.main(
|
||||||
@@ -166,36 +155,7 @@ 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 ────────────────────────────────────────────────────────
|
||||||
@@ -288,12 +248,6 @@ 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,
|
||||||
@@ -303,10 +257,6 @@ 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,7 +7,6 @@ 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,
|
||||||
@@ -18,6 +17,5 @@ class SavedDraft {
|
|||||||
required this.subjectText,
|
required this.subjectText,
|
||||||
required this.bodyText,
|
required this.bodyText,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
this.imapServerId,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ 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,
|
||||||
@@ -45,7 +43,6 @@ 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) {
|
||||||
@@ -80,7 +77,6 @@ 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?,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +102,6 @@ class Email {
|
|||||||
'references': references,
|
'references': references,
|
||||||
'snoozedUntil': snoozedUntil?.toIso8601String(),
|
'snoozedUntil': snoozedUntil?.toIso8601String(),
|
||||||
'snoozedFromMailboxPath': snoozedFromMailboxPath,
|
'snoozedFromMailboxPath': snoozedFromMailboxPath,
|
||||||
'listUnsubscribeHeader': listUnsubscribeHeader,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +126,6 @@ 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,
|
||||||
@@ -155,8 +149,6 @@ class Email {
|
|||||||
snoozedUntil: snoozedUntil ?? this.snoozedUntil,
|
snoozedUntil: snoozedUntil ?? this.snoozedUntil,
|
||||||
snoozedFromMailboxPath:
|
snoozedFromMailboxPath:
|
||||||
snoozedFromMailboxPath ?? this.snoozedFromMailboxPath,
|
snoozedFromMailboxPath ?? this.snoozedFromMailboxPath,
|
||||||
listUnsubscribeHeader:
|
|
||||||
listUnsubscribeHeader ?? this.listUnsubscribeHeader,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,29 +224,12 @@ 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,
|
||||||
@@ -262,7 +237,6 @@ class EmailBody {
|
|||||||
this.htmlBody,
|
this.htmlBody,
|
||||||
required this.attachments,
|
required this.attachments,
|
||||||
this.headers = const [],
|
this.headers = const [],
|
||||||
this.mimeTree,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,4 @@ 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,19 +1,14 @@
|
|||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
|
||||||
abstract class EmailRepository {
|
abstract class EmailRepository {
|
||||||
Stream<List<Email>> observeEmails(
|
Stream<List<Email>> observeEmails(String accountId, String mailboxPath);
|
||||||
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(
|
||||||
@@ -27,7 +22,6 @@ 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
|
||||||
@@ -41,10 +35,6 @@ 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(
|
||||||
@@ -61,14 +51,6 @@ 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);
|
||||||
@@ -99,17 +81,6 @@ 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;
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
abstract interface class SearchHistoryRepository {
|
|
||||||
Future<List<String>> getRecentSearches();
|
|
||||||
Future<void> saveSearch(String query);
|
|
||||||
Future<void> clearHistory();
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
|
||||||
|
|
||||||
/// Stores and retrieves ephemeral X25519 key pairs for secure account sharing.
|
|
||||||
abstract class ShareKeyRepository {
|
|
||||||
/// Generates a new key pair and persists it with a 20-minute expiry.
|
|
||||||
Future<ShareKeyMaterial> createKeyPair();
|
|
||||||
|
|
||||||
/// Returns the key pair whose ID matches [keyId], or null if not found /
|
|
||||||
/// expired.
|
|
||||||
Future<ShareKeyMaterial?> findByKeyId(Uint8List keyId);
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,12 @@ class MailboxSyncStats {
|
|||||||
required this.fetched,
|
required this.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 {
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:math';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:cryptography/cryptography.dart';
|
|
||||||
|
|
||||||
const _pubKeyPrefix = 'sharedinbox.de:pubkey:v1:';
|
|
||||||
const _encAccountsPrefix = 'sharedinbox.de:encrypted-accounts:v1:';
|
|
||||||
|
|
||||||
// ECIES wire sizes (bytes).
|
|
||||||
const _keyIdLen = 16;
|
|
||||||
const _pubKeyLen = 32;
|
|
||||||
const _nonceLen = 12;
|
|
||||||
const _macLen = 16;
|
|
||||||
|
|
||||||
/// Describes a freshly generated key pair before it is written to the database.
|
|
||||||
class ShareKeyMaterial {
|
|
||||||
const ShareKeyMaterial({
|
|
||||||
required this.keyId,
|
|
||||||
required this.publicKeyBytes,
|
|
||||||
required this.privateKeyBytes,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Random 16-byte identifier (hex-encoded when stored / included in QR).
|
|
||||||
final Uint8List keyId;
|
|
||||||
|
|
||||||
/// X25519 public key, 32 bytes.
|
|
||||||
final Uint8List publicKeyBytes;
|
|
||||||
|
|
||||||
/// X25519 private key, 32 bytes.
|
|
||||||
final Uint8List privateKeyBytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An account + password pair, used in the plaintext payload before encryption.
|
|
||||||
class AccountPayload {
|
|
||||||
const AccountPayload({required this.accountJson, required this.password});
|
|
||||||
|
|
||||||
final Map<String, dynamic> accountJson;
|
|
||||||
final String password;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure-Dart cryptographic helpers for the secure account-sharing flow.
|
|
||||||
///
|
|
||||||
/// Protocol:
|
|
||||||
/// Receiver generates an X25519 key pair with 20-minute lifetime and shows
|
|
||||||
/// its public key as a QR code. The sender scans that QR, encrypts the
|
|
||||||
/// selected account(s) using ECIES (X25519-ECDH + HKDF-SHA256 + AES-256-GCM)
|
|
||||||
/// and shows the encrypted payload as a QR code. The receiver scans that QR,
|
|
||||||
/// looks up the private key by the embedded key-ID, and decrypts.
|
|
||||||
class ShareEncryptionService {
|
|
||||||
static final _x25519 = X25519();
|
|
||||||
static final _aesGcm = AesGcm.with256bits();
|
|
||||||
static final _hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
|
|
||||||
static final _rng = Random.secure();
|
|
||||||
|
|
||||||
// ── Key generation ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
static Future<ShareKeyMaterial> generateKeyPair() async {
|
|
||||||
final keyId = Uint8List(_keyIdLen);
|
|
||||||
for (var i = 0; i < _keyIdLen; i++) {
|
|
||||||
keyId[i] = _rng.nextInt(256);
|
|
||||||
}
|
|
||||||
|
|
||||||
final keyPair = await _x25519.newKeyPair();
|
|
||||||
final pub = await keyPair.extractPublicKey();
|
|
||||||
final priv = await keyPair.extractPrivateKeyBytes();
|
|
||||||
|
|
||||||
return ShareKeyMaterial(
|
|
||||||
keyId: keyId,
|
|
||||||
publicKeyBytes: Uint8List.fromList(pub.bytes),
|
|
||||||
privateKeyBytes: Uint8List.fromList(priv),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Public-key QR encoding / parsing ────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Encodes the receiver's public key as a QR-code string.
|
|
||||||
///
|
|
||||||
/// Format: `sharedinbox.de:pubkey:v1:<base64(keyId[16] || pubKey[32])>`
|
|
||||||
static String encodePublicKeyQr(Uint8List keyId, Uint8List publicKeyBytes) {
|
|
||||||
assert(keyId.length == _keyIdLen);
|
|
||||||
assert(publicKeyBytes.length == _pubKeyLen);
|
|
||||||
final data = Uint8List(_keyIdLen + _pubKeyLen)
|
|
||||||
..setAll(0, keyId)
|
|
||||||
..setAll(_keyIdLen, publicKeyBytes);
|
|
||||||
return '$_pubKeyPrefix${base64.encode(data)}';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses a public-key QR string. Returns null if the format is invalid.
|
|
||||||
static ({Uint8List keyId, Uint8List publicKeyBytes})? parsePublicKeyQr(
|
|
||||||
String s,
|
|
||||||
) {
|
|
||||||
if (!s.startsWith(_pubKeyPrefix)) return null;
|
|
||||||
try {
|
|
||||||
final data =
|
|
||||||
Uint8List.fromList(base64.decode(s.substring(_pubKeyPrefix.length)));
|
|
||||||
if (data.length != _keyIdLen + _pubKeyLen) return null;
|
|
||||||
return (
|
|
||||||
keyId: data.sublist(0, _keyIdLen),
|
|
||||||
publicKeyBytes: data.sublist(_keyIdLen),
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Encryption ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Encrypts [accounts] for the given recipient key pair using ECIES.
|
|
||||||
///
|
|
||||||
/// Returns the QR-code string to show on the sender device.
|
|
||||||
///
|
|
||||||
/// Wire format (base64-encoded):
|
|
||||||
/// keyId[16] || ephPubKey[32] || nonce[12] || ciphertext || mac[16]
|
|
||||||
static Future<String> encryptAccounts({
|
|
||||||
required Uint8List recipientKeyId,
|
|
||||||
required Uint8List recipientPublicKeyBytes,
|
|
||||||
required List<AccountPayload> accounts,
|
|
||||||
}) async {
|
|
||||||
// Build plaintext JSON.
|
|
||||||
final plaintext = utf8.encode(
|
|
||||||
jsonEncode({
|
|
||||||
'v': 2,
|
|
||||||
'issuedAt': DateTime.now().toUtc().toIso8601String(),
|
|
||||||
'accounts': accounts
|
|
||||||
.map((a) => {'account': a.accountJson, 'password': a.password})
|
|
||||||
.toList(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ephemeral sender key pair for forward-secrecy.
|
|
||||||
final ephKeyPair = await _x25519.newKeyPair();
|
|
||||||
final ephPub = await ephKeyPair.extractPublicKey();
|
|
||||||
|
|
||||||
// ECDH: shared secret = X25519(ephPriv, recipientPub).
|
|
||||||
final sharedSecret = await _x25519.sharedSecretKey(
|
|
||||||
keyPair: ephKeyPair,
|
|
||||||
remotePublicKey: SimplePublicKey(
|
|
||||||
recipientPublicKeyBytes,
|
|
||||||
type: KeyPairType.x25519,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Derive AES key via HKDF-SHA256.
|
|
||||||
final aesKey = await _hkdf.deriveKey(
|
|
||||||
secretKey: sharedSecret,
|
|
||||||
nonce: recipientKeyId,
|
|
||||||
info: utf8.encode('sharedinbox-account-transfer'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Encrypt with AES-256-GCM.
|
|
||||||
final nonce = Uint8List(_nonceLen);
|
|
||||||
for (var i = 0; i < _nonceLen; i++) {
|
|
||||||
nonce[i] = _rng.nextInt(256);
|
|
||||||
}
|
|
||||||
|
|
||||||
final box = await _aesGcm.encrypt(
|
|
||||||
plaintext,
|
|
||||||
secretKey: aesKey,
|
|
||||||
nonce: nonce,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Pack wire format.
|
|
||||||
final ephPubBytes = Uint8List.fromList(ephPub.bytes);
|
|
||||||
final cipherBytes = Uint8List.fromList(box.cipherText);
|
|
||||||
final macBytes = Uint8List.fromList(box.mac.bytes);
|
|
||||||
|
|
||||||
final out = Uint8List(
|
|
||||||
_keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length + _macLen,
|
|
||||||
)
|
|
||||||
..setAll(0, recipientKeyId)
|
|
||||||
..setAll(_keyIdLen, ephPubBytes)
|
|
||||||
..setAll(_keyIdLen + _pubKeyLen, nonce)
|
|
||||||
..setAll(_keyIdLen + _pubKeyLen + _nonceLen, cipherBytes)
|
|
||||||
..setAll(
|
|
||||||
_keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length,
|
|
||||||
macBytes,
|
|
||||||
);
|
|
||||||
|
|
||||||
return '$_encAccountsPrefix${base64.encode(out)}';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Decryption ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Parses and decrypts an encrypted-accounts QR string.
|
|
||||||
///
|
|
||||||
/// Throws [FormatException] if the format is invalid.
|
|
||||||
/// Throws [SecretBoxAuthenticationError] if authentication fails (tampered).
|
|
||||||
static Future<List<AccountPayload>> decryptAccounts({
|
|
||||||
required String qrString,
|
|
||||||
required Uint8List privateKeyBytes,
|
|
||||||
required Uint8List publicKeyBytes,
|
|
||||||
required Uint8List keyId,
|
|
||||||
}) async {
|
|
||||||
if (!qrString.startsWith(_encAccountsPrefix)) {
|
|
||||||
throw const FormatException('Not an encrypted-accounts QR code');
|
|
||||||
}
|
|
||||||
|
|
||||||
final Uint8List data;
|
|
||||||
try {
|
|
||||||
data = Uint8List.fromList(
|
|
||||||
base64.decode(qrString.substring(_encAccountsPrefix.length)),
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
throw const FormatException('Invalid base64 in encrypted-accounts QR');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimum: keyId + ephPubKey + nonce + mac (no ciphertext is valid but odd).
|
|
||||||
if (data.length < _keyIdLen + _pubKeyLen + _nonceLen + _macLen) {
|
|
||||||
throw const FormatException('Encrypted-accounts payload too short');
|
|
||||||
}
|
|
||||||
|
|
||||||
final embeddedKeyId = data.sublist(0, _keyIdLen);
|
|
||||||
// Verify that this payload was encrypted for the right key pair.
|
|
||||||
for (var i = 0; i < _keyIdLen; i++) {
|
|
||||||
if (embeddedKeyId[i] != keyId[i]) {
|
|
||||||
throw const FormatException(
|
|
||||||
'Key ID mismatch — please scan a fresh public-key QR code',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final ephPubBytes = data.sublist(_keyIdLen, _keyIdLen + _pubKeyLen);
|
|
||||||
final nonce = data.sublist(
|
|
||||||
_keyIdLen + _pubKeyLen,
|
|
||||||
_keyIdLen + _pubKeyLen + _nonceLen,
|
|
||||||
);
|
|
||||||
final cipherText = data.sublist(
|
|
||||||
_keyIdLen + _pubKeyLen + _nonceLen,
|
|
||||||
data.length - _macLen,
|
|
||||||
);
|
|
||||||
final mac = data.sublist(data.length - _macLen);
|
|
||||||
|
|
||||||
// Reconstruct key pair.
|
|
||||||
final keyPair = SimpleKeyPairData(
|
|
||||||
privateKeyBytes,
|
|
||||||
publicKey: SimplePublicKey(publicKeyBytes, type: KeyPairType.x25519),
|
|
||||||
type: KeyPairType.x25519,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ECDH.
|
|
||||||
final sharedSecret = await _x25519.sharedSecretKey(
|
|
||||||
keyPair: keyPair,
|
|
||||||
remotePublicKey: SimplePublicKey(ephPubBytes, type: KeyPairType.x25519),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Re-derive AES key.
|
|
||||||
final aesKey = await _hkdf.deriveKey(
|
|
||||||
secretKey: sharedSecret,
|
|
||||||
nonce: keyId,
|
|
||||||
info: utf8.encode('sharedinbox-account-transfer'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Decrypt — throws SecretBoxAuthenticationError if tampered.
|
|
||||||
final plaintext = await _aesGcm.decrypt(
|
|
||||||
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
|
|
||||||
secretKey: aesKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Parse JSON.
|
|
||||||
final Map<String, dynamic> json;
|
|
||||||
try {
|
|
||||||
json = jsonDecode(utf8.decode(plaintext)) as Map<String, dynamic>;
|
|
||||||
} catch (_) {
|
|
||||||
throw const FormatException('Decrypted payload is not valid JSON');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((json['v'] as int?) != 2) {
|
|
||||||
throw const FormatException('Unsupported encrypted-accounts version');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify issuedAt is within 20 minutes.
|
|
||||||
final issuedAtRaw = json['issuedAt'] as String?;
|
|
||||||
if (issuedAtRaw != null) {
|
|
||||||
final issuedAt = DateTime.tryParse(issuedAtRaw);
|
|
||||||
if (issuedAt != null) {
|
|
||||||
final age = DateTime.now().toUtc().difference(issuedAt.toUtc());
|
|
||||||
if (age.abs() > const Duration(minutes: 20)) {
|
|
||||||
throw const FormatException(
|
|
||||||
'The encrypted payload has expired (older than 20 minutes)',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final rawAccounts = json['accounts'] as List<dynamic>;
|
|
||||||
return rawAccounts.map((entry) {
|
|
||||||
final m = entry as Map<String, dynamic>;
|
|
||||||
return AccountPayload(
|
|
||||||
accountJson: m['account'] as Map<String, dynamic>,
|
|
||||||
password: m['password'] as String,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
await _ref.read(undoRepositoryProvider).deleteAction(removed.id);
|
unawaited(_ref.read(undoRepositoryProvider).deleteAction(removed.id));
|
||||||
}
|
}
|
||||||
state = newList;
|
state = newList;
|
||||||
await _ref.read(undoRepositoryProvider).saveAction(action);
|
unawaited(_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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the original entry in state and DB so the user can see what
|
unawaited(_ref.read(undoRepositoryProvider).deleteAction(action.id));
|
||||||
// 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,22 +70,10 @@ 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. Resolve the current DB row for the email.
|
// 2. If row is missing (hard delete), restore it first.
|
||||||
// For IMAP, after a server-applied move the email gets a new UID, so
|
// We restore it at its CURRENT state (where it is on the server,
|
||||||
// the original id ('accountId:oldUid') no longer exists. Look it up by
|
// or where it was moving to).
|
||||||
// Message-ID so we use the correct UID in the pending change.
|
if (original != null) {
|
||||||
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);
|
||||||
@@ -94,40 +82,19 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Move it back to source.
|
// 3. 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 using the correct UID.
|
// a reverse move on the server.
|
||||||
await repo.moveEmail(currentId, action.sourceMailboxPath);
|
await repo.moveEmail(id, action.sourceMailboxPath);
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
// 5. If we successfully cancelled the original, the reverse move
|
// 4. If we successfully cancelled the original, the reverse move
|
||||||
// we just enqueued is redundant.
|
// we just enqueued is redundant.
|
||||||
await repo.cancelPendingChange(currentId, 'move');
|
await repo.cancelPendingChange(id, '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(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
|
|
||||||
const _kAppVersion = String.fromEnvironment('GIT_HASH');
|
|
||||||
const _kLatestJsonUrl = 'https://sharedinbox.de/latest.json';
|
|
||||||
|
|
||||||
class UpdateInfo {
|
|
||||||
const UpdateInfo({required this.latestVersion, required this.downloadUrl});
|
|
||||||
|
|
||||||
final String latestVersion;
|
|
||||||
final String downloadUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns an [UpdateInfo] when a newer Linux or Windows version is available,
|
|
||||||
/// or null if the app is up to date, the version is unknown, or the platform
|
|
||||||
/// is not a supported desktop.
|
|
||||||
final updateInfoProvider = FutureProvider<UpdateInfo?>((ref) async {
|
|
||||||
final platformKey = Platform.isLinux
|
|
||||||
? 'linux'
|
|
||||||
: Platform.isWindows
|
|
||||||
? 'windows'
|
|
||||||
: null;
|
|
||||||
if (platformKey == null || _kAppVersion.isEmpty) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final resp = await http
|
|
||||||
.get(Uri.parse(_kLatestJsonUrl))
|
|
||||||
.timeout(const Duration(seconds: 10));
|
|
||||||
if (resp.statusCode != 200) return null;
|
|
||||||
final json = jsonDecode(resp.body) as Map<String, dynamic>;
|
|
||||||
final latest = json['version'] as String?;
|
|
||||||
final url = json[platformKey] as String?;
|
|
||||||
if (latest == null || url == null) return null;
|
|
||||||
if (latest == _kAppVersion) return null;
|
|
||||||
return UpdateInfo(latestVersion: latest, downloadUrl: url);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
sealed class SieveAction {}
|
|
||||||
|
|
||||||
final class FileIntoAction extends SieveAction {
|
|
||||||
FileIntoAction(this.folder);
|
|
||||||
final String folder;
|
|
||||||
}
|
|
||||||
|
|
||||||
final class KeepAction extends SieveAction {}
|
|
||||||
|
|
||||||
final class DiscardAction extends SieveAction {}
|
|
||||||
|
|
||||||
final class MarkAsSeenAction extends SieveAction {}
|
|
||||||
|
|
||||||
final class FlagAction extends SieveAction {
|
|
||||||
FlagAction(this.flags);
|
|
||||||
final List<String> flags;
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
sealed class SieveCondition {}
|
|
||||||
|
|
||||||
final class HeaderCondition extends SieveCondition {
|
|
||||||
HeaderCondition(this.headers, this.matchType, this.keyList);
|
|
||||||
final List<String> headers;
|
|
||||||
final String matchType; // ':contains', ':is', ':matches'
|
|
||||||
final List<String> keyList;
|
|
||||||
}
|
|
||||||
|
|
||||||
final class SizeCondition extends SieveCondition {
|
|
||||||
SizeCondition(this.comparison, this.bytes);
|
|
||||||
final String comparison; // ':over' or ':under'
|
|
||||||
final int bytes;
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
|
||||||
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
|
||||||
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
|
|
||||||
|
|
||||||
/// A lightweight email representation used by [SieveInterpreter].
|
|
||||||
/// Header names are lower-cased.
|
|
||||||
class SieveEmailContext {
|
|
||||||
const SieveEmailContext({required this.headers, this.sizeBytes = 0});
|
|
||||||
|
|
||||||
final Map<String, List<String>> headers;
|
|
||||||
final int sizeBytes;
|
|
||||||
|
|
||||||
List<String> getHeader(String name) =>
|
|
||||||
headers[name.toLowerCase()] ?? const [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tracks the outcome of running a Sieve script against one email.
|
|
||||||
class SieveExecutionContext {
|
|
||||||
bool isCancelled = false;
|
|
||||||
Set<String> targetFolders = {};
|
|
||||||
Set<String> flagsToAdd = {};
|
|
||||||
bool keepInInbox = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Evaluates a compiled list of [SieveRule]s against a [SieveEmailContext].
|
|
||||||
class SieveInterpreter {
|
|
||||||
/// Executes [rules] and returns the resulting [SieveExecutionContext].
|
|
||||||
///
|
|
||||||
/// Rules produced by [SieveParser] may carry a [SieveRule.branchGroupId]
|
|
||||||
/// to represent if/elsif/else chains; at most one branch per group fires.
|
|
||||||
SieveExecutionContext execute(
|
|
||||||
List<SieveRule> rules,
|
|
||||||
SieveEmailContext email,
|
|
||||||
) {
|
|
||||||
final ctx = SieveExecutionContext();
|
|
||||||
final firedGroups = <int>{};
|
|
||||||
|
|
||||||
for (final rule in rules) {
|
|
||||||
if (ctx.isCancelled) break;
|
|
||||||
|
|
||||||
final groupId = rule.branchGroupId;
|
|
||||||
if (groupId != null && firedGroups.contains(groupId)) continue;
|
|
||||||
|
|
||||||
bool matches;
|
|
||||||
if (rule.isElseBranch) {
|
|
||||||
matches = true; // else fires unconditionally (group not yet consumed)
|
|
||||||
} else {
|
|
||||||
matches = _evaluateConditions(rule, email);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matches) {
|
|
||||||
_applyActions(rule.actions, ctx);
|
|
||||||
if (groupId != null) firedGroups.add(groupId);
|
|
||||||
if (ctx.isCancelled) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Implicit keep: if no fileinto/discard was reached, email stays in inbox.
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _evaluateConditions(SieveRule rule, SieveEmailContext email) {
|
|
||||||
if (rule.conditions.isEmpty) return true;
|
|
||||||
return switch (rule.joinType) {
|
|
||||||
'allof' => rule.conditions.every((c) => _evalCondition(c, email)),
|
|
||||||
'anyof' => rule.conditions.any((c) => _evalCondition(c, email)),
|
|
||||||
_ => rule.conditions.length == 1 &&
|
|
||||||
_evalCondition(rule.conditions.first, email),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _evalCondition(SieveCondition cond, SieveEmailContext email) {
|
|
||||||
return switch (cond) {
|
|
||||||
final HeaderCondition c => _evalHeader(c, email),
|
|
||||||
final SizeCondition c => _evalSize(c, email),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _evalHeader(HeaderCondition cond, SieveEmailContext email) {
|
|
||||||
for (final header in cond.headers) {
|
|
||||||
final values = email.getHeader(header);
|
|
||||||
for (final value in values) {
|
|
||||||
for (final key in cond.keyList) {
|
|
||||||
if (_matchString(value, cond.matchType, key)) return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _evalSize(SizeCondition cond, SieveEmailContext email) {
|
|
||||||
return switch (cond.comparison) {
|
|
||||||
':over' => email.sizeBytes > cond.bytes,
|
|
||||||
':under' => email.sizeBytes < cond.bytes,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _matchString(String value, String matchType, String key) {
|
|
||||||
final v = value.toLowerCase();
|
|
||||||
final k = key.toLowerCase();
|
|
||||||
return switch (matchType) {
|
|
||||||
':contains' => k.isEmpty || v.contains(k),
|
|
||||||
':is' => v == k,
|
|
||||||
':matches' => _globMatch(v, k),
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _globMatch(String value, String pattern) {
|
|
||||||
final regexStr =
|
|
||||||
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
|
||||||
return RegExp('^$regexStr\$').hasMatch(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _applyActions(List<SieveAction> actions, SieveExecutionContext ctx) {
|
|
||||||
for (final action in actions) {
|
|
||||||
switch (action) {
|
|
||||||
case final FileIntoAction a:
|
|
||||||
ctx.targetFolders.add(a.folder);
|
|
||||||
ctx.keepInInbox = false;
|
|
||||||
case DiscardAction():
|
|
||||||
ctx.isCancelled = true;
|
|
||||||
ctx.keepInInbox = false;
|
|
||||||
return;
|
|
||||||
case KeepAction():
|
|
||||||
ctx.keepInInbox = true;
|
|
||||||
case MarkAsSeenAction():
|
|
||||||
ctx.flagsToAdd.add(r'\Seen');
|
|
||||||
case final FlagAction a:
|
|
||||||
ctx.flagsToAdd.addAll(a.flags);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,593 +0,0 @@
|
|||||||
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
|
||||||
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
|
||||||
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
|
|
||||||
|
|
||||||
/// Parses a Sieve script (RFC 5228 subset) into a flat list of [SieveRule]s.
|
|
||||||
///
|
|
||||||
/// Supported commands: require, if, elsif, else, fileinto, keep, discard,
|
|
||||||
/// flag, setflag, addflag, stop.
|
|
||||||
/// Supported tests: header, address, size, exists, allof, anyof, not, true.
|
|
||||||
/// Supported match types: :contains, :is, :matches.
|
|
||||||
class SieveParser {
|
|
||||||
List<SieveRule> parse(String script) {
|
|
||||||
final scanner = _Scanner(script);
|
|
||||||
final rules = <SieveRule>[];
|
|
||||||
_parseStatements(scanner, rules);
|
|
||||||
return rules;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _parseStatements(_Scanner s, List<SieveRule> out) {
|
|
||||||
while (!s.isAtEnd) {
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
if (s.isAtEnd) break;
|
|
||||||
|
|
||||||
final word = s.peekWord();
|
|
||||||
if (word == null) break;
|
|
||||||
|
|
||||||
if (word == 'require') {
|
|
||||||
_parseRequire(s);
|
|
||||||
} else if (word == 'if') {
|
|
||||||
_parseIf(s, out);
|
|
||||||
} else if (word == 'elsif' || word == 'else') {
|
|
||||||
// Reached by _parseIf, should not appear at top level.
|
|
||||||
break;
|
|
||||||
} else if (word == '}') {
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
final action = _tryParseAction(s);
|
|
||||||
if (action != null) {
|
|
||||||
out.add(
|
|
||||||
SieveRule(
|
|
||||||
joinType: 'single',
|
|
||||||
conditions: const [],
|
|
||||||
actions: [action],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
s.skipToNextSemicolon();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _parseRequire(_Scanner s) {
|
|
||||||
s.expectWord('require');
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
_parseStringOrList(s); // discard capability list
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
s.expectChar(';');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Monotonically increasing id shared per parse run, threaded via closure.
|
|
||||||
int _groupCounter = 0;
|
|
||||||
|
|
||||||
void _parseIf(_Scanner s, List<SieveRule> out) {
|
|
||||||
final groupId = ++_groupCounter;
|
|
||||||
|
|
||||||
s.expectWord('if');
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
final (joinType, conditions) = _parseTest(s);
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
final ifActions = _parseBlock(s);
|
|
||||||
|
|
||||||
out.add(
|
|
||||||
SieveRule(
|
|
||||||
joinType: joinType,
|
|
||||||
conditions: conditions,
|
|
||||||
actions: ifActions,
|
|
||||||
branchGroupId: groupId,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Parse zero or more elsif branches.
|
|
||||||
while (true) {
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
if (s.peekWord() != 'elsif') break;
|
|
||||||
s.expectWord('elsif');
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
final (ej, ec) = _parseTest(s);
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
final elsifActions = _parseBlock(s);
|
|
||||||
out.add(
|
|
||||||
SieveRule(
|
|
||||||
joinType: ej,
|
|
||||||
conditions: ec,
|
|
||||||
actions: elsifActions,
|
|
||||||
branchGroupId: groupId,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional else branch.
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
if (s.peekWord() == 'else') {
|
|
||||||
s.expectWord('else');
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
final elseActions = _parseBlock(s);
|
|
||||||
out.add(
|
|
||||||
SieveRule(
|
|
||||||
joinType: 'single',
|
|
||||||
conditions: const [],
|
|
||||||
actions: elseActions,
|
|
||||||
branchGroupId: groupId,
|
|
||||||
isElseBranch: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<SieveAction> _parseBlock(_Scanner s) {
|
|
||||||
s.expectChar('{');
|
|
||||||
final blockRules = <SieveRule>[];
|
|
||||||
_parseStatements(s, blockRules);
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
s.expectChar('}');
|
|
||||||
return blockRules.expand((r) => r.actions).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns (joinType, conditions).
|
|
||||||
(String, List<SieveCondition>) _parseTest(_Scanner s) {
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
final word = s.peekWord();
|
|
||||||
|
|
||||||
if (word == 'allof' || word == 'anyof') {
|
|
||||||
s.readWord();
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
s.expectChar('(');
|
|
||||||
final conditions = <SieveCondition>[];
|
|
||||||
while (true) {
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
if (s.peek() == ')') break;
|
|
||||||
final (_, conds) = _parseTest(s);
|
|
||||||
conditions.addAll(conds);
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
if (s.peek() == ',') {
|
|
||||||
s.advance();
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
s.expectChar(')');
|
|
||||||
return (word!, conditions);
|
|
||||||
}
|
|
||||||
|
|
||||||
final cond = _parseSingleTest(s);
|
|
||||||
return ('single', cond != null ? [cond] : []);
|
|
||||||
}
|
|
||||||
|
|
||||||
SieveCondition? _parseSingleTest(_Scanner s) {
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
final word = s.peekWord()?.toLowerCase();
|
|
||||||
if (word == null) return null;
|
|
||||||
|
|
||||||
if (word == 'not') {
|
|
||||||
s.readWord();
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
// Negation is not represented in the flat rule model; the caller
|
|
||||||
// should handle the negated condition separately. For now we parse
|
|
||||||
// and return the inner condition unchanged (best-effort for this subset).
|
|
||||||
return _parseSingleTest(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (word == 'true') {
|
|
||||||
s.readWord();
|
|
||||||
return null; // no condition = always matches
|
|
||||||
}
|
|
||||||
|
|
||||||
if (word == 'header' || word == 'address') {
|
|
||||||
s.readWord();
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
final matchType = _parseMatchType(s);
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
// Consume optional :comparator "..." tagged argument.
|
|
||||||
if (s.peekTaggedArg() == ':comparator') {
|
|
||||||
s.readWord();
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
_parseStringOrList(s); // discard comparator value
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
}
|
|
||||||
final headers = _parseStringOrList(s);
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
final keys = _parseStringOrList(s);
|
|
||||||
return HeaderCondition(headers, matchType, keys);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (word == 'exists') {
|
|
||||||
s.readWord();
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
final headers = _parseStringOrList(s);
|
|
||||||
// Represent exists as :contains "" so any non-empty value matches.
|
|
||||||
return HeaderCondition(headers, ':contains', const ['']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (word == 'size') {
|
|
||||||
s.readWord();
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
final comp = s.readTaggedArg(); // :over or :under
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
final bytes = _parseSizeNumber(s);
|
|
||||||
return SizeCondition(comp, bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown test — skip to closing paren or brace.
|
|
||||||
s.readWord();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String _parseMatchType(_Scanner s) {
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
final tag = s.peekTaggedArg();
|
|
||||||
if (tag == ':contains' || tag == ':is' || tag == ':matches') {
|
|
||||||
s.readWord();
|
|
||||||
return tag!;
|
|
||||||
}
|
|
||||||
// Default per RFC 5228 is :is.
|
|
||||||
return ':is';
|
|
||||||
}
|
|
||||||
|
|
||||||
List<String> _parseStringOrList(_Scanner s) {
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
if (s.peek() == '[') {
|
|
||||||
s.advance(); // consume '['
|
|
||||||
final items = <String>[];
|
|
||||||
while (true) {
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
if (s.peek() == ']') {
|
|
||||||
s.advance();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
items.add(_parseString(s));
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
if (s.peek() == ',') {
|
|
||||||
s.advance();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
return [_parseString(s)];
|
|
||||||
}
|
|
||||||
|
|
||||||
String _parseString(_Scanner s) {
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
if (s.peek() == '"') {
|
|
||||||
return s.readQuotedString();
|
|
||||||
}
|
|
||||||
// Multi-line text: text:...\r\n.\r\n (RFC 5228 §2.4.2)
|
|
||||||
if (s.peekWord()?.toLowerCase() == 'text:') {
|
|
||||||
return s.readTextBlock();
|
|
||||||
}
|
|
||||||
throw SieveParseException(
|
|
||||||
'Expected string at position ${s.position}: "${s.remaining.substring(0, 20)}"',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
int _parseSizeNumber(_Scanner s) {
|
|
||||||
final digits = s.readDigits();
|
|
||||||
final value = int.parse(digits);
|
|
||||||
final unit = s.peekSizeUnit();
|
|
||||||
if (unit != null) {
|
|
||||||
s.advance();
|
|
||||||
return switch (unit.toUpperCase()) {
|
|
||||||
'K' => value * 1024,
|
|
||||||
'M' => value * 1024 * 1024,
|
|
||||||
'G' => value * 1024 * 1024 * 1024,
|
|
||||||
_ => value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
SieveAction? _tryParseAction(_Scanner s) {
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
final word = s.peekWord()?.toLowerCase();
|
|
||||||
if (word == null) return null;
|
|
||||||
|
|
||||||
if (word == 'fileinto') {
|
|
||||||
s.readWord();
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
final folder = _parseString(s);
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
s.expectChar(';');
|
|
||||||
return FileIntoAction(folder);
|
|
||||||
}
|
|
||||||
if (word == 'keep') {
|
|
||||||
s.readWord();
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
s.expectChar(';');
|
|
||||||
return KeepAction();
|
|
||||||
}
|
|
||||||
if (word == 'discard') {
|
|
||||||
s.readWord();
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
s.expectChar(';');
|
|
||||||
return DiscardAction();
|
|
||||||
}
|
|
||||||
if (word == 'stop') {
|
|
||||||
s.readWord();
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
s.expectChar(';');
|
|
||||||
return KeepAction(); // stop with no prior action = implicit keep
|
|
||||||
}
|
|
||||||
if (word == 'flag' || word == 'setflag' || word == 'addflag') {
|
|
||||||
s.readWord();
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
// Optional variable name (string arg before the flag list).
|
|
||||||
final peek = s.peek();
|
|
||||||
List<String> flags;
|
|
||||||
if (peek == '"') {
|
|
||||||
final first = _parseString(s);
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
if (s.peek() == '[' || s.peek() == '"') {
|
|
||||||
// first was the variable name, next is the flag list
|
|
||||||
flags = _parseStringOrList(s);
|
|
||||||
} else {
|
|
||||||
flags = [first];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
flags = _parseStringOrList(s);
|
|
||||||
}
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
s.expectChar(';');
|
|
||||||
if (flags.any(
|
|
||||||
(f) => f.toLowerCase() == r'\seen' || f.toLowerCase() == r'\\seen',
|
|
||||||
)) {
|
|
||||||
return MarkAsSeenAction();
|
|
||||||
}
|
|
||||||
return FlagAction(flags);
|
|
||||||
}
|
|
||||||
if (word == 'mark') {
|
|
||||||
s.readWord();
|
|
||||||
s.skipWhitespaceAndComments();
|
|
||||||
s.expectChar(';');
|
|
||||||
return MarkAsSeenAction();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Low-level scanner
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class SieveParseException implements Exception {
|
|
||||||
SieveParseException(this.message);
|
|
||||||
final String message;
|
|
||||||
@override
|
|
||||||
String toString() => 'SieveParseException: $message';
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Scanner {
|
|
||||||
_Scanner(this._src);
|
|
||||||
|
|
||||||
final String _src;
|
|
||||||
int _pos = 0;
|
|
||||||
|
|
||||||
int get position => _pos;
|
|
||||||
bool get isAtEnd => _pos >= _src.length;
|
|
||||||
String get remaining => _pos < _src.length ? _src.substring(_pos) : '';
|
|
||||||
|
|
||||||
String? peek() {
|
|
||||||
if (isAtEnd) return null;
|
|
||||||
return _src[_pos];
|
|
||||||
}
|
|
||||||
|
|
||||||
String advance() {
|
|
||||||
if (isAtEnd) throw SieveParseException('Unexpected end of input');
|
|
||||||
return _src[_pos++];
|
|
||||||
}
|
|
||||||
|
|
||||||
void skipWhitespaceAndComments() {
|
|
||||||
while (!isAtEnd) {
|
|
||||||
final ch = _src[_pos];
|
|
||||||
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
|
|
||||||
_pos++;
|
|
||||||
} else if (ch == '#') {
|
|
||||||
// Line comment — skip to end of line.
|
|
||||||
while (!isAtEnd && _src[_pos] != '\n') {
|
|
||||||
_pos++;
|
|
||||||
}
|
|
||||||
} else if (_pos + 1 < _src.length && ch == '/' && _src[_pos + 1] == '*') {
|
|
||||||
// Block comment.
|
|
||||||
_pos += 2;
|
|
||||||
while (_pos + 1 < _src.length) {
|
|
||||||
if (_src[_pos] == '*' && _src[_pos + 1] == '/') {
|
|
||||||
_pos += 2;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
_pos++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Peeks at the next word-like token (letters/digits/underscores/colons for
|
|
||||||
/// tagged args, and special single-char tokens like `{`, `}`, `;`).
|
|
||||||
String? peekWord() {
|
|
||||||
if (isAtEnd) return null;
|
|
||||||
final ch = _src[_pos];
|
|
||||||
if ('{}();[],'.contains(ch)) return ch;
|
|
||||||
if (ch == ':') {
|
|
||||||
// Tagged arg like :contains
|
|
||||||
final start = _pos;
|
|
||||||
var end = _pos + 1;
|
|
||||||
while (end < _src.length && _isWordChar(_src[end])) {
|
|
||||||
end++;
|
|
||||||
}
|
|
||||||
return _src.substring(start, end).toLowerCase();
|
|
||||||
}
|
|
||||||
if (_isWordChar(ch)) {
|
|
||||||
final start = _pos;
|
|
||||||
var end = _pos + 1;
|
|
||||||
while (
|
|
||||||
end < _src.length && (_isWordChar(_src[end]) || _src[end] == ':')) {
|
|
||||||
// Include trailing colon for "text:" multiline token.
|
|
||||||
if (_src[end] == ':') {
|
|
||||||
end++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
end++;
|
|
||||||
}
|
|
||||||
return _src.substring(start, end).toLowerCase();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String readWord() {
|
|
||||||
final start = _pos;
|
|
||||||
final ch = _src[_pos];
|
|
||||||
if ('{}();[],'.contains(ch)) {
|
|
||||||
_pos++;
|
|
||||||
return ch;
|
|
||||||
}
|
|
||||||
if (ch == ':') {
|
|
||||||
_pos++;
|
|
||||||
while (!isAtEnd && _isWordChar(_src[_pos])) {
|
|
||||||
_pos++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
while (!isAtEnd && (_isWordChar(_src[_pos]) || _src[_pos] == ':')) {
|
|
||||||
if (_src[_pos] == ':') {
|
|
||||||
_pos++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
_pos++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _src.substring(start, _pos).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
String? peekTaggedArg() {
|
|
||||||
if (!isAtEnd && _src[_pos] == ':') return peekWord();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String readTaggedArg() {
|
|
||||||
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
|
||||||
throw SieveParseException(
|
|
||||||
'Expected tagged argument at position $_pos',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String? peekSizeUnit() {
|
|
||||||
if (isAtEnd) return null;
|
|
||||||
final ch = _src[_pos].toUpperCase();
|
|
||||||
if (ch == 'K' || ch == 'M' || ch == 'G') return ch;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String readDigits() {
|
|
||||||
if (isAtEnd || !_isDigit(_src[_pos])) {
|
|
||||||
throw SieveParseException(
|
|
||||||
'Expected number at position $_pos',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final start = _pos;
|
|
||||||
while (!isAtEnd && _isDigit(_src[_pos])) {
|
|
||||||
_pos++;
|
|
||||||
}
|
|
||||||
return _src.substring(start, _pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
String readQuotedString() {
|
|
||||||
if (_src[_pos] != '"') {
|
|
||||||
throw SieveParseException(
|
|
||||||
'Expected " at position $_pos',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_pos++; // skip opening quote
|
|
||||||
final buf = StringBuffer();
|
|
||||||
while (!isAtEnd) {
|
|
||||||
final ch = _src[_pos];
|
|
||||||
if (ch == '"') {
|
|
||||||
_pos++;
|
|
||||||
return buf.toString();
|
|
||||||
}
|
|
||||||
if (ch == '\\' && _pos + 1 < _src.length) {
|
|
||||||
_pos++;
|
|
||||||
buf.write(_src[_pos]);
|
|
||||||
_pos++;
|
|
||||||
} else {
|
|
||||||
buf.write(ch);
|
|
||||||
_pos++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw SieveParseException('Unterminated string');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses a `text:` multi-line block (RFC 5228 §2.4.2).
|
|
||||||
/// Format: `text:\r\n<lines>\r\n.\r\n`
|
|
||||||
String readTextBlock() {
|
|
||||||
// Consume "text:"
|
|
||||||
while (!isAtEnd && _src[_pos] != ':') {
|
|
||||||
_pos++;
|
|
||||||
}
|
|
||||||
if (!isAtEnd) _pos++; // skip ':'
|
|
||||||
// Skip optional whitespace then newline.
|
|
||||||
while (!isAtEnd && (_src[_pos] == ' ' || _src[_pos] == '\t')) {
|
|
||||||
_pos++;
|
|
||||||
}
|
|
||||||
if (!isAtEnd && _src[_pos] == '\r') _pos++;
|
|
||||||
if (!isAtEnd && _src[_pos] == '\n') _pos++;
|
|
||||||
final buf = StringBuffer();
|
|
||||||
while (!isAtEnd) {
|
|
||||||
// Check for terminator: a lone "." on its own line.
|
|
||||||
if (_src[_pos] == '.' &&
|
|
||||||
(_pos + 1 >= _src.length ||
|
|
||||||
_src[_pos + 1] == '\r' ||
|
|
||||||
_src[_pos + 1] == '\n')) {
|
|
||||||
_pos++;
|
|
||||||
if (!isAtEnd && _src[_pos] == '\r') _pos++;
|
|
||||||
if (!isAtEnd && _src[_pos] == '\n') _pos++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
buf.write(_src[_pos]);
|
|
||||||
_pos++;
|
|
||||||
}
|
|
||||||
return buf.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
void expectChar(String ch) {
|
|
||||||
skipWhitespaceAndComments();
|
|
||||||
if (isAtEnd || _src[_pos] != ch) {
|
|
||||||
throw SieveParseException(
|
|
||||||
'Expected "$ch" at position $_pos, got '
|
|
||||||
'"${isAtEnd ? "EOF" : _src[_pos]}"',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_pos++;
|
|
||||||
}
|
|
||||||
|
|
||||||
void expectWord(String word) {
|
|
||||||
skipWhitespaceAndComments();
|
|
||||||
final got = readWord();
|
|
||||||
if (got.toLowerCase() != word.toLowerCase()) {
|
|
||||||
throw SieveParseException(
|
|
||||||
'Expected "$word" at position $_pos, got "$got"',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void skipToNextSemicolon() {
|
|
||||||
while (!isAtEnd && _src[_pos] != ';') {
|
|
||||||
_pos++;
|
|
||||||
}
|
|
||||||
if (!isAtEnd) _pos++; // skip ';'
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool _isWordChar(String ch) {
|
|
||||||
final c = ch.codeUnitAt(0);
|
|
||||||
return (c >= 0x41 && c <= 0x5A) || // A-Z
|
|
||||||
(c >= 0x61 && c <= 0x7A) || // a-z
|
|
||||||
(c >= 0x30 && c <= 0x39) || // 0-9
|
|
||||||
c == 0x5F || // _
|
|
||||||
c == 0x2D; // -
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool _isDigit(String ch) {
|
|
||||||
final c = ch.codeUnitAt(0);
|
|
||||||
return c >= 0x30 && c <= 0x39;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
|
||||||
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
|
||||||
|
|
||||||
class SieveRule {
|
|
||||||
const SieveRule({
|
|
||||||
required this.joinType,
|
|
||||||
required this.conditions,
|
|
||||||
required this.actions,
|
|
||||||
this.branchGroupId,
|
|
||||||
this.isElseBranch = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 'allof', 'anyof', or 'single'
|
|
||||||
final String joinType;
|
|
||||||
final List<SieveCondition> conditions;
|
|
||||||
final List<SieveAction> actions;
|
|
||||||
// Non-null groups this rule into an if/elsif/else chain.
|
|
||||||
final int? branchGroupId;
|
|
||||||
// True for the unconditional else branch.
|
|
||||||
final bool isElseBranch;
|
|
||||||
}
|
|
||||||
@@ -4,16 +4,12 @@ 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.
|
||||||
///
|
///
|
||||||
@@ -26,35 +22,19 @@ 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();
|
||||||
@@ -65,7 +45,6 @@ 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,
|
||||||
@@ -74,10 +53,6 @@ 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,
|
||||||
@@ -85,8 +60,6 @@ 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;
|
||||||
@@ -108,7 +81,6 @@ 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
|
||||||
@@ -141,10 +113,6 @@ 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,
|
||||||
@@ -152,8 +120,6 @@ class AccountSyncManager {
|
|||||||
_emails,
|
_emails,
|
||||||
_accounts,
|
_accounts,
|
||||||
_syncLog,
|
_syncLog,
|
||||||
onSyncStart: () => _emitSyncing(accountId, syncing: true),
|
|
||||||
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
_active[accountId] = loop;
|
_active[accountId] = loop;
|
||||||
@@ -179,12 +145,7 @@ 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;
|
||||||
@@ -192,16 +153,11 @@ 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() {
|
||||||
@@ -229,7 +185,6 @@ 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,
|
||||||
@@ -249,10 +204,8 @@ 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(
|
||||||
@@ -293,7 +246,6 @@ 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') ||
|
||||||
@@ -304,16 +256,11 @@ 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>();
|
||||||
_waitTimer = Timer(Duration(seconds: seconds), () {
|
await Future.any([
|
||||||
if (!_stopSignal!.isCompleted) _stopSignal!.complete();
|
Future.delayed(Duration(seconds: seconds)),
|
||||||
});
|
_stopSignal!.future,
|
||||||
try {
|
]);
|
||||||
await _stopSignal!.future;
|
_stopSignal = null;
|
||||||
} finally {
|
|
||||||
_waitTimer?.cancel();
|
|
||||||
_waitTimer = null;
|
|
||||||
_stopSignal = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<(_SyncStats, String?)> _runSync(bool verbose) async {
|
Future<(_SyncStats, String?)> _runSync(bool verbose) async {
|
||||||
@@ -332,8 +279,6 @@ 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);
|
||||||
|
|
||||||
@@ -347,7 +292,6 @@ 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(
|
||||||
@@ -356,11 +300,9 @@ 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,
|
||||||
@@ -383,7 +325,6 @@ 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>()
|
||||||
@@ -391,11 +332,7 @@ class _AccountSync implements _SyncLoop {
|
|||||||
(e) =>
|
(e) =>
|
||||||
e is imap.ImapMessagesExistEvent || e is imap.ImapExpungeEvent,
|
e is imap.ImapMessagesExistEvent || e is imap.ImapExpungeEvent,
|
||||||
)
|
)
|
||||||
.listen((e) {
|
.listen((_) {
|
||||||
if (e is imap.ImapMessagesExistEvent &&
|
|
||||||
e.newMessagesExists > e.oldMessagesExists) {
|
|
||||||
hasNewMail = true;
|
|
||||||
}
|
|
||||||
if (!newMessageCompleter.isCompleted) newMessageCompleter.complete();
|
if (!newMessageCompleter.isCompleted) newMessageCompleter.complete();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -403,23 +340,14 @@ 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.
|
||||||
final idleTimer = Timer(const Duration(minutes: 25), () {
|
await Future.any([
|
||||||
if (_stopSignal != null && !_stopSignal!.isCompleted) {
|
newMessageCompleter.future,
|
||||||
_stopSignal!.complete();
|
Future.delayed(const Duration(minutes: 25)),
|
||||||
}
|
_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;
|
||||||
@@ -436,24 +364,18 @@ 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);
|
||||||
|
|
||||||
@@ -481,7 +403,6 @@ 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,
|
||||||
@@ -501,10 +422,8 @@ 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(
|
||||||
@@ -545,7 +464,6 @@ 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') ||
|
||||||
@@ -557,16 +475,11 @@ 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>();
|
||||||
_waitTimer = Timer(Duration(seconds: seconds), () {
|
await Future.any([
|
||||||
if (!_stopSignal!.isCompleted) _stopSignal!.complete();
|
Future.delayed(Duration(seconds: seconds)),
|
||||||
});
|
_stopSignal!.future,
|
||||||
try {
|
]);
|
||||||
await _stopSignal!.future;
|
_stopSignal = null;
|
||||||
} finally {
|
|
||||||
_waitTimer?.cancel();
|
|
||||||
_waitTimer = null;
|
|
||||||
_stopSignal = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<(_SyncStats, String?)> _runSync(bool verbose) async {
|
Future<(_SyncStats, String?)> _runSync(bool verbose) async {
|
||||||
@@ -601,7 +514,6 @@ 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(
|
||||||
@@ -610,11 +522,9 @@ 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,
|
||||||
@@ -641,16 +551,11 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
onError: (_) {},
|
onError: (_) {},
|
||||||
);
|
);
|
||||||
|
|
||||||
final pollTimer = Timer(_pollInterval, () {
|
await Future.any([
|
||||||
if (_stopSignal != null && !_stopSignal!.isCompleted) {
|
pushReady.future,
|
||||||
_stopSignal!.complete();
|
Future.delayed(_pollInterval),
|
||||||
}
|
_stopSignal!.future,
|
||||||
});
|
]);
|
||||||
try {
|
|
||||||
await Future.any([pushReady.future, _stopSignal!.future]);
|
|
||||||
} finally {
|
|
||||||
pollTimer.cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
await pushSub.cancel();
|
await pushSub.cancel();
|
||||||
_stopSignal = null;
|
_stopSignal = null;
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
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, {bool force = false}) async {
|
Future<void> _runForAccount(String accountId) 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 (!force && !_running) break;
|
if (!_running) break;
|
||||||
final result = await _emails.verifySyncReliability(
|
final result = await _emails.verifySyncReliability(
|
||||||
accountId,
|
accountId,
|
||||||
mailbox.path,
|
mailbox.path,
|
||||||
@@ -103,14 +103,7 @@ 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 {
|
||||||
final accounts = await _accounts.observeAccounts().first;
|
await _runAll();
|
||||||
for (final account in accounts) {
|
|
||||||
await _runForAccount(account.id, force: true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
|
||||||
|
|
||||||
/// Replaces `src="cid:..."` references in [html] with inline `data:` URIs
|
|
||||||
/// by looking up each Content-ID in the MIME tree of [msg].
|
|
||||||
///
|
|
||||||
/// Emails with `multipart/related` often embed images this way. Without
|
|
||||||
/// substitution the WebView shows broken image icons even after the full
|
|
||||||
/// message has been downloaded.
|
|
||||||
String injectInlineImages(String html, imap.MimeMessage msg) {
|
|
||||||
final inlineParts = msg.findContentInfo(
|
|
||||||
disposition: imap.ContentDisposition.inline,
|
|
||||||
);
|
|
||||||
if (inlineParts.isEmpty) return html;
|
|
||||||
|
|
||||||
var result = html;
|
|
||||||
for (final info in inlineParts) {
|
|
||||||
final cid = info.cid;
|
|
||||||
if (cid == null || cid.isEmpty) continue;
|
|
||||||
final bareCid = cid.startsWith('<') && cid.endsWith('>')
|
|
||||||
? cid.substring(1, cid.length - 1)
|
|
||||||
: cid;
|
|
||||||
|
|
||||||
final part = msg.getPart(info.fetchId);
|
|
||||||
if (part == null) continue;
|
|
||||||
final bytes = part.decodeContentBinary();
|
|
||||||
if (bytes == null || bytes.isEmpty) continue;
|
|
||||||
|
|
||||||
final contentType =
|
|
||||||
info.contentType?.mediaType.text ?? 'application/octet-stream';
|
|
||||||
final dataUri = 'data:$contentType;base64,${base64.encode(bytes)}';
|
|
||||||
|
|
||||||
result = result
|
|
||||||
.replaceAll('src="cid:$bareCid"', 'src="$dataUri"')
|
|
||||||
.replaceAll("src='cid:$bareCid'", "src='$dataUri'")
|
|
||||||
.replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"')
|
|
||||||
.replaceAll(
|
|
||||||
"src='cid:${bareCid.toLowerCase()}'",
|
|
||||||
"src='$dataUri'",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
+10
-184
@@ -3,7 +3,6 @@ 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';
|
||||||
|
|
||||||
@@ -89,9 +88,6 @@ 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};
|
||||||
}
|
}
|
||||||
@@ -108,8 +104,6 @@ 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};
|
||||||
@@ -205,8 +199,6 @@ 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.
|
||||||
@@ -235,44 +227,6 @@ 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')
|
||||||
@@ -288,21 +242,6 @@ 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(
|
||||||
@@ -319,57 +258,16 @@ class LocalSieveApplied 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 => 32;
|
int get schemaVersion => 22;
|
||||||
|
|
||||||
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
|
||||||
@@ -522,54 +420,6 @@ 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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -579,44 +429,20 @@ 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 {
|
||||||
try {
|
final dir = await getApplicationSupportDirectory();
|
||||||
final dir = await getApplicationSupportDirectory();
|
_dbPath = p.join(dir.path, 'sharedinbox.db');
|
||||||
_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(await _resolveDatabasePath());
|
final file = File(
|
||||||
|
_dbPath ??
|
||||||
|
p.join(
|
||||||
|
(await getApplicationSupportDirectory()).path,
|
||||||
|
'sharedinbox.db',
|
||||||
|
),
|
||||||
|
);
|
||||||
return NativeDatabase.createInBackground(
|
return NativeDatabase.createInBackground(
|
||||||
file,
|
file,
|
||||||
setup: (db) {
|
setup: (db) {
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
import 'package:drift/drift.dart';
|
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/sieve_script.dart';
|
|
||||||
import 'package:sharedinbox/data/db/database.dart';
|
|
||||||
|
|
||||||
class LocalSieveRepository {
|
|
||||||
LocalSieveRepository(this._db);
|
|
||||||
|
|
||||||
final AppDatabase _db;
|
|
||||||
|
|
||||||
Future<List<SieveScript>> listScripts(String accountId) async {
|
|
||||||
final rows = await (_db.select(_db.localSieveScripts)
|
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.get();
|
|
||||||
return rows
|
|
||||||
.map(
|
|
||||||
(r) => SieveScript(
|
|
||||||
id: r.id.toString(),
|
|
||||||
name: r.name,
|
|
||||||
blobId: r.id.toString(),
|
|
||||||
isActive: r.isActive,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> getScriptContent(String accountId, String blobId) async {
|
|
||||||
final rowId = int.parse(blobId);
|
|
||||||
final row = await (_db.select(_db.localSieveScripts)
|
|
||||||
..where(
|
|
||||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
|
||||||
))
|
|
||||||
.getSingleOrNull();
|
|
||||||
if (row == null) throw Exception('Local script not found: $blobId');
|
|
||||||
return row.content;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<SieveScript> saveScript(
|
|
||||||
String accountId, {
|
|
||||||
String? id,
|
|
||||||
required String name,
|
|
||||||
required String content,
|
|
||||||
}) async {
|
|
||||||
if (id != null) {
|
|
||||||
final rowId = int.parse(id);
|
|
||||||
await (_db.update(_db.localSieveScripts)
|
|
||||||
..where(
|
|
||||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
|
||||||
))
|
|
||||||
.write(
|
|
||||||
LocalSieveScriptsCompanion(
|
|
||||||
name: Value(name),
|
|
||||||
content: Value(content),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final updated = await (_db.select(_db.localSieveScripts)
|
|
||||||
..where(
|
|
||||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
|
||||||
))
|
|
||||||
.getSingleOrNull();
|
|
||||||
return SieveScript(
|
|
||||||
id: id,
|
|
||||||
name: name,
|
|
||||||
blobId: id,
|
|
||||||
isActive: updated?.isActive ?? false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final rowId = await _db.into(_db.localSieveScripts).insert(
|
|
||||||
LocalSieveScriptsCompanion.insert(
|
|
||||||
accountId: accountId,
|
|
||||||
name: name,
|
|
||||||
content: Value(content),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final idStr = rowId.toString();
|
|
||||||
return SieveScript(id: idStr, name: name, blobId: idStr, isActive: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> deleteScript(String accountId, String scriptId) async {
|
|
||||||
final rowId = int.parse(scriptId);
|
|
||||||
await (_db.delete(_db.localSieveScripts)
|
|
||||||
..where(
|
|
||||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
|
||||||
))
|
|
||||||
.go();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> activateScript(String accountId, String scriptId) async {
|
|
||||||
await _db.transaction(() async {
|
|
||||||
await (_db.update(_db.localSieveScripts)
|
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.write(const LocalSieveScriptsCompanion(isActive: Value(false)));
|
|
||||||
final rowId = int.parse(scriptId);
|
|
||||||
await (_db.update(_db.localSieveScripts)
|
|
||||||
..where(
|
|
||||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
|
||||||
))
|
|
||||||
.write(const LocalSieveScriptsCompanion(isActive: Value(true)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,52 +21,15 @@ class TlsModeMismatchException implements Exception {
|
|||||||
'STARTTLS). Original error: $original';
|
'STARTTLS). Original error: $original';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wraps a TLS certificate verification failure into a user-actionable message.
|
/// If [error] is a TLS handshake failure caused by a wrong-version-number
|
||||||
///
|
/// (i.e. the server is not speaking TLS), throw a [TlsModeMismatchException]
|
||||||
/// Thrown when the server's certificate cannot be verified — either because it
|
/// with [host]/[port] context. Otherwise rethrow [error] unchanged.
|
||||||
/// 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) {
|
||||||
final s = error.toString();
|
if (error.toString().contains('WRONG_VERSION_NUMBER')) {
|
||||||
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,23 +1,13 @@
|
|||||||
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(
|
DraftRepositoryImpl(this._db);
|
||||||
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({
|
||||||
@@ -105,110 +95,6 @@ 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,
|
||||||
@@ -218,6 +104,5 @@ 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,10 +13,6 @@ import 'package:sharedinbox/core/models/account.dart' as account_model;
|
|||||||
import 'package:sharedinbox/core/models/email.dart' as model;
|
import 'package:sharedinbox/core/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';
|
||||||
@@ -62,17 +58,15 @@ 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());
|
||||||
}
|
}
|
||||||
@@ -80,17 +74,15 @@ 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());
|
||||||
}
|
}
|
||||||
@@ -239,9 +231,7 @@ 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 rawHtml = msg.decodeTextHtmlPart();
|
final htmlBody = msg.decodeTextHtmlPart();
|
||||||
final htmlBody =
|
|
||||||
rawHtml == null ? null : injectInlineImages(rawHtml, msg);
|
|
||||||
final contentInfos = msg.findContentInfo();
|
final contentInfos = msg.findContentInfo();
|
||||||
|
|
||||||
final attachmentsJson = jsonEncode(
|
final attachmentsJson = jsonEncode(
|
||||||
@@ -265,8 +255,6 @@ 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,
|
||||||
@@ -274,7 +262,6 @@ 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()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -284,7 +271,6 @@ 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();
|
||||||
@@ -321,17 +307,9 @@ 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',
|
||||||
],
|
],
|
||||||
@@ -351,12 +329,6 @@ 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,
|
||||||
@@ -364,7 +336,6 @@ 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()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -375,7 +346,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
htmlBody: htmlBody,
|
htmlBody: htmlBody,
|
||||||
attachments: _parseAttachments(attachmentsJson),
|
attachments: _parseAttachments(attachmentsJson),
|
||||||
headers: _parseHeaders(headersJson),
|
headers: _parseHeaders(headersJson),
|
||||||
mimeTree: _parseMimeTree(mimeTreeJson),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,7 +528,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 LIST-UNSUBSCRIBE)])';
|
'(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (REFERENCES)])';
|
||||||
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);
|
||||||
@@ -599,7 +569,6 @@ 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,
|
||||||
@@ -643,7 +612,6 @@ 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),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -982,7 +950,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
'htmlBody',
|
'htmlBody',
|
||||||
'bodyValues',
|
'bodyValues',
|
||||||
'attachments',
|
'attachments',
|
||||||
'header:List-Unsubscribe:asText',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
static const _emailGetBodyOptions = {
|
static const _emailGetBodyOptions = {
|
||||||
@@ -1184,8 +1151,6 @@ 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(
|
||||||
@@ -1208,7 +1173,6 @@ 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),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1474,8 +1438,7 @@ 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)))
|
||||||
.getSingleOrNull();
|
.getSingle();
|
||||||
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) {
|
||||||
@@ -1547,70 +1510,12 @@ 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)))
|
||||||
.getSingleOrNull();
|
.getSingle();
|
||||||
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) {
|
||||||
@@ -1678,8 +1583,7 @@ 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)))
|
||||||
.getSingleOrNull();
|
.getSingle();
|
||||||
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.
|
||||||
@@ -1873,22 +1777,6 @@ 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) {
|
||||||
@@ -1920,218 +1808,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies locally stored active Sieve rules to INBOX emails that have not
|
|
||||||
/// been processed yet. See [EmailRepository.applySieveRules] for details.
|
|
||||||
@override
|
|
||||||
Future<int> applySieveRules(String accountId) async {
|
|
||||||
final scriptRow = await (_db.select(_db.localSieveScripts)
|
|
||||||
..where(
|
|
||||||
(t) => t.accountId.equals(accountId) & t.isActive.equals(true),
|
|
||||||
)
|
|
||||||
..limit(1))
|
|
||||||
.getSingleOrNull();
|
|
||||||
if (scriptRow == null) return 0;
|
|
||||||
|
|
||||||
List<SieveRule> rules;
|
|
||||||
try {
|
|
||||||
rules = SieveParser().parse(scriptRow.content);
|
|
||||||
} catch (e) {
|
|
||||||
log('Sieve parse error for account $accountId: $e');
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (rules.isEmpty) return 0;
|
|
||||||
|
|
||||||
final inboxMailbox = await (_db.select(_db.mailboxes)
|
|
||||||
..where(
|
|
||||||
(t) => t.accountId.equals(accountId) & t.role.equals('inbox'),
|
|
||||||
)
|
|
||||||
..limit(1))
|
|
||||||
.getSingleOrNull();
|
|
||||||
final inboxPath = inboxMailbox?.path ?? 'INBOX';
|
|
||||||
|
|
||||||
final alreadyApplied = await (_db.select(_db.localSieveApplied)
|
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.get();
|
|
||||||
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
|
|
||||||
|
|
||||||
final inboxEmails = await (_db.select(_db.emails)
|
|
||||||
..where(
|
|
||||||
(t) =>
|
|
||||||
t.accountId.equals(accountId) &
|
|
||||||
t.mailboxPath.equals(inboxPath) &
|
|
||||||
t.messageId.isNotNull(),
|
|
||||||
))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
final account = (await _accounts.getAccount(accountId))!;
|
|
||||||
final interpreter = SieveInterpreter();
|
|
||||||
var matched = 0;
|
|
||||||
|
|
||||||
for (final row in inboxEmails) {
|
|
||||||
final msgId = row.messageId!;
|
|
||||||
if (appliedIds.contains(msgId)) continue;
|
|
||||||
|
|
||||||
final emailCtx = _buildSieveContext(row);
|
|
||||||
|
|
||||||
SieveExecutionContext result;
|
|
||||||
try {
|
|
||||||
result = interpreter.execute(rules, emailCtx);
|
|
||||||
} catch (e) {
|
|
||||||
log('Sieve interpreter error for message $msgId: $e');
|
|
||||||
await _markSieveApplied(accountId, msgId);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _markSieveApplied(accountId, msgId);
|
|
||||||
|
|
||||||
if (result.isCancelled) {
|
|
||||||
await _enqueueSieveDelete(account, row);
|
|
||||||
matched++;
|
|
||||||
} else if (result.targetFolders.isNotEmpty) {
|
|
||||||
final dest = result.targetFolders.first;
|
|
||||||
await _enqueueSieveMove(account, row, dest);
|
|
||||||
matched++;
|
|
||||||
} else if (result.flagsToAdd.isNotEmpty) {
|
|
||||||
await _enqueueSieveFlagSeen(account, row);
|
|
||||||
matched++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matched;
|
|
||||||
}
|
|
||||||
|
|
||||||
SieveEmailContext _buildSieveContext(Email row) {
|
|
||||||
String formatAddrs(String json) {
|
|
||||||
try {
|
|
||||||
final list = jsonDecode(json) as List<dynamic>;
|
|
||||||
return list.map((e) {
|
|
||||||
final m = e as Map<String, dynamic>;
|
|
||||||
final name = m['name'] as String? ?? '';
|
|
||||||
final email = m['email'] as String? ?? '';
|
|
||||||
return name.isEmpty ? email : '$name <$email>';
|
|
||||||
}).join(', ');
|
|
||||||
} catch (_) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return SieveEmailContext(
|
|
||||||
headers: {
|
|
||||||
if (row.subject != null && row.subject!.isNotEmpty)
|
|
||||||
'subject': [row.subject!],
|
|
||||||
'from': [formatAddrs(row.fromJson)],
|
|
||||||
'to': [formatAddrs(row.toAddresses)],
|
|
||||||
'cc': [formatAddrs(row.ccJson)],
|
|
||||||
if (row.messageId != null) 'message-id': [row.messageId!],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _markSieveApplied(String accountId, String messageId) async {
|
|
||||||
await _db.into(_db.localSieveApplied).insertOnConflictUpdate(
|
|
||||||
LocalSieveAppliedCompanion.insert(
|
|
||||||
accountId: accountId,
|
|
||||||
messageId: messageId,
|
|
||||||
appliedAt: DateTime.now(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _enqueueSieveMove(
|
|
||||||
account_model.Account account,
|
|
||||||
Email row,
|
|
||||||
String folder,
|
|
||||||
) async {
|
|
||||||
String destPath;
|
|
||||||
if (account.type == account_model.AccountType.jmap) {
|
|
||||||
final destMailbox = await (_db.select(_db.mailboxes)
|
|
||||||
..where(
|
|
||||||
(t) => t.accountId.equals(account.id) & t.name.equals(folder),
|
|
||||||
)
|
|
||||||
..limit(1))
|
|
||||||
.getSingleOrNull();
|
|
||||||
if (destMailbox == null) {
|
|
||||||
log('Sieve: JMAP mailbox "$folder" not found for account ${account.id}');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
destPath = destMailbox.path;
|
|
||||||
await _enqueueChange(
|
|
||||||
account.id,
|
|
||||||
row.id,
|
|
||||||
'move',
|
|
||||||
jsonEncode({'src': row.mailboxPath, 'dest': destPath}),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
destPath = folder;
|
|
||||||
await _enqueueChange(
|
|
||||||
account.id,
|
|
||||||
row.id,
|
|
||||||
'move',
|
|
||||||
jsonEncode({
|
|
||||||
'uid': row.uid,
|
|
||||||
'mailboxPath': row.mailboxPath,
|
|
||||||
'dest': destPath,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await (_db.update(_db.emails)..where((t) => t.id.equals(row.id))).write(
|
|
||||||
EmailsCompanion(mailboxPath: Value(destPath)),
|
|
||||||
);
|
|
||||||
await _updateThread(account.id, row.mailboxPath, row.threadId ?? row.id);
|
|
||||||
await _updateThread(account.id, destPath, row.threadId ?? row.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _enqueueSieveDelete(
|
|
||||||
account_model.Account account,
|
|
||||||
Email row,
|
|
||||||
) async {
|
|
||||||
if (account.type == account_model.AccountType.jmap) {
|
|
||||||
await _enqueueChange(
|
|
||||||
account.id,
|
|
||||||
row.id,
|
|
||||||
'delete',
|
|
||||||
jsonEncode(<String, dynamic>{}),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await _enqueueChange(
|
|
||||||
account.id,
|
|
||||||
row.id,
|
|
||||||
'delete',
|
|
||||||
jsonEncode({'uid': row.uid, 'mailboxPath': row.mailboxPath}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await (_db.delete(_db.emails)..where((t) => t.id.equals(row.id))).go();
|
|
||||||
await _updateThread(account.id, row.mailboxPath, row.threadId ?? row.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _enqueueSieveFlagSeen(
|
|
||||||
account_model.Account account,
|
|
||||||
Email row,
|
|
||||||
) async {
|
|
||||||
if (account.type == account_model.AccountType.jmap) {
|
|
||||||
await _enqueueChange(
|
|
||||||
account.id,
|
|
||||||
row.id,
|
|
||||||
'flag_seen',
|
|
||||||
jsonEncode({'seen': true}),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await _enqueueChange(
|
|
||||||
account.id,
|
|
||||||
row.id,
|
|
||||||
'flag_seen',
|
|
||||||
jsonEncode({
|
|
||||||
'uid': row.uid,
|
|
||||||
'mailboxPath': row.mailboxPath,
|
|
||||||
'seen': true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await (_db.update(_db.emails)..where((t) => t.id.equals(row.id))).write(
|
|
||||||
const EmailsCompanion(isSeen: Value(true)),
|
|
||||||
);
|
|
||||||
await _updateThread(account.id, row.mailboxPath, row.threadId ?? row.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drains pending changes for [accountId] via the appropriate protocol.
|
/// 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
|
||||||
@@ -2247,18 +1923,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
.go();
|
.go();
|
||||||
applied++;
|
applied++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (_isImapNotFoundError(e)) {
|
await _recordChangeError(row, 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 {
|
||||||
@@ -2267,19 +1932,13 @@ 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;
|
||||||
// snooze/unsnooze payloads use 'src' for the source folder; all others use 'mailboxPath'.
|
final mailboxPath = payload['mailboxPath'] as String;
|
||||||
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);
|
||||||
|
|
||||||
@@ -2418,29 +2077,8 @@ 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';
|
||||||
var destMailboxId = payload['dest'] as String;
|
final 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',
|
||||||
@@ -2804,13 +2442,9 @@ 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[]',
|
'BODY.PEEK[${attachment.fetchPartId}]',
|
||||||
);
|
);
|
||||||
final msg = fetch.messages.first;
|
final msg = fetch.messages.first;
|
||||||
final part = msg.getPart(attachment.fetchPartId) ?? msg;
|
final part = msg.getPart(attachment.fetchPartId) ?? msg;
|
||||||
@@ -2825,103 +2459,33 @@ 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
|
||||||
.trim()
|
.toLowerCase()
|
||||||
.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();
|
||||||
if (words.isEmpty) return '';
|
final rows = await (_db.select(_db.emails)
|
||||||
return words.map((w) => '$w*').join(' ');
|
..where((t) {
|
||||||
|
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
|
||||||
@@ -2947,52 +2511,6 @@ 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,
|
||||||
@@ -3145,7 +2663,6 @@ 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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3155,27 +2672,6 @@ 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) {
|
||||||
@@ -3267,36 +2763,3 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recursively converts an [imap.MimePart] into a JSON-serialisable map.
|
|
||||||
Map<String, dynamic> _mimePartToJson(imap.MimePart part) {
|
|
||||||
final ct = part.getHeaderContentType();
|
|
||||||
final disposition = part.getHeaderContentDisposition();
|
|
||||||
final rawEncoding =
|
|
||||||
part.getHeader('content-transfer-encoding')?.firstOrNull?.value;
|
|
||||||
final encoding = rawEncoding?.split(';').first.trim().toLowerCase();
|
|
||||||
return {
|
|
||||||
'contentType': ct?.mediaType.text ?? 'application/octet-stream',
|
|
||||||
'filename': disposition?.filename ?? ct?.parameters['name'],
|
|
||||||
'size': disposition?.size,
|
|
||||||
'encoding': encoding,
|
|
||||||
'children': (part.parts ?? []).map(_mimePartToJson).toList(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a JSON string representing the MIME tree of [msg].
|
|
||||||
String _buildMimeTreeJson(imap.MimeMessage msg) =>
|
|
||||||
jsonEncode(_mimePartToJson(msg));
|
|
||||||
|
|
||||||
/// Converts a JMAP `bodyStructure` object into the same JSON format used by
|
|
||||||
/// [_mimePartToJson], so [_parseMimeTree] can deserialise it uniformly.
|
|
||||||
Map<String, dynamic> _jmapBodyStructureToJson(Map<String, dynamic> m) => {
|
|
||||||
'contentType': m['type'] as String? ?? 'application/octet-stream',
|
|
||||||
'filename': m['name'],
|
|
||||||
'size': m['size'],
|
|
||||||
'encoding': null,
|
|
||||||
'children': ((m['subParts'] as List<dynamic>?) ?? [])
|
|
||||||
.cast<Map<String, dynamic>>()
|
|
||||||
.map(_jmapBodyStructureToJson)
|
|
||||||
.toList(),
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import 'package:drift/drift.dart';
|
|
||||||
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
|
||||||
import 'package:sharedinbox/data/db/database.dart';
|
|
||||||
|
|
||||||
class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
|
||||||
SearchHistoryRepositoryImpl(this._db);
|
|
||||||
final AppDatabase _db;
|
|
||||||
|
|
||||||
static const _maxEntries = 10;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<String>> getRecentSearches() async {
|
|
||||||
final rows = await (_db.select(_db.searchHistoryEntries)
|
|
||||||
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
|
|
||||||
..limit(_maxEntries))
|
|
||||||
.get();
|
|
||||||
return rows.map((r) => r.query).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> saveSearch(String query) async {
|
|
||||||
final trimmed = query.trim();
|
|
||||||
if (trimmed.isEmpty) return;
|
|
||||||
|
|
||||||
await _db.transaction(() async {
|
|
||||||
// Remove existing entry for same query (deduplication).
|
|
||||||
await (_db.delete(_db.searchHistoryEntries)
|
|
||||||
..where((t) => t.query.equals(trimmed)))
|
|
||||||
.go();
|
|
||||||
|
|
||||||
await _db.into(_db.searchHistoryEntries).insert(
|
|
||||||
SearchHistoryEntriesCompanion.insert(
|
|
||||||
query: trimmed,
|
|
||||||
searchedAt: DateTime.now(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Prune to the most recent _maxEntries.
|
|
||||||
final keepIds = await (_db.select(_db.searchHistoryEntries)
|
|
||||||
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
|
|
||||||
..limit(_maxEntries))
|
|
||||||
.map((r) => r.id)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (keepIds.isNotEmpty) {
|
|
||||||
await (_db.delete(_db.searchHistoryEntries)
|
|
||||||
..where((t) => t.id.isNotIn(keepIds)))
|
|
||||||
.go();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> clearHistory() async {
|
|
||||||
await _db.delete(_db.searchHistoryEntries).go();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:drift/drift.dart';
|
|
||||||
|
|
||||||
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
|
||||||
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
|
||||||
import 'package:sharedinbox/data/db/database.dart';
|
|
||||||
|
|
||||||
/// Drift-backed implementation of [ShareKeyRepository].
|
|
||||||
///
|
|
||||||
/// Each key pair lives for 20 minutes. Expired rows are pruned whenever a
|
|
||||||
/// new key pair is created or looked up.
|
|
||||||
class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
|
||||||
ShareKeyRepositoryImpl(this._db);
|
|
||||||
|
|
||||||
final AppDatabase _db;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ShareKeyMaterial> createKeyPair() async {
|
|
||||||
await _pruneExpired();
|
|
||||||
|
|
||||||
final material = await ShareEncryptionService.generateKeyPair();
|
|
||||||
final keyIdHex = _hex(material.keyId);
|
|
||||||
final expiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
|
|
||||||
|
|
||||||
await _db.into(_db.shareKeys).insert(
|
|
||||||
ShareKeysCompanion.insert(
|
|
||||||
id: keyIdHex,
|
|
||||||
publicKey: base64.encode(material.publicKeyBytes),
|
|
||||||
privateKey: base64.encode(material.privateKeyBytes),
|
|
||||||
expiresAt: expiresAt,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return material;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ShareKeyMaterial?> findByKeyId(Uint8List keyId) async {
|
|
||||||
await _pruneExpired();
|
|
||||||
|
|
||||||
final keyIdHex = _hex(keyId);
|
|
||||||
final row = await (_db.select(_db.shareKeys)
|
|
||||||
..where((t) => t.id.equals(keyIdHex)))
|
|
||||||
.getSingleOrNull();
|
|
||||||
|
|
||||||
if (row == null) return null;
|
|
||||||
if (row.expiresAt.isBefore(DateTime.now().toUtc())) return null;
|
|
||||||
|
|
||||||
return ShareKeyMaterial(
|
|
||||||
keyId: keyId,
|
|
||||||
publicKeyBytes: Uint8List.fromList(base64.decode(row.publicKey)),
|
|
||||||
privateKeyBytes: Uint8List.fromList(base64.decode(row.privateKey)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _pruneExpired() async {
|
|
||||||
await (_db.delete(_db.shareKeys)
|
|
||||||
..where(
|
|
||||||
(t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()),
|
|
||||||
))
|
|
||||||
.go();
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _hex(Uint8List bytes) =>
|
|
||||||
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
|
||||||
}
|
|
||||||
@@ -49,7 +49,6 @@ 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),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -91,9 +90,6 @@ 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(),
|
||||||
|
|||||||
+2
-54
@@ -3,33 +3,26 @@ 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' hide Email, EmailBody;
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
import 'package:sharedinbox/data/db/local_sieve_repository.dart';
|
|
||||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
|
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
|
||||||
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';
|
||||||
@@ -63,10 +56,6 @@ 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),
|
||||||
@@ -76,11 +65,7 @@ final mailboxRepositoryProvider = Provider<MailboxRepository>((ref) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final draftRepositoryProvider = Provider<DraftRepository>((ref) {
|
final draftRepositoryProvider = Provider<DraftRepository>((ref) {
|
||||||
return DraftRepositoryImpl(
|
return DraftRepositoryImpl(ref.watch(dbProvider));
|
||||||
ref.watch(dbProvider),
|
|
||||||
ref.watch(accountRepositoryProvider),
|
|
||||||
imapConnect: ref.watch(imapConnectProvider),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final emailRepositoryProvider = Provider<EmailRepository>((ref) {
|
final emailRepositoryProvider = Provider<EmailRepository>((ref) {
|
||||||
@@ -96,11 +81,6 @@ 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));
|
||||||
});
|
});
|
||||||
@@ -130,11 +110,6 @@ 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),
|
||||||
@@ -142,8 +117,6 @@ 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;
|
||||||
@@ -162,10 +135,6 @@ 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),
|
||||||
@@ -187,27 +156,6 @@ 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(
|
||||||
|
|||||||
+1
-8
@@ -1,11 +1,8 @@
|
|||||||
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';
|
||||||
@@ -35,10 +32,6 @@ 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()),
|
||||||
);
|
);
|
||||||
@@ -70,7 +63,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'sharedinbox.de',
|
title: 'SharedInbox',
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ 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';
|
||||||
@@ -36,14 +33,6 @@ 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(),
|
||||||
@@ -52,10 +41,6 @@ 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(
|
||||||
@@ -80,21 +65,6 @@ 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) =>
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
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,10 +3,9 @@ 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});
|
||||||
@@ -15,7 +14,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.de'),
|
title: const Text('SharedInbox'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
@@ -30,18 +29,10 @@ class AccountListScreen extends ConsumerWidget {
|
|||||||
const DrawerHeader(
|
const DrawerHeader(
|
||||||
decoration: BoxDecoration(color: Colors.blueGrey),
|
decoration: BoxDecoration(color: Colors.blueGrey),
|
||||||
child: Text(
|
child: Text(
|
||||||
'sharedinbox.de',
|
'SharedInbox',
|
||||||
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'),
|
||||||
@@ -58,39 +49,37 @@ 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: Column(
|
body: StreamBuilder(
|
||||||
children: [
|
stream: ref.watch(accountRepositoryProvider).observeAccounts(),
|
||||||
const _UpdateBanner(),
|
builder: (ctx, snap) {
|
||||||
Expanded(
|
if (!snap.hasData) {
|
||||||
child: StreamBuilder(
|
return const Center(child: CircularProgressIndicator());
|
||||||
stream: ref.watch(accountRepositoryProvider).observeAccounts(),
|
}
|
||||||
builder: (ctx, snap) {
|
final accounts = snap.data!;
|
||||||
if (!snap.hasData) {
|
if (accounts.isEmpty) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return Center(
|
||||||
}
|
child: Column(
|
||||||
final accounts = snap.data!;
|
mainAxisSize: MainAxisSize.min,
|
||||||
if (accounts.isEmpty) {
|
children: [
|
||||||
return const _OnboardingView();
|
const Text('No accounts yet.'),
|
||||||
}
|
const SizedBox(height: 12),
|
||||||
return ListView.builder(
|
FilledButton.icon(
|
||||||
itemCount: accounts.length,
|
onPressed: () => context.push('/accounts/add'),
|
||||||
itemBuilder: (ctx, i) => _AccountTile(account: accounts[i]),
|
icon: const Icon(Icons.add),
|
||||||
);
|
label: const Text('Add account'),
|
||||||
},
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: accounts.length,
|
||||||
|
itemBuilder: (ctx, i) => _AccountTile(account: accounts[i]),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () => context.push('/accounts/add'),
|
onPressed: () => context.push('/accounts/add'),
|
||||||
@@ -170,27 +159,15 @@ 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.emailFiltersRemote,
|
value: _AccountAction.emailFilters,
|
||||||
child: Text('Server email filters'),
|
child: Text('Email filters'),
|
||||||
),
|
),
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.emailFiltersLocal,
|
|
||||||
child: Text('Local email filters'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.send,
|
|
||||||
child: Text('Send accounts'),
|
|
||||||
),
|
|
||||||
const PopupMenuDivider(),
|
const PopupMenuDivider(),
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: _AccountAction.delete,
|
value: _AccountAction.delete,
|
||||||
@@ -217,53 +194,16 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(content: Text('Starting sync verification...')),
|
||||||
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.emailFiltersRemote:
|
case _AccountAction.emailFilters:
|
||||||
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,
|
||||||
@@ -293,122 +233,7 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _OnboardingView extends StatelessWidget {
|
enum _AccountAction { syncLog, verifySync, edit, emailFilters, delete }
|
||||||
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].
|
||||||
///
|
///
|
||||||
@@ -420,31 +245,3 @@ 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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,391 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
|
||||||
import 'package:qr_flutter/qr_flutter.dart';
|
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
|
||||||
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
|
||||||
import 'package:sharedinbox/di.dart';
|
|
||||||
|
|
||||||
/// Receiving side of the secure account-sharing flow.
|
|
||||||
///
|
|
||||||
/// Step 1 – generates an X25519 key pair with a 20-minute lifetime and shows
|
|
||||||
/// the public key as a QR code to be scanned by the sender.
|
|
||||||
///
|
|
||||||
/// Step 2 – scans the encrypted-accounts QR code shown by the sender, decrypts
|
|
||||||
/// it using the private key, and imports the accounts.
|
|
||||||
class AccountReceiveScreen extends ConsumerStatefulWidget {
|
|
||||||
const AccountReceiveScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<AccountReceiveScreen> createState() =>
|
|
||||||
_AccountReceiveScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
|
|
||||||
|
|
||||||
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|
||||||
_Step _step = _Step.generatingKey;
|
|
||||||
ShareKeyMaterial? _keyMaterial;
|
|
||||||
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]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
|
||||||
import 'package:qr_flutter/qr_flutter.dart';
|
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
|
||||||
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
|
||||||
import 'package:sharedinbox/di.dart';
|
|
||||||
|
|
||||||
/// Sending side of the secure account-sharing flow.
|
|
||||||
///
|
|
||||||
/// Step 1 – scans (or pastes) the receiver's public-key QR code.
|
|
||||||
///
|
|
||||||
/// Step 2 – if more than one account exists, the user selects which accounts
|
|
||||||
/// to transfer (auto-selected when only one account is present).
|
|
||||||
///
|
|
||||||
/// Step 3 – shows the encrypted-accounts QR code for the receiver to scan.
|
|
||||||
class AccountSendScreen extends ConsumerStatefulWidget {
|
|
||||||
const AccountSendScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<AccountSendScreen> createState() => _AccountSendScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
enum _Step { scanning, selectAccounts, showEncrypted, error }
|
|
||||||
|
|
||||||
class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
|
||||||
_Step _step = _Step.scanning;
|
|
||||||
|
|
||||||
// Set after scanning the pubkey QR.
|
|
||||||
Uint8List? _recipientKeyId;
|
|
||||||
Uint8List? _recipientPublicKey;
|
|
||||||
|
|
||||||
// All available accounts + the selection (for step 2).
|
|
||||||
List<Account> _accounts = [];
|
|
||||||
final Set<String> _selectedIds = {};
|
|
||||||
|
|
||||||
// Set after encryption (step 3).
|
|
||||||
String? _encryptedQr;
|
|
||||||
String? _errorMessage;
|
|
||||||
bool _scannerActive = true;
|
|
||||||
|
|
||||||
MobileScannerController? _scannerController;
|
|
||||||
|
|
||||||
@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,13 +295,6 @@ 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'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class ChangeLogScreen extends StatelessWidget {
|
class ChangeLogScreen extends StatelessWidget {
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ 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;
|
||||||
@@ -141,8 +139,6 @@ 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) {
|
||||||
@@ -196,12 +192,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(
|
).showSnackBar(SnackBar(content: Text('Failed to open file: $e')));
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -215,12 +206,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
if (_accountId == null) {
|
if (_accountId == null) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(
|
).showSnackBar(const SnackBar(content: Text('Select an account first')));
|
||||||
const SnackBar(
|
|
||||||
duration: Duration(seconds: 5),
|
|
||||||
content: Text('Select an account first'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _sending = true);
|
setState(() => _sending = true);
|
||||||
@@ -257,12 +243,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(
|
).showSnackBar(SnackBar(content: Text('Send failed: $e')));
|
||||||
SnackBar(
|
|
||||||
duration: const Duration(seconds: 5),
|
|
||||||
content: Text('Send failed: $e'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _sending = false);
|
if (mounted) setState(() => _sending = false);
|
||||||
}
|
}
|
||||||
@@ -334,8 +315,8 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_addressField(_to, _toFocus, 'To'),
|
_field(_to, 'To', keyboardType: TextInputType.emailAddress),
|
||||||
_addressField(_cc, _ccFocus, 'Cc'),
|
_field(_cc, 'Cc', keyboardType: TextInputType.emailAddress),
|
||||||
_field(_subject, 'Subject'),
|
_field(_subject, 'Subject'),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
@@ -388,96 +369,6 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _addressField(
|
|
||||||
TextEditingController ctrl,
|
|
||||||
FocusNode focusNode,
|
|
||||||
String label,
|
|
||||||
) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
||||||
child: RawAutocomplete<EmailAddress>(
|
|
||||||
textEditingController: ctrl,
|
|
||||||
focusNode: focusNode,
|
|
||||||
displayStringForOption: (option) {
|
|
||||||
final text = ctrl.text;
|
|
||||||
final lastComma = text.lastIndexOf(',');
|
|
||||||
final prefix =
|
|
||||||
lastComma >= 0 ? '${text.substring(0, lastComma + 1)} ' : '';
|
|
||||||
return '$prefix${option.email}, ';
|
|
||||||
},
|
|
||||||
optionsBuilder: (value) async {
|
|
||||||
final text = value.text;
|
|
||||||
final lastComma = text.lastIndexOf(',');
|
|
||||||
final token = lastComma >= 0
|
|
||||||
? text.substring(lastComma + 1).trim()
|
|
||||||
: text.trim();
|
|
||||||
if (token.length < 2) return const [];
|
|
||||||
final results = await ref
|
|
||||||
.read(emailRepositoryProvider)
|
|
||||||
.searchAddresses(null, token);
|
|
||||||
// Guard: if focus left the field while the query was running,
|
|
||||||
// return empty so RawAutocomplete doesn't call show() after hide()
|
|
||||||
// has already been called — that races into an assertion in overlay.dart.
|
|
||||||
if (!focusNode.hasFocus) return const [];
|
|
||||||
return results;
|
|
||||||
},
|
|
||||||
fieldViewBuilder: (ctx, fieldCtrl, fieldFocusNode, onFieldSubmitted) {
|
|
||||||
return TextFormField(
|
|
||||||
controller: fieldCtrl,
|
|
||||||
focusNode: fieldFocusNode,
|
|
||||||
keyboardType: TextInputType.emailAddress,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: label,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
onFieldSubmitted: (_) => onFieldSubmitted(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
optionsViewBuilder: (ctx, onSelected, options) {
|
|
||||||
return Align(
|
|
||||||
alignment: Alignment.topLeft,
|
|
||||||
child: Material(
|
|
||||||
elevation: 4,
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxHeight: 200),
|
|
||||||
child: ListView.builder(
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: options.length,
|
|
||||||
itemBuilder: (ctx, i) {
|
|
||||||
final option = options.elementAt(i);
|
|
||||||
return InkWell(
|
|
||||||
onTap: () => onSelected(option),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
child: option.name != null
|
|
||||||
? Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(option.name!),
|
|
||||||
Text(
|
|
||||||
option.email,
|
|
||||||
style: const TextStyle(fontSize: 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: Text(option.email),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _field(
|
Widget _field(
|
||||||
TextEditingController ctrl,
|
TextEditingController ctrl,
|
||||||
String label, {
|
String label, {
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
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 {
|
||||||
@@ -15,26 +12,6 @@ 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(
|
||||||
@@ -43,22 +20,39 @@ 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: Builder(
|
body: SingleChildScrollView(
|
||||||
builder: (ctx) => SingleChildScrollView(
|
padding: const EdgeInsets.all(16),
|
||||||
padding: const EdgeInsets.all(16),
|
child: Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
children: [
|
||||||
children: [
|
const Icon(Icons.error_outline, color: Colors.red, size: 64),
|
||||||
const Icon(Icons.error_outline, color: Colors.red, size: 64),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 16),
|
Text(
|
||||||
Text(
|
'SharedInbox encountered an unexpected error and needs to be restarted.',
|
||||||
'sharedinbox.de encountered an unexpected error and needs to be restarted.',
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
style: Theme.of(ctx).textTheme.titleMedium,
|
textAlign: TextAlign.center,
|
||||||
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),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
child: Text(
|
||||||
|
exception.toString(),
|
||||||
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (stackTrace != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
const Text(
|
const Text(
|
||||||
'Error Details:',
|
'Stack Trace:',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -69,120 +63,64 @@ class CrashScreen extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
exception.toString(),
|
stackTrace.toString(),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: 12,
|
fontSize: 10,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (stackTrace != null) ...[
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 24),
|
||||||
const Text(
|
FilledButton.icon(
|
||||||
'Stack Trace:',
|
onPressed: () async {
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
final data = 'Error: $exception\n\nStack Trace:\n$stackTrace';
|
||||||
),
|
await Clipboard.setData(ClipboardData(text: data));
|
||||||
const SizedBox(height: 8),
|
if (context.mounted) {
|
||||||
Container(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
padding: const EdgeInsets.all(12),
|
const SnackBar(content: Text('Copied to clipboard')),
|
||||||
decoration: BoxDecoration(
|
);
|
||||||
color: Colors.grey[200],
|
}
|
||||||
borderRadius: BorderRadius.circular(8),
|
},
|
||||||
),
|
icon: const Icon(Icons.copy),
|
||||||
child: Text(
|
label: const Text('Copy to Clipboard'),
|
||||||
stackTrace.toString(),
|
),
|
||||||
style: const TextStyle(
|
const SizedBox(height: 16),
|
||||||
fontFamily: 'monospace',
|
OutlinedButton.icon(
|
||||||
fontSize: 10,
|
onPressed: () async {
|
||||||
),
|
final title = Uri.encodeComponent(
|
||||||
),
|
'Crash: ${exception.toString().split('\n').first}',
|
||||||
),
|
);
|
||||||
],
|
final body = Uri.encodeComponent(
|
||||||
if (_gitHash.isNotEmpty) ...[
|
'Error: $exception\n\nStack Trace:\n$stackTrace',
|
||||||
const SizedBox(height: 16),
|
);
|
||||||
const Text(
|
final url = Uri.parse(
|
||||||
'Git Commit:',
|
'https://codeberg.org/guettli/sharedinbox/issues/new?title=$title&body=$body',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
);
|
||||||
),
|
try {
|
||||||
const SizedBox(height: 4),
|
final launched = await launchUrl(
|
||||||
GestureDetector(
|
url,
|
||||||
onTap: () async {
|
mode: LaunchMode.externalApplication,
|
||||||
final url = Uri.parse(
|
);
|
||||||
'https://codeberg.org/guettli/sharedinbox/commit/$_gitHash',
|
if (!launched && context.mounted) {
|
||||||
);
|
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(
|
||||||
duration: Duration(seconds: 5),
|
content: Text('Could not open browser.'),
|
||||||
content: Text('Copied to clipboard'),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
} catch (e) {
|
||||||
icon: const Icon(Icons.copy),
|
if (context.mounted) {
|
||||||
label: const Text('Copy to Clipboard'),
|
ScaffoldMessenger.of(
|
||||||
),
|
context,
|
||||||
const SizedBox(height: 16),
|
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||||
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),
|
},
|
||||||
label: const Text('Report Issue on Codeberg'),
|
icon: const Icon(Icons.bug_report),
|
||||||
),
|
label: const Text('Report Issue on Codeberg'),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ 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() {
|
||||||
@@ -170,6 +171,43 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _forceResync() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Force full sync?'),
|
||||||
|
content: const Text(
|
||||||
|
'This clears all locally-cached emails and mailboxes for this '
|
||||||
|
'account and immediately re-downloads everything from the server. '
|
||||||
|
'Previously viewed email content will not need to be re-downloaded.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
child: const Text('Force sync'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
setState(() => _resyncing = true);
|
||||||
|
try {
|
||||||
|
await ref.read(syncManagerProvider).forceResync(widget.accountId);
|
||||||
|
if (mounted) context.pop();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_resyncing = false;
|
||||||
|
_errorMessage = 'Force sync failed: $e';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
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;
|
||||||
@@ -230,7 +268,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
|
body: _loading || _saving || _resyncing
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _buildForm(),
|
: _buildForm(),
|
||||||
);
|
);
|
||||||
@@ -349,6 +387,15 @@ 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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
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/services.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';
|
||||||
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');
|
||||||
|
|
||||||
@@ -31,153 +25,144 @@ 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);
|
||||||
final detail = ref.watch(emailDetailProvider(widget.emailId));
|
return FutureBuilder<(Email?, EmailBody)>(
|
||||||
|
future: _dataFuture,
|
||||||
|
builder: (ctx, snap) {
|
||||||
|
final header = snap.data?.$1;
|
||||||
|
final body = snap.data?.$2;
|
||||||
|
|
||||||
ref.listen<AsyncValue<(Email?, EmailBody)>>(
|
return Scaffold(
|
||||||
emailDetailProvider(widget.emailId),
|
appBar: AppBar(
|
||||||
(_, next) {
|
title: Text(
|
||||||
final email = next.valueOrNull?.$1;
|
header?.subject ?? '(loading…)',
|
||||||
if (email != null && mounted) {
|
overflow: TextOverflow.ellipsis,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
tooltip: _isFlagged ? 'Unflag' : 'Flag',
|
actions: [
|
||||||
onPressed: () async {
|
IconButton(
|
||||||
final next = !_isFlagged;
|
icon: const Icon(Icons.reply),
|
||||||
await repo.setFlag(widget.emailId, flagged: next);
|
tooltip: 'Reply',
|
||||||
if (mounted) setState(() => _isFlagged = next);
|
onPressed: header == null
|
||||||
},
|
? null
|
||||||
),
|
: () => _reply(context, header, body, replyAll: false),
|
||||||
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) {
|
|
||||||
unawaited(
|
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
|
||||||
UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: header.accountId,
|
|
||||||
type: UndoType.delete,
|
|
||||||
emailIds: [widget.emailId],
|
|
||||||
sourceMailboxPath: header.mailboxPath,
|
|
||||||
destinationMailboxPath: destPath,
|
|
||||||
originalEmails: [header],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.mounted) context.pop();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
PopupMenuButton<String>(
|
|
||||||
itemBuilder: (ctx) => [
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: 'headers',
|
|
||||||
child: Text('Show Mail Headers'),
|
|
||||||
),
|
),
|
||||||
const PopupMenuItem(
|
IconButton(
|
||||||
value: 'structure',
|
icon: const Icon(Icons.reply_all),
|
||||||
child: Text('Show Mail Structure'),
|
tooltip: 'Reply all',
|
||||||
|
onPressed: header == null
|
||||||
|
? null
|
||||||
|
: () => _reply(context, header, body, replyAll: true),
|
||||||
),
|
),
|
||||||
const PopupMenuItem(
|
IconButton(
|
||||||
value: 'rfc',
|
icon: const Icon(Icons.forward),
|
||||||
child: Text('Show Raw Email'),
|
tooltip: 'Forward',
|
||||||
|
onPressed: header == null
|
||||||
|
? null
|
||||||
|
: () => _forward(context, header, body),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.mark_email_unread_outlined),
|
||||||
|
tooltip: 'Mark as unread',
|
||||||
|
onPressed: () async {
|
||||||
|
await repo.setFlag(widget.emailId, seen: false);
|
||||||
|
if (context.mounted) context.pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_isFlagged ? Icons.star : Icons.star_border,
|
||||||
|
color: _isFlagged ? Colors.amber : null,
|
||||||
|
),
|
||||||
|
tooltip: _isFlagged ? 'Unflag' : 'Flag',
|
||||||
|
onPressed: () async {
|
||||||
|
final next = !_isFlagged;
|
||||||
|
await repo.setFlag(widget.emailId, flagged: next);
|
||||||
|
if (mounted) setState(() => _isFlagged = next);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.drive_file_move_outline),
|
||||||
|
tooltip: 'Move to folder',
|
||||||
|
onPressed:
|
||||||
|
header == null ? null : () => _moveTo(context, header),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.access_time),
|
||||||
|
tooltip: 'Snooze',
|
||||||
|
onPressed:
|
||||||
|
header == null ? null : () => _snooze(context, header),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
tooltip: 'Delete',
|
||||||
|
onPressed: () async {
|
||||||
|
final destPath = await repo.deleteEmail(widget.emailId);
|
||||||
|
|
||||||
|
if (header != null) {
|
||||||
|
unawaited(
|
||||||
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
|
UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: header.accountId,
|
||||||
|
type: UndoType.delete,
|
||||||
|
emailIds: [widget.emailId],
|
||||||
|
sourceMailboxPath: header.mailboxPath,
|
||||||
|
destinationMailboxPath: destPath,
|
||||||
|
originalEmails: [header],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.mounted) context.pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
itemBuilder: (ctx) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'headers',
|
||||||
|
child: Text('Show Mail Headers'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
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())
|
||||||
body: detail.when(
|
: snap.hasError
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
? Center(child: Text('Error: ${snap.error}'))
|
||||||
error: (e, _) => Center(child: Text('Error: $e')),
|
: _buildBody(ctx, header, body!),
|
||||||
data: (d) => _buildBody(context, d.$1, d.$2),
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,9 +185,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SecureEmailWebView(
|
Html(
|
||||||
htmlBody: body.htmlBody!,
|
data: body.htmlBody!,
|
||||||
loadRemoteImages: _loadRemoteImages,
|
extensions: [if (!_loadRemoteImages) _BlockRemoteImagesExtension()],
|
||||||
),
|
),
|
||||||
] else
|
] else
|
||||||
SelectableText(
|
SelectableText(
|
||||||
@@ -282,40 +267,30 @@ 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!),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> _quotedBody(Email header, EmailBody? body) async {
|
String _quotedBody(Email header, EmailBody? body) {
|
||||||
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 rawText = body?.textBody;
|
final text = body?.textBody ?? htmlToPlain(body?.htmlBody ?? '');
|
||||||
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _reply(
|
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',
|
||||||
@@ -323,29 +298,23 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
'replyToEmailId': widget.emailId,
|
'replyToEmailId': widget.emailId,
|
||||||
'prefillTo': to,
|
'prefillTo': to,
|
||||||
'prefillSubject': subject,
|
'prefillSubject': subject,
|
||||||
'prefillBody': quoted,
|
'prefillBody': _quotedBody(header, body),
|
||||||
if (cc.isNotEmpty) 'prefillCc': cc,
|
if (cc.isNotEmpty) 'prefillCc': cc,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _forward(
|
void _forward(BuildContext context, Email header, EmailBody? body) {
|
||||||
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': quoted,
|
'prefillBody': _quotedBody(header, body),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -425,7 +394,6 @@ 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)}',
|
||||||
),
|
),
|
||||||
@@ -435,121 +403,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showRaw(BuildContext context, Email? header) async {
|
|
||||||
final String raw;
|
|
||||||
try {
|
|
||||||
raw = await ref
|
|
||||||
.read(emailRepositoryProvider)
|
|
||||||
.fetchRawRfc822(widget.emailId);
|
|
||||||
} catch (e) {
|
|
||||||
if (!context.mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Failed to fetch raw email: $e')),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
unawaited(
|
|
||||||
showDialog<void>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('Raw Email'),
|
|
||||||
content: SizedBox(
|
|
||||||
width: double.maxFinite,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
fmtSize(raw.length),
|
|
||||||
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
|
|
||||||
color: Theme.of(ctx).colorScheme.outline,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Flexible(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: SelectableText(
|
|
||||||
raw,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () async {
|
|
||||||
await Clipboard.setData(ClipboardData(text: raw));
|
|
||||||
if (ctx.mounted) {
|
|
||||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Copied to clipboard')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('Copy'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () async {
|
|
||||||
await _downloadRaw(ctx, header, raw);
|
|
||||||
if (ctx.mounted) Navigator.pop(ctx);
|
|
||||||
},
|
|
||||||
child: const Text('Download'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('Close'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _downloadRaw(
|
|
||||||
BuildContext context,
|
|
||||||
Email? header,
|
|
||||||
String raw,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
final dir = await getTemporaryDirectory();
|
|
||||||
final subject = (header?.subject ?? 'email')
|
|
||||||
.replaceAll(RegExp(r'[^\w\s-]'), '_')
|
|
||||||
.trim();
|
|
||||||
final filename = '$subject.eml';
|
|
||||||
final file = File('${dir.path}/$filename');
|
|
||||||
await file.writeAsString(raw);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Saved $filename'),
|
|
||||||
action: SnackBarAction(
|
|
||||||
label: 'Share',
|
|
||||||
onPressed: () => SharePlus.instance.share(
|
|
||||||
ShareParams(files: [XFile(file.path)]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (!context.mounted) return;
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text('Download failed: $e')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showHeaders(BuildContext context, EmailBody body) {
|
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.'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -603,119 +460,20 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showStructure(BuildContext context, EmailBody body) {
|
|
||||||
final tree = body.mimeTree;
|
|
||||||
if (tree == null) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
duration: Duration(seconds: 5),
|
|
||||||
content: Text(
|
|
||||||
'Structure not available. Try re-syncing the email.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final rows = <_MimeRow>[];
|
|
||||||
_flattenMimeTree(tree, 0, rows);
|
|
||||||
|
|
||||||
unawaited(
|
|
||||||
showDialog<void>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('Mail Structure'),
|
|
||||||
content: SizedBox(
|
|
||||||
width: double.maxFinite,
|
|
||||||
child: ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: rows.length,
|
|
||||||
itemBuilder: (ctx, i) {
|
|
||||||
final row = rows[i];
|
|
||||||
return Container(
|
|
||||||
color: i.isEven
|
|
||||||
? Theme.of(ctx).colorScheme.surfaceContainerHighest
|
|
||||||
: Theme.of(ctx).colorScheme.surface,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 4,
|
|
||||||
horizontal: 8,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(width: row.depth * 16.0),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
row.label,
|
|
||||||
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('Close'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MimeRow {
|
class _BlockRemoteImagesExtension extends HtmlExtension {
|
||||||
const _MimeRow(this.depth, this.label);
|
@override
|
||||||
final int depth;
|
Set<String> get supportedTags => {'img'};
|
||||||
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
|
||||||
Widget build(BuildContext context) {
|
bool matches(ExtensionContext context) {
|
||||||
final uri = _parseUnsubscribeUri(header);
|
if (context.elementName != 'img') return false;
|
||||||
if (uri == null) return const SizedBox.shrink();
|
final src = context.attributes['src'] ?? '';
|
||||||
return ActionChip(
|
return src.startsWith('http://') || src.startsWith('https://');
|
||||||
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
|
|
||||||
label: const Text('Unsubscribe'),
|
|
||||||
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
InlineSpan build(ExtensionContext context) =>
|
||||||
|
const WidgetSpan(child: SizedBox.shrink());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,19 +10,10 @@ 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({
|
||||||
@@ -53,10 +44,6 @@ 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;
|
||||||
|
|
||||||
@@ -94,16 +81,6 @@ 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)) {
|
||||||
@@ -188,13 +165,7 @@ 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(),
|
||||||
@@ -209,7 +180,22 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildSyncButton(emailRepo),
|
IconButton(
|
||||||
|
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(
|
||||||
@@ -217,22 +203,6 @@ 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),
|
||||||
@@ -259,47 +229,6 @@ 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(
|
||||||
@@ -373,12 +302,6 @@ 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'),
|
||||||
@@ -396,11 +319,7 @@ 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(
|
stream: emailRepo.observeThreads(widget.accountId, widget.mailboxPath),
|
||||||
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());
|
||||||
@@ -430,12 +349,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
if (mailbox == null) {
|
if (mailbox == null) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(
|
).showSnackBar(SnackBar(content: Text(notFoundMessage)));
|
||||||
SnackBar(
|
|
||||||
duration: const Duration(seconds: 5),
|
|
||||||
content: Text(notFoundMessage),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
@@ -466,36 +380,8 @@ 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);
|
||||||
|
|
||||||
@@ -522,25 +408,6 @@ 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() =>
|
||||||
@@ -640,7 +507,6 @@ 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)}',
|
||||||
),
|
),
|
||||||
@@ -649,16 +515,9 @@ 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 + (hasMore ? 1 : 0),
|
itemCount: threads.length,
|
||||||
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 =
|
||||||
@@ -727,7 +586,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(
|
||||||
_fmtDate(t.latestDate),
|
_dateFmt.format(t.latestDate),
|
||||||
style: Theme.of(ctx).textTheme.bodySmall,
|
style: Theme.of(ctx).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -829,9 +688,10 @@ 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);
|
||||||
return EmailTile(
|
final sender = e.from.isNotEmpty
|
||||||
email: e,
|
? (e.from.first.name ?? e.from.first.email)
|
||||||
selected: isSelected,
|
: '(unknown)';
|
||||||
|
return ListTile(
|
||||||
leading: SizedBox(
|
leading: SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
child: _selecting
|
child: _selecting
|
||||||
@@ -839,11 +699,31 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
value: isSelected,
|
value: isSelected,
|
||||||
onChanged: (_) => _toggleSearchSelection(e.id),
|
onChanged: (_) => _toggleSearchSelection(e.id),
|
||||||
)
|
)
|
||||||
: null,
|
: Icon(
|
||||||
|
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)
|
||||||
: () => unawaited(_openSearchResultAndRefresh(e.id)),
|
: () => context.push(
|
||||||
|
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(e.id)}',
|
||||||
|
),
|
||||||
onLongPress: () => _toggleSearchSelection(e.id),
|
onLongPress: () => _toggleSearchSelection(e.id),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,17 +8,6 @@ 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});
|
||||||
@@ -30,24 +19,13 @@ 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();
|
||||||
}
|
}
|
||||||
@@ -66,12 +44,6 @@ 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);
|
||||||
@@ -84,7 +56,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
).wait;
|
).wait;
|
||||||
|
|
||||||
final matchedMailboxes = allMailboxes
|
final matchedMailboxes = allMailboxes
|
||||||
.where((m) => _hasWordPrefix(m.name, ql))
|
.where((m) => m.name.toLowerCase().contains(ql))
|
||||||
.toList()
|
.toList()
|
||||||
..sort(compareMailboxes);
|
..sort(compareMailboxes);
|
||||||
|
|
||||||
@@ -96,9 +68,8 @@ 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 = _hasWordPrefix(addr.email, ql);
|
final matchesEmail = addr.email.toLowerCase().contains(ql);
|
||||||
final matchesName =
|
final matchesName = addr.name?.toLowerCase().contains(ql) ?? false;
|
||||||
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;
|
||||||
@@ -140,7 +111,6 @@ 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…',
|
||||||
@@ -166,9 +136,6 @@ 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!;
|
||||||
@@ -188,79 +155,11 @@ 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(
|
_EmailTile(email: e, accountId: e.accountId),
|
||||||
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 {
|
||||||
@@ -347,3 +246,42 @@ 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,7 +11,6 @@ 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;
|
||||||
@@ -19,9 +18,6 @@ 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();
|
||||||
@@ -54,13 +50,9 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
Future<void> _loadContent() async {
|
Future<void> _loadContent() async {
|
||||||
setState(() => _loadingContent = true);
|
setState(() => _loadingContent = true);
|
||||||
try {
|
try {
|
||||||
final content = widget.isLocal
|
final content = await ref
|
||||||
? await ref
|
.read(sieveRepositoryProvider)
|
||||||
.read(localSieveRepositoryProvider)
|
.getScriptContent(widget.accountId, widget.script!.blobId);
|
||||||
.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);
|
||||||
@@ -86,21 +78,12 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
if (widget.isLocal) {
|
await ref.read(sieveRepositoryProvider).saveScript(
|
||||||
await ref.read(localSieveRepositoryProvider).saveScript(
|
widget.accountId,
|
||||||
widget.accountId,
|
id: widget.script?.id,
|
||||||
id: widget.script?.id,
|
name: name,
|
||||||
name: name,
|
content: _contentController.text,
|
||||||
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,17 +8,10 @@ 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({
|
const SieveScriptsScreen({super.key, required this.accountId});
|
||||||
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();
|
||||||
}
|
}
|
||||||
@@ -28,10 +21,6 @@ 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();
|
||||||
@@ -44,13 +33,8 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final scripts = widget.isLocal
|
final scripts =
|
||||||
? await ref
|
await ref.read(sieveRepositoryProvider).listScripts(widget.accountId);
|
||||||
.read(localSieveRepositoryProvider)
|
|
||||||
.listScripts(widget.accountId)
|
|
||||||
: await ref
|
|
||||||
.read(sieveRepositoryProvider)
|
|
||||||
.listScripts(widget.accountId);
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_scripts = scripts;
|
_scripts = scripts;
|
||||||
@@ -69,24 +53,15 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
|
|
||||||
Future<void> _activate(SieveScript script) async {
|
Future<void> _activate(SieveScript script) async {
|
||||||
try {
|
try {
|
||||||
if (widget.isLocal) {
|
await ref
|
||||||
await ref
|
.read(sieveRepositoryProvider)
|
||||||
.read(localSieveRepositoryProvider)
|
.activateScript(widget.accountId, script.id);
|
||||||
.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(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(
|
context,
|
||||||
duration: const Duration(seconds: 5),
|
).showSnackBar(SnackBar(content: Text('Failed to activate: $e')));
|
||||||
content: Text('Failed to activate: $e'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,24 +86,15 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
);
|
);
|
||||||
if (!(confirmed ?? false) || !mounted) return;
|
if (!(confirmed ?? false) || !mounted) return;
|
||||||
try {
|
try {
|
||||||
if (widget.isLocal) {
|
await ref
|
||||||
await ref
|
.read(sieveRepositoryProvider)
|
||||||
.read(localSieveRepositoryProvider)
|
.deleteScript(widget.accountId, script.id);
|
||||||
.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(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(
|
context,
|
||||||
duration: const Duration(seconds: 5),
|
).showSnackBar(SnackBar(content: Text('Failed to delete: $e')));
|
||||||
content: Text('Failed to delete: $e'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,15 +102,11 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: const Text('Email filters')),
|
||||||
title: Text(
|
|
||||||
widget.isLocal ? 'Local Filters' : 'Remote Filters',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: _buildBody(),
|
body: _buildBody(),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await context.push(_editRoute);
|
await context.push('/accounts/${widget.accountId}/sieve/edit');
|
||||||
await _load();
|
await _load();
|
||||||
},
|
},
|
||||||
child: const Icon(Icons.add),
|
child: const Icon(Icons.add),
|
||||||
@@ -172,69 +134,22 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final scripts = _scripts ?? [];
|
final scripts = _scripts ?? [];
|
||||||
return Column(
|
if (scripts.isEmpty) {
|
||||||
children: [
|
return const Center(
|
||||||
_SieveSourceBanner(isLocal: widget.isLocal),
|
child: Text('No Sieve scripts. Tap + to create one.'),
|
||||||
Expanded(
|
);
|
||||||
child: scripts.isEmpty
|
}
|
||||||
? const Center(
|
return RefreshIndicator(
|
||||||
child: Text('No filters yet. Tap + to create one.'),
|
onRefresh: _load,
|
||||||
)
|
child: ListView.builder(
|
||||||
: RefreshIndicator(
|
itemCount: scripts.length,
|
||||||
onRefresh: _load,
|
itemBuilder: (ctx, i) => _ScriptTile(
|
||||||
child: ListView.builder(
|
script: scripts[i],
|
||||||
itemCount: scripts.length,
|
accountId: widget.accountId,
|
||||||
itemBuilder: (ctx, i) => _ScriptTile(
|
onActivate: () => _activate(scripts[i]),
|
||||||
script: scripts[i],
|
onDelete: () => _delete(scripts[i]),
|
||||||
accountId: widget.accountId,
|
onEdited: _load,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -244,7 +159,6 @@ 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,
|
||||||
@@ -252,7 +166,6 @@ 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;
|
||||||
@@ -270,7 +183,10 @@ class _ScriptTile extends StatelessWidget {
|
|||||||
onSelected: (action) async {
|
onSelected: (action) async {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case _ScriptAction.edit:
|
case _ScriptAction.edit:
|
||||||
await context.push(editRoute, extra: script);
|
await context.push(
|
||||||
|
'/accounts/$accountId/sieve/edit',
|
||||||
|
extra: script,
|
||||||
|
);
|
||||||
onEdited();
|
onEdited();
|
||||||
case _ScriptAction.activate:
|
case _ScriptAction.activate:
|
||||||
onActivate();
|
onActivate();
|
||||||
@@ -293,7 +209,7 @@ class _ScriptTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await context.push(editRoute, extra: script);
|
await context.push('/accounts/$accountId/sieve/edit', extra: script);
|
||||||
onEdited();
|
onEdited();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,11 +9,6 @@ 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';
|
||||||
@@ -109,7 +104,9 @@ class _SyncLogTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final durationLabel = _fmtDuration(entry.duration);
|
final ms = entry.duration.inMilliseconds;
|
||||||
|
final durationLabel =
|
||||||
|
ms < 1000 ? '${ms}ms' : '${(ms / 1000).toStringAsFixed(1)}s';
|
||||||
final proto =
|
final proto =
|
||||||
entry.protocol.isEmpty ? '' : ' · ${entry.protocol.toUpperCase()}';
|
entry.protocol.isEmpty ? '' : ' · ${entry.protocol.toUpperCase()}';
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -157,10 +154,7 @@ 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,6 +1,7 @@
|
|||||||
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';
|
||||||
@@ -9,7 +10,6 @@ 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,9 +163,11 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
setState(() => _loadRemoteImages = true),
|
setState(() => _loadRemoteImages = true),
|
||||||
),
|
),
|
||||||
SecureEmailWebView(
|
Html(
|
||||||
htmlBody: body.htmlBody!,
|
data: body.htmlBody!,
|
||||||
loadRemoteImages: _loadRemoteImages,
|
extensions: [
|
||||||
|
if (!_loadRemoteImages) _BlockRemoteImagesExtension(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
] else
|
] else
|
||||||
SelectableText(
|
SelectableText(
|
||||||
@@ -246,7 +248,6 @@ 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
|
||||||
@@ -254,7 +255,6 @@ 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,3 +273,19 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _BlockRemoteImagesExtension extends HtmlExtension {
|
||||||
|
@override
|
||||||
|
Set<String> get supportedTags => {'img'};
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool matches(ExtensionContext context) {
|
||||||
|
if (context.elementName != 'img') return false;
|
||||||
|
final src = context.attributes['src'] ?? '';
|
||||||
|
return src.startsWith('http://') || src.startsWith('https://');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
InlineSpan build(ExtensionContext context) =>
|
||||||
|
const WidgetSpan(child: SizedBox.shrink());
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,12 +86,7 @@ class _UndoActionTile extends ConsumerWidget {
|
|||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(
|
).showSnackBar(const SnackBar(content: Text('Action undone.')));
|
||||||
const SnackBar(
|
|
||||||
duration: Duration(seconds: 5),
|
|
||||||
content: Text('Action undone.'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Undo'),
|
child: const Text('Undo'),
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
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,21 +70,13 @@ class FolderDrawer extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.dns),
|
leading: const Icon(Icons.filter_list),
|
||||||
title: const Text('Remote Filters'),
|
title: const Text('Email 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(
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
|
||||||
|
|
||||||
/// Builds the full HTML document string for rendering an email body.
|
|
||||||
///
|
|
||||||
/// Forces `color-scheme: light` so that emails with black text remain readable
|
|
||||||
/// when the device is in dark mode — the WebView would otherwise apply a dark
|
|
||||||
/// background while leaving the email's own text colours unchanged.
|
|
||||||
@visibleForTesting
|
|
||||||
String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
|
|
||||||
final imgSrc = loadRemoteImages ? 'https: http: data: blob:' : 'data: blob:';
|
|
||||||
// script-src 'none' blocks page scripts; JS mode stays unrestricted so the
|
|
||||||
// controller can call runJavaScriptReturningResult for height measurement.
|
|
||||||
const cspBase = "default-src 'none'; "
|
|
||||||
"style-src 'unsafe-inline'; "
|
|
||||||
"script-src 'none'; "
|
|
||||||
"object-src 'none'; "
|
|
||||||
"font-src 'none'";
|
|
||||||
final csp = '$cspBase; img-src $imgSrc';
|
|
||||||
|
|
||||||
return '''<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="color-scheme" content="light">
|
|
||||||
<meta http-equiv="Content-Security-Policy" content="$csp">
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; color-scheme: light; background-color: #ffffff; color: #000000; }
|
|
||||||
img { max-width: 100%; height: auto; }
|
|
||||||
a { color: #1976D2; }
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
$htmlBody
|
|
||||||
</body>
|
|
||||||
</html>''';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Renders an HTML email body securely.
|
|
||||||
///
|
|
||||||
/// On Android the content is displayed in a WebView with JavaScript blocked
|
|
||||||
/// via CSP and remote images blocked until the user opts in. Link taps show
|
|
||||||
/// a confirmation dialog that highlights the registered domain to aid phishing
|
|
||||||
/// detection.
|
|
||||||
///
|
|
||||||
/// On Linux (where webview_flutter has no platform support) the HTML is
|
|
||||||
/// converted to plain text and shown in a [SelectableText] widget.
|
|
||||||
class SecureEmailWebView extends StatefulWidget {
|
|
||||||
const SecureEmailWebView({
|
|
||||||
super.key,
|
|
||||||
required this.htmlBody,
|
|
||||||
this.loadRemoteImages = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String htmlBody;
|
|
||||||
final bool loadRemoteImages;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<SecureEmailWebView> createState() => _SecureEmailWebViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
|
||||||
// Null on Linux where WebView is unavailable.
|
|
||||||
WebViewController? _controller;
|
|
||||||
double _height = 300;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
if (!Platform.isLinux) {
|
|
||||||
final c = WebViewController();
|
|
||||||
unawaited(c.setJavaScriptMode(JavaScriptMode.unrestricted));
|
|
||||||
unawaited(c.setBackgroundColor(Colors.transparent));
|
|
||||||
unawaited(
|
|
||||||
c.setNavigationDelegate(
|
|
||||||
NavigationDelegate(
|
|
||||||
onNavigationRequest: _handleNavigation,
|
|
||||||
onPageFinished: _measureHeight,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
unawaited(c.loadHtmlString(_buildHtml()));
|
|
||||||
_controller = c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(SecureEmailWebView old) {
|
|
||||||
super.didUpdateWidget(old);
|
|
||||||
if (old.htmlBody != widget.htmlBody ||
|
|
||||||
old.loadRemoteImages != widget.loadRemoteImages) {
|
|
||||||
if (_controller != null) {
|
|
||||||
unawaited(_controller!.loadHtmlString(_buildHtml()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _buildHtml() => buildEmailHtml(
|
|
||||||
widget.htmlBody,
|
|
||||||
loadRemoteImages: widget.loadRemoteImages,
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> _measureHeight(String _) async {
|
|
||||||
final result = await _controller!.runJavaScriptReturningResult(
|
|
||||||
'document.documentElement.scrollHeight',
|
|
||||||
);
|
|
||||||
final h = double.tryParse(result.toString());
|
|
||||||
if (h != null && h > 0 && mounted) {
|
|
||||||
setState(() => _height = h);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationDecision _handleNavigation(NavigationRequest req) {
|
|
||||||
final url = req.url;
|
|
||||||
if (url == 'about:blank' || url.startsWith('data:')) {
|
|
||||||
return NavigationDecision.navigate;
|
|
||||||
}
|
|
||||||
unawaited(_showLinkDialog(url));
|
|
||||||
return NavigationDecision.prevent;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _showLinkDialog(String url) async {
|
|
||||||
final uri = Uri.tryParse(url);
|
|
||||||
if (uri == null || !mounted) return;
|
|
||||||
|
|
||||||
final host = uri.host;
|
|
||||||
final parts = host.split('.');
|
|
||||||
// Bold the registered domain (last two DNS labels) to aid phishing detection.
|
|
||||||
final boldStart = (parts.length >= 2
|
|
||||||
? host.length -
|
|
||||||
parts.last.length -
|
|
||||||
1 -
|
|
||||||
parts[parts.length - 2].length
|
|
||||||
: 0)
|
|
||||||
.clamp(0, host.length);
|
|
||||||
|
|
||||||
final confirmed = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('Open link?'),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text.rich(
|
|
||||||
TextSpan(
|
|
||||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
|
||||||
children: [
|
|
||||||
TextSpan(text: host.substring(0, boldStart)),
|
|
||||||
TextSpan(
|
|
||||||
text: host.substring(boldStart),
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
url,
|
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
|
||||||
maxLines: 3,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
|
||||||
child: const Text('Open in browser'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed == true && mounted) {
|
|
||||||
final launched =
|
|
||||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
||||||
if (!launched && mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Could not open: $url')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
// Linux has no webview_flutter platform implementation — show plain text.
|
|
||||||
if (Platform.isLinux) {
|
|
||||||
return SelectableText(
|
|
||||||
htmlToPlain(widget.htmlBody),
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return SizedBox(
|
|
||||||
height: _height,
|
|
||||||
child: WebViewWidget(controller: _controller!),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,12 +13,7 @@ class UndoShell extends ConsumerWidget {
|
|||||||
ref.listen<List<UndoAction>>(undoServiceProvider, (previous, next) {
|
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)) {
|
||||||
final action = next.last;
|
_showUndoSnackbar(context, ref, 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,7 +29,6 @@ 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
+7
-26
@@ -40,27 +40,10 @@ 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
|
||||||
webview_flutter: ^4.0.0
|
flutter_html: ^3.0.0
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
flutter_markdown_plus: ^1.0.7
|
flutter_markdown: ^0.7.7+1
|
||||||
|
|
||||||
# Background sync and local notifications
|
|
||||||
flutter_local_notifications: ^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:
|
||||||
@@ -73,8 +56,7 @@ 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
|
||||||
path_provider_platform_interface: ^2.1.2
|
sqlite3: any # used directly in test/unit/db_test_helper.dart
|
||||||
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
|
||||||
|
|
||||||
@@ -84,8 +66,7 @@ flutter:
|
|||||||
- assets/
|
- assets/
|
||||||
|
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
# path_provider_android 2.2.21 updated to Pigeon 26, which causes a
|
# path_provider_android 2.3+ uses package:jni which crashes on startup
|
||||||
# channel-error on startup on some Android devices. 2.3+ uses package:jni
|
# (SIGSEGV in libdartjni.so FindClassUnchecked — JNI env not ready when
|
||||||
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
|
# the Dart VM first calls into it). Pin to 2.2.x which uses Pigeon instead.
|
||||||
# stable Pigeon and is known to work reliably.
|
path_provider_android: ">=2.2.0 <2.3.0"
|
||||||
path_provider_android: ">=2.2.0 <2.2.21"
|
|
||||||
|
|||||||
@@ -1,598 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
agent_loop.py — called from cron every 10 minutes.
|
|
||||||
|
|
||||||
Flow
|
|
||||||
----
|
|
||||||
1. Agent already running?
|
|
||||||
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
|
|
||||||
b. Age ≤ 1 h → print status, exit 0 (let it keep working)
|
|
||||||
2. No agent running → extract pending_issue from state (if any), then check CI
|
|
||||||
a. 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,10 +15,8 @@ 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',
|
||||||
};
|
};
|
||||||
@@ -34,8 +32,6 @@ 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',
|
||||||
@@ -52,25 +48,18 @@ 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() {
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Verify that all *.mocks.dart files are up to date.
|
|
||||||
# Re-runs build_runner and fails if any generated mock differs from what is committed.
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(git rev-parse --show-toplevel)"
|
|
||||||
|
|
||||||
echo "check-mocks: regenerating..."
|
|
||||||
tmp=$(mktemp)
|
|
||||||
trap 'rm -f "$tmp"' EXIT
|
|
||||||
if fvm flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1; then
|
|
||||||
grep -vE '^\[' "$tmp" || true
|
|
||||||
else
|
|
||||||
cat "$tmp"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
CHANGED=$(git diff --name-only -- '*.mocks.dart')
|
|
||||||
if [ -n "$CHANGED" ]; then
|
|
||||||
echo "ERROR: The following mock files are out of date:"
|
|
||||||
echo "$CHANGED"
|
|
||||||
echo "Run 'task codegen' and commit the regenerated mocks."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "check-mocks: all mock files are up to date."
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Fail if binary files (other than images and fonts) are staged for commit.
|
|
||||||
# Prevents accidental inclusion of build artifacts, databases, compiled binaries.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ALLOWED_EXTENSIONS='(png|jpg|jpeg|gif|webp|svg|ico|ttf|otf|woff|woff2)'
|
|
||||||
|
|
||||||
# git diff --numstat shows "- - path" for binary files
|
|
||||||
BINARY=$(git diff --cached --numstat | awk '$1=="-" && $2=="-" {print $3}')
|
|
||||||
|
|
||||||
if [ -z "$BINARY" ]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
BLOCKED=''
|
|
||||||
while IFS= read -r f; do
|
|
||||||
if ! echo "$f" | grep -qiE "\.$ALLOWED_EXTENSIONS$"; then
|
|
||||||
BLOCKED="$BLOCKED\n $f"
|
|
||||||
fi
|
|
||||||
done <<< "$BINARY"
|
|
||||||
|
|
||||||
if [ -n "$BLOCKED" ]; then
|
|
||||||
echo "Binary files staged for commit (not allowed):"
|
|
||||||
echo -e "$BLOCKED"
|
|
||||||
echo ""
|
|
||||||
echo "If this is intentional, add the extension to ALLOWED_EXTENSIONS in scripts/check_no_binary.sh"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
+25
-86
@@ -4,78 +4,14 @@
|
|||||||
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():
|
||||||
@@ -88,31 +24,34 @@ 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)
|
||||||
|
|
||||||
session = _make_session(config_json)
|
creds = service_account.Credentials.from_service_account_info(
|
||||||
|
json.loads(config_json),
|
||||||
edit_resp = session.post(
|
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
||||||
f"{_BASE}/{PACKAGE_NAME}/edits",
|
|
||||||
json={},
|
|
||||||
timeout=30,
|
|
||||||
)
|
)
|
||||||
edit_resp.raise_for_status()
|
|
||||||
edit_id = edit_resp.json()["id"]
|
|
||||||
|
|
||||||
version_code = _upload_aab(session, edit_id)
|
service = build("androidpublisher", "v3", credentials=creds)
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
tracks_resp = session.put(
|
service.edits().tracks().update(
|
||||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
|
packageName=PACKAGE_NAME,
|
||||||
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
editId=edit_id,
|
||||||
timeout=30,
|
track=TRACK,
|
||||||
)
|
body={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
||||||
tracks_resp.raise_for_status()
|
).execute()
|
||||||
|
|
||||||
commit_resp = session.post(
|
service.edits().commit(packageName=PACKAGE_NAME, editId=edit_id).execute()
|
||||||
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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Generate Hugo markdown pages listing builds fetched from the server.
|
|
||||||
|
|
||||||
Reads build artifacts under public_html/builds/ on the deployment server via SSH,
|
|
||||||
parses the git hash from each filename, fetches the commit title from the
|
|
||||||
Codeberg API, then writes Hugo content pages to website/content/builds/.
|
|
||||||
|
|
||||||
Covers two platforms:
|
|
||||||
- Linux: sharedinbox-linux-amd64-<hash>.tar.gz
|
|
||||||
- Android: sharedinbox-mua-<hash>.apk
|
|
||||||
|
|
||||||
At most MAX_BUILDS_PER_PLATFORM of the most-recent builds are shown per platform.
|
|
||||||
|
|
||||||
These generated pages are not tracked in git (see .gitignore).
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
CODEBERG_REPO = "guettli/sharedinbox"
|
|
||||||
REMOTE_BUILDS_DIR = "public_html/builds"
|
|
||||||
CONTENT_DIR = Path("website/content/builds")
|
|
||||||
BASE_URL = "https://sharedinbox.de"
|
|
||||||
CODEBERG_BASE = "https://codeberg.org"
|
|
||||||
MAX_BUILDS_PER_PLATFORM = 30
|
|
||||||
|
|
||||||
|
|
||||||
def list_remote_files(ssh_user: str, ssh_host: str, pattern: str) -> list[str]:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
"ssh",
|
|
||||||
"-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()
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Runs the Firebase Test Lab Dagger pipeline with Gradle/Dagger noise filtered out.
|
|
||||||
# Retries up to 3 times on transient Dagger engine connectivity errors.
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
OUT=$(mktemp)
|
|
||||||
RC_FILE=$(mktemp)
|
|
||||||
trap 'rm -f "$OUT" "$RC_FILE"' EXIT
|
|
||||||
|
|
||||||
_strip_ansi() {
|
|
||||||
sed 's/\x1b\[[0-9;]*[mGKHFJ]//g'
|
|
||||||
}
|
|
||||||
|
|
||||||
_filter_noise() {
|
|
||||||
grep -vE \
|
|
||||||
'> Task :.+(UP-TO-DATE|NO-SOURCE|SKIPPED)'\
|
|
||||||
'|[0-9]+ files found for path '\''lib/'\
|
|
||||||
'|^Inputs:'\
|
|
||||||
'|^[[:space:]]+-[[:space:]]/'\
|
|
||||||
'|\[Incubating\]'\
|
|
||||||
'|Deprecated Gradle features'\
|
|
||||||
'|warning-mode all'\
|
|
||||||
'|please refer to https://docs\.gradle'\
|
|
||||||
'|[0-9]+ actionable tasks'\
|
|
||||||
'|^warning: \[options\]'\
|
|
||||||
'|^Note: Some input files'\
|
|
||||||
'|Starting a Gradle Daemon'\
|
|
||||||
'|Have questions, feedback, or issues'\
|
|
||||||
'|https://firebase\.google\.com/support'\
|
|
||||||
'|^\s*[┆│]\s*$' \
|
|
||||||
|| true
|
|
||||||
}
|
|
||||||
|
|
||||||
_run() {
|
|
||||||
: > "$OUT" ; : > "$RC_FILE"
|
|
||||||
{
|
|
||||||
dagger call --progress=plain -q -m ci --source=. test-android-firebase \
|
|
||||||
--service-account-key env:FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY \
|
|
||||||
--project-id "$FIREBASE_PROJECT_ID"
|
|
||||||
echo $? > "$RC_FILE"
|
|
||||||
} 2>&1 | tee "$OUT" | _strip_ansi | _filter_noise
|
|
||||||
}
|
|
||||||
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
_run && break
|
|
||||||
RC=$(cat "$RC_FILE" 2>/dev/null || echo 1)
|
|
||||||
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|No Dagger server responded" "$OUT"; then
|
|
||||||
echo "[firebase] dagger connectivity error on attempt $attempt/3, retrying..." >&2
|
|
||||||
else
|
|
||||||
exit "$RC"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
exit "$(cat "$RC_FILE" 2>/dev/null || echo 0)"
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Establishes a secure tunnel to a remote Dagger Engine via stunnel.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [ -z "${DAGGER_STUNNEL_URL:-}" ]; then
|
|
||||||
echo "Error: DAGGER_STUNNEL_URL must be set."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Parse host and port (e.g., example.com:8774 or just example.com)
|
|
||||||
host=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f1)
|
|
||||||
port=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f2)
|
|
||||||
if [ "$host" == "$port" ]; then
|
|
||||||
port="8774"
|
|
||||||
fi
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,466 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Tests for pure functions in generate_build_history.py."""
|
|
||||||
import re
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from generate_build_history import MAX_BUILDS_PER_PLATFORM, parse_builds, render_entries
|
|
||||||
|
|
||||||
LINUX_RE = re.compile(
|
|
||||||
r"public_html/builds/(\d{4})/(\d{2})/(\d{2})/(sharedinbox-linux-amd64-(.+)\.tar\.gz)$"
|
|
||||||
)
|
|
||||||
APK_RE = re.compile(
|
|
||||||
r"public_html/builds/(\d{4})/(\d{2})/(\d{2})/(sharedinbox-mua-(.+)\.apk)$"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _fake_commit_info(hash_val: str):
|
|
||||||
return (f"feat: {hash_val}", "2025-05-10T12:00:00Z")
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseBuilds(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
patcher = patch("generate_build_history.get_commit_info", side_effect=_fake_commit_info)
|
|
||||||
self.mock_commit = patcher.start()
|
|
||||||
self.addCleanup(patcher.stop)
|
|
||||||
|
|
||||||
def test_linux_path_parsed(self):
|
|
||||||
paths = ["public_html/builds/2025/05/10/sharedinbox-linux-amd64-abc1234.tar.gz"]
|
|
||||||
result = parse_builds(paths, LINUX_RE)
|
|
||||||
self.assertIn("2025/05/10", result)
|
|
||||||
entry = result["2025/05/10"][0]
|
|
||||||
self.assertEqual(entry[0], "abc1234")
|
|
||||||
self.assertIn("sharedinbox-linux-amd64-abc1234.tar.gz", entry[1])
|
|
||||||
|
|
||||||
def test_apk_path_parsed(self):
|
|
||||||
paths = ["public_html/builds/2025/05/11/sharedinbox-mua-def5678.apk"]
|
|
||||||
result = parse_builds(paths, APK_RE)
|
|
||||||
self.assertIn("2025/05/11", result)
|
|
||||||
entry = result["2025/05/11"][0]
|
|
||||||
self.assertEqual(entry[0], "def5678")
|
|
||||||
self.assertIn("sharedinbox-mua-def5678.apk", entry[1])
|
|
||||||
|
|
||||||
def test_unexpected_path_skipped(self):
|
|
||||||
paths = [
|
|
||||||
"public_html/builds/2025/05/10/sharedinbox-linux-amd64-abc1234.tar.gz",
|
|
||||||
"public_html/builds/bad-path/other.tar.gz",
|
|
||||||
]
|
|
||||||
result = parse_builds(paths, LINUX_RE)
|
|
||||||
self.assertEqual(len(result), 1)
|
|
||||||
|
|
||||||
def test_multiple_builds_same_day(self):
|
|
||||||
paths = [
|
|
||||||
"public_html/builds/2025/05/10/sharedinbox-linux-amd64-aaa0001.tar.gz",
|
|
||||||
"public_html/builds/2025/05/10/sharedinbox-linux-amd64-bbb0002.tar.gz",
|
|
||||||
]
|
|
||||||
result = parse_builds(paths, LINUX_RE)
|
|
||||||
self.assertEqual(len(result["2025/05/10"]), 2)
|
|
||||||
|
|
||||||
def test_limited_to_max_builds(self):
|
|
||||||
paths = [
|
|
||||||
f"public_html/builds/2025/05/{i:02d}/sharedinbox-linux-amd64-hash{i:03d}.tar.gz"
|
|
||||||
for i in range(1, MAX_BUILDS_PER_PLATFORM + 5)
|
|
||||||
]
|
|
||||||
result = parse_builds(paths, LINUX_RE)
|
|
||||||
total = sum(len(v) for v in result.values())
|
|
||||||
self.assertEqual(total, MAX_BUILDS_PER_PLATFORM)
|
|
||||||
|
|
||||||
def test_download_url_contains_date_and_filename(self):
|
|
||||||
paths = ["public_html/builds/2025/03/15/sharedinbox-linux-amd64-cafebabe.tar.gz"]
|
|
||||||
result = parse_builds(paths, LINUX_RE)
|
|
||||||
url = result["2025/03/15"][0][1]
|
|
||||||
self.assertIn("/2025/03/15/", url)
|
|
||||||
self.assertIn("sharedinbox-linux-amd64-cafebabe.tar.gz", url)
|
|
||||||
self.assertTrue(url.startswith("https://"))
|
|
||||||
|
|
||||||
|
|
||||||
class TestRenderEntries(unittest.TestCase):
|
|
||||||
def _make_entry(self, hash_val="abc1234", url="https://example.com/file.apk",
|
|
||||||
title="feat: something", dt="2025-05-10T12:00:00Z"):
|
|
||||||
return (hash_val, url, title, dt)
|
|
||||||
|
|
||||||
def test_output_contains_title_and_link(self):
|
|
||||||
entry = self._make_entry()
|
|
||||||
out = render_entries([entry], "Download APK")
|
|
||||||
self.assertIn("feat: something", out)
|
|
||||||
self.assertIn("Download APK", out)
|
|
||||||
self.assertIn("abc1234", out)
|
|
||||||
|
|
||||||
def test_commit_url_uses_hash(self):
|
|
||||||
entry = self._make_entry(hash_val="deadbeef")
|
|
||||||
out = render_entries([entry], "Download")
|
|
||||||
self.assertIn("deadbeef", out)
|
|
||||||
self.assertIn("codeberg.org", out)
|
|
||||||
|
|
||||||
def test_datetime_shown_when_present(self):
|
|
||||||
entry = self._make_entry(dt="2025-05-10T12:00:00Z")
|
|
||||||
out = render_entries([entry], "Download")
|
|
||||||
self.assertIn("2025-05-10T12:00:00Z", out)
|
|
||||||
|
|
||||||
def test_datetime_omitted_when_empty(self):
|
|
||||||
entry = self._make_entry(dt="")
|
|
||||||
out = render_entries([entry], "Download")
|
|
||||||
self.assertNotIn(" · ", out)
|
|
||||||
|
|
||||||
def test_multiple_entries_all_rendered(self):
|
|
||||||
entries = [self._make_entry(hash_val=f"hash{i}", title=f"commit {i}") for i in range(3)]
|
|
||||||
out = render_entries(entries, "Download")
|
|
||||||
for i in range(3):
|
|
||||||
self.assertIn(f"commit {i}", out)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user