Compare commits
181
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccad2e4a9f | ||
|
|
3bd404f0cf | ||
|
|
9ca7089c50 | ||
|
|
adef2e9f80 | ||
|
|
2788a43dda | ||
|
|
cef63dee60 | ||
|
|
71dac3cbb2 | ||
|
|
913e5493f5 | ||
|
|
2612f4dbcd | ||
|
|
cca0e5d461 | ||
|
|
8718339b4e | ||
|
|
ccefccf6a6 | ||
|
|
7a4defbab4 | ||
|
|
31c0479fc9 | ||
|
|
bde782f511 | ||
|
|
0cefc8f8e7 | ||
|
|
3db1bd8ac2 | ||
|
|
515b12dd0f | ||
|
|
2ceabcacf0 | ||
|
|
a56eca0851 | ||
|
|
85c9df604b | ||
|
|
68950e6888 | ||
|
|
59a9ed9109 | ||
|
|
3d2288ab9f | ||
|
|
4ef441ab1b | ||
|
|
f28630fd7e | ||
|
|
6177605f22 | ||
|
|
ccfdfdb92e | ||
|
|
b631bdae24 | ||
|
|
4a07a175b9 | ||
|
|
2137d25d6d | ||
|
|
d03ee8b555 | ||
|
|
a82927cae8 | ||
|
|
6b1627b4c9 | ||
|
|
ef3255cd2b | ||
|
|
1aa2926f30 | ||
|
|
771ac691d9 | ||
|
|
65ac023622 | ||
|
|
838eee66bd | ||
|
|
6b4c2939ab | ||
|
|
0195f6e75c | ||
|
|
cd8c930000 | ||
|
|
b0354c7423 | ||
|
|
582f6764eb | ||
|
|
674d402ff9 | ||
|
|
09e20dd85f | ||
|
|
c1d314a621 | ||
|
|
fa5938c7bd | ||
|
|
f92f3debd7 | ||
|
|
692fa14d4d | ||
|
|
5e029a1365 | ||
|
|
87244de7da | ||
|
|
6d1df2d213 | ||
|
|
29c2c7e96c | ||
|
|
6a097976d3 | ||
|
|
d847d40ab0 | ||
|
|
761378f583 | ||
|
|
63da36c18a | ||
|
|
d3bd8dba92 | ||
|
|
9605c5e3b7 | ||
|
|
1681fb9202 | ||
|
|
d7a9c2b4f8 | ||
|
|
2747c4e63d | ||
|
|
dbc9d4dac8 | ||
|
|
34351d65a2 | ||
|
|
b0a09939c9 | ||
|
|
8ea8d71f42 | ||
|
|
3520f161e3 | ||
|
|
ed247baaac | ||
|
|
69bd7f5962 | ||
|
|
e0ecac20aa | ||
|
|
f9e0fadb68 | ||
|
|
aebc1e508e | ||
|
|
375fd18f9f | ||
|
|
ba21b802eb | ||
|
|
7974c28102 | ||
|
|
6303cc5ac1 | ||
|
|
9744fe1379 | ||
|
|
39a65b97e9 | ||
|
|
e5c5dc9db8 | ||
|
|
6703ffd69b | ||
|
|
43eafbd4c2 | ||
|
|
ee1fccf340 | ||
|
|
5757176937 | ||
|
|
180035ec55 | ||
|
|
68dabc56d0 | ||
|
|
8ee411d1c8 | ||
|
|
ec3ebfa4a3 | ||
|
|
d206c5aa79 | ||
|
|
1e2d1b6063 | ||
|
|
9290d87a7f | ||
|
|
264ce7e349 | ||
|
|
b3f5ad4110 | ||
|
|
7e3308cb94 | ||
|
|
c6e7c035f2 | ||
|
|
71ec760365 | ||
|
|
2a9a5f339a | ||
|
|
ea5d119706 | ||
|
|
968db75c69 | ||
|
|
d905cd653f | ||
|
|
e21cde0a3c | ||
|
|
50a6678ec2 | ||
|
|
91083218d4 | ||
|
|
adc4eb6f6d | ||
|
|
05d00bdf09 | ||
|
|
c45775be92 | ||
|
|
47fc534a8d | ||
|
|
a5928c1aa6 | ||
|
|
7f3cd43d6e | ||
|
|
f0f210e5ab | ||
|
|
41550eb4b5 | ||
|
|
633fc5d9da | ||
|
|
14f64cd2a5 | ||
|
|
5ddfe68467 | ||
|
|
f42522e6d0 | ||
|
|
db78d590ca | ||
|
|
dbb29fb76a | ||
|
|
2d2d12cc24 | ||
|
|
3f0b3e5096 | ||
|
|
38fab3f5fc | ||
|
|
e2b08e07b7 | ||
|
|
c0dd13be5d | ||
|
|
4e32984ecc | ||
|
|
2f975829e5 | ||
|
|
73bbfd2694 | ||
|
|
49e6b335d9 | ||
|
|
96bd351512 | ||
|
|
e8234981c5 | ||
|
|
cf94c7c1fb | ||
|
|
92183a3eb2 | ||
|
|
4e8a5ff968 | ||
|
|
33f1c5a9d4 | ||
|
|
0552b7a48c | ||
|
|
2f0da5b475 | ||
|
|
a1f8bb5994 | ||
|
|
6714e330cc | ||
|
|
a8d6ec5861 | ||
|
|
491a220fbb | ||
|
|
e22c4aa88d | ||
|
|
4bc24072f0 | ||
|
|
720c54433a | ||
|
|
dd26086220 | ||
|
|
2747ff0dca | ||
|
|
f57a8c502d | ||
|
|
c4efb56a0c | ||
|
|
c97e3d505f | ||
|
|
2bb7ac11df | ||
|
|
8709e9f38d | ||
|
|
7997ff0980 | ||
|
|
2359c7d586 | ||
|
|
4ada3798b6 | ||
|
|
07ac73dcb2 | ||
|
|
bb475a2350 | ||
|
|
63f7463ced | ||
|
|
0175c9e5a5 | ||
|
|
9f9bf14bbe | ||
|
|
a7783d46cf | ||
|
|
3868c160d3 | ||
|
|
50fc012e81 | ||
|
|
94b20f50be | ||
|
|
885906b204 | ||
|
|
06df3ee200 | ||
|
|
e03c7708ba | ||
|
|
27bef3356e | ||
|
|
32ba916cbf | ||
|
|
86e12ffe72 | ||
|
|
f4a052bedc | ||
|
|
b2c11e0c63 | ||
|
|
09c90c244b | ||
|
|
357ed9af31 | ||
|
|
96b1660b59 | ||
|
|
e7ff9243c9 | ||
|
|
d51e67ddcc | ||
|
|
43068509d2 | ||
|
|
d9b8748631 | ||
|
|
50ae7df8a3 | ||
|
|
7dd5800064 | ||
|
|
77e581299d | ||
|
|
37eca207c6 | ||
|
|
5925cee4f2 | ||
|
|
a8603edfc3 |
+6
-2
@@ -4,14 +4,18 @@
|
|||||||
# In systemd service:
|
# In systemd service:
|
||||||
# ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner
|
# ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner
|
||||||
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
|
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
|
||||||
|
|
||||||
FROM ghcr.io/catthehacker/ubuntu:go-24.04
|
FROM ghcr.io/catthehacker/ubuntu:go-24.04
|
||||||
|
|
||||||
# Infrastructure tools required by CI workflows
|
# Infrastructure tools required by CI workflows
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
stunnel4 \
|
jq \
|
||||||
netcat-openbsd \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# SOPS
|
||||||
|
RUN curl -fsSL -o /usr/local/bin/sops https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 \
|
||||||
|
&& chmod +x /usr/local/bin/sops
|
||||||
|
|
||||||
# Dagger CLI — pinned to match the engine version on the runner host
|
# Dagger CLI — pinned to match the engine version on the runner host
|
||||||
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
||||||
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
||||||
|
|||||||
@@ -1,111 +1,15 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
on: [push, pull_request]
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'lib/**'
|
|
||||||
- 'test/**'
|
|
||||||
- 'integration_test/**'
|
|
||||||
- 'android/**'
|
|
||||||
- 'linux/**'
|
|
||||||
- 'assets/**'
|
|
||||||
- '!assets/changelog.txt'
|
|
||||||
- 'pubspec.yaml'
|
|
||||||
- 'pubspec.lock'
|
|
||||||
- 'analysis_options.yaml'
|
|
||||||
- 'scripts/**'
|
|
||||||
- 'stalwart-dev/**'
|
|
||||||
- 'ci/**'
|
|
||||||
- 'Taskfile.yml'
|
|
||||||
- 'drift_schemas/**'
|
|
||||||
- '.forgejo/workflows/ci.yml'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'lib/**'
|
|
||||||
- 'test/**'
|
|
||||||
- 'integration_test/**'
|
|
||||||
- 'android/**'
|
|
||||||
- 'linux/**'
|
|
||||||
- 'assets/**'
|
|
||||||
- '!assets/changelog.txt'
|
|
||||||
- 'pubspec.yaml'
|
|
||||||
- 'pubspec.lock'
|
|
||||||
- 'analysis_options.yaml'
|
|
||||||
- 'scripts/**'
|
|
||||||
- 'stalwart-dev/**'
|
|
||||||
- 'ci/**'
|
|
||||||
- 'Taskfile.yml'
|
|
||||||
- 'drift_schemas/**'
|
|
||||||
- '.forgejo/workflows/ci.yml'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
name: Full Project Check
|
name: Full Project Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
- name: Setup Dagger Remote Engine
|
||||||
fetch-depth: 50
|
|
||||||
|
|
||||||
- name: Check runner tools
|
|
||||||
run: |
|
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine (via stunnel)
|
|
||||||
env:
|
env:
|
||||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
|
||||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
|
||||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Locate Docker daemon for local Dagger engine
|
|
||||||
run: |
|
|
||||||
# Skip if remote Dagger engine is already configured (preferred path)
|
|
||||||
if [ -n "${_DAGGER_RUNNER_HOST:-}" ]; then
|
|
||||||
echo "Remote Dagger engine configured, no local Docker needed."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Try host Docker socket (DooD) if runner mounts it
|
|
||||||
if [ -S /var/run/docker.sock ]; then
|
|
||||||
if DOCKER_HOST=unix:///var/run/docker.sock docker info >/dev/null 2>&1; then
|
|
||||||
echo "Docker available via host socket."
|
|
||||||
echo "DOCKER_HOST=unix:///var/run/docker.sock" >> "$GITHUB_ENV"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "WARNING: No remote Dagger engine and no local Docker found." >&2
|
|
||||||
echo " - Remote engine: check DAGGER_STUNNEL_URL secret and that the host proxy is running." >&2
|
|
||||||
echo " - Local Docker: runner does not expose /var/run/docker.sock." >&2
|
|
||||||
echo "CI will likely fail at the Dagger step." >&2
|
|
||||||
|
|
||||||
- name: Prune Dagger cache before check
|
|
||||||
env:
|
|
||||||
DAGGER_NO_NAG: "1"
|
|
||||||
# prune(maxUsedSpace) also reclaims named cache volumes (gradle-cache, go-build-cache, etc.)
|
|
||||||
# when total cache exceeds the limit; without args only unreferenced entries are removed.
|
|
||||||
run: |
|
|
||||||
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
|
|
||||||
|
|
||||||
- name: Run Full Check Suite
|
- name: Run Full Check Suite
|
||||||
env:
|
|
||||||
DAGGER_NO_NAG: "1"
|
|
||||||
run: task check-dagger
|
run: task check-dagger
|
||||||
|
|
||||||
- name: Prune Dagger cache after check
|
|
||||||
if: always()
|
|
||||||
env:
|
|
||||||
DAGGER_NO_NAG: "1"
|
|
||||||
run: |
|
|
||||||
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
|
|
||||||
|
|
||||||
- name: Cleanup TLS credentials
|
|
||||||
if: always()
|
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
|
||||||
|
|||||||
+113
-138
@@ -17,11 +17,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Detect Android and Linux changes
|
- name: Detect Android and Linux changes
|
||||||
id: diff
|
id: diff
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
# On workflow_dispatch always build everything
|
# On workflow_dispatch always build everything
|
||||||
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
||||||
@@ -30,62 +32,106 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
|
HEAD_SHA=$(git rev-parse HEAD)
|
||||||
# when the parent is unavailable (initial commit, shallow clone).
|
|
||||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
# Find the most recent workflow run where deploy-playstore actually succeeded
|
||||||
|| git show --name-only --format= HEAD)
|
# (not merely skipped). Bug fix: previous code used commit_sha (always None in
|
||||||
|
# Forgejo's API) instead of head_sha, causing LAST_DEPLOYED_SHA to be empty on
|
||||||
|
# every run and the fallback diff to only cover HEAD~1..HEAD.
|
||||||
|
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
|
||||||
|
import json, os, sys, urllib.request
|
||||||
|
token = os.environ.get("FORGEJO_TOKEN", "")
|
||||||
|
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||||
|
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||||
|
base_api = f"{server}/api/v1/repos/{repo}/actions"
|
||||||
|
url = f"{base_api}/runs?workflow_id=deploy.yml&status=success&limit=10"
|
||||||
|
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
data = json.loads(r.read())
|
||||||
|
runs = [
|
||||||
|
r for r in data.get("workflow_runs", [])
|
||||||
|
if r.get("status") == "success"
|
||||||
|
]
|
||||||
|
# Walk runs newest-first; pick the first one where deploy-playstore
|
||||||
|
# actually ran (conclusion=success), not just skipped.
|
||||||
|
for run in runs:
|
||||||
|
run_id = run.get("id")
|
||||||
|
jobs_url = f"{base_api}/runs/{run_id}/jobs"
|
||||||
|
jobs_req = urllib.request.Request(jobs_url, headers={"Authorization": f"token {token}"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(jobs_req) as jr:
|
||||||
|
jobs_data = json.loads(jr.read())
|
||||||
|
for job in jobs_data.get("workflow_jobs", []):
|
||||||
|
if "Deploy to Play Store" in job.get("name", "") and (
|
||||||
|
job.get("conclusion") == "success" or
|
||||||
|
job.get("status") == "success"
|
||||||
|
):
|
||||||
|
print(run.get("head_sha") or "")
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception:
|
||||||
|
pass # skip this run if jobs API fails
|
||||||
|
print("")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
|
||||||
|
print("")
|
||||||
|
PYEOF
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ -z "$LAST_DEPLOYED_SHA" ]; then
|
||||||
|
echo "::warning::Could not determine last successfully deployed SHA — deploying all targets as a precaution"
|
||||||
|
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
|
||||||
|
echo "::notice::All deploys SKIPPED — HEAD $HEAD_SHA was already successfully deployed"
|
||||||
|
echo "android=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "skip_reason=commit $HEAD_SHA was already successfully deployed" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Diff from the last successfully deployed commit to catch all changes since
|
||||||
|
# that deploy, not just the most recent commit. Deploy all targets when the
|
||||||
|
# SHA is not in local history (shallow clone or very old deploy).
|
||||||
|
if git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
|
||||||
|
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
|
||||||
|
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|
||||||
|
|| git show --name-only --format= HEAD)
|
||||||
|
else
|
||||||
|
echo "::warning::Last deployed SHA $LAST_DEPLOYED_SHA not in local history — deploying all targets as a precaution"
|
||||||
|
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Changed files:"
|
echo "Changed files:"
|
||||||
echo "$CHANGED"
|
echo "$CHANGED"
|
||||||
|
|
||||||
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/)'
|
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
|
||||||
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
||||||
|
|
||||||
echo "$CHANGED" | grep -qE "$android_re" \
|
if echo "$CHANGED" | grep -qE "$android_re"; then
|
||||||
&& echo "android=true" >> "$GITHUB_OUTPUT" \
|
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||||
|| echo "android=false" >> "$GITHUB_OUTPUT"
|
echo "Android deploy: TRIGGERED (android-relevant files changed)"
|
||||||
|
echo "::notice::Android deploy TRIGGERED — android-relevant files changed since $LAST_DEPLOYED_SHA"
|
||||||
|
else
|
||||||
|
echo "android=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Android deploy: SKIPPED (no android-relevant files changed)"
|
||||||
|
echo "::notice::Android deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no android-relevant changes"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "$CHANGED" | grep -qE "$linux_re" \
|
if echo "$CHANGED" | grep -qE "$linux_re"; then
|
||||||
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||||
|| echo "linux=false" >> "$GITHUB_OUTPUT"
|
echo "Linux deploy: TRIGGERED (linux-relevant files changed)"
|
||||||
|
echo "::notice::Linux deploy TRIGGERED — linux-relevant files changed since $LAST_DEPLOYED_SHA"
|
||||||
test-android-firebase:
|
else
|
||||||
name: Android Instrumented Tests (Firebase Test Lab)
|
echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||||
runs-on: ubuntu-latest
|
echo "Linux deploy: SKIPPED (no linux-relevant files changed)"
|
||||||
timeout-minutes: 60
|
echo "::notice::Linux deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no linux-relevant changes"
|
||||||
needs: [check-changes]
|
fi
|
||||||
if: needs.check-changes.outputs.android == 'true'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Check runner tools
|
|
||||||
run: |
|
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine (via stunnel)
|
|
||||||
env:
|
|
||||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
|
||||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
|
||||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
|
||||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
|
||||||
run: scripts/setup_dagger_remote.sh
|
|
||||||
|
|
||||||
- name: Run Android Tests on Firebase Test Lab
|
|
||||||
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
|
|
||||||
env:
|
|
||||||
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
|
|
||||||
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
|
||||||
run: task test-android-firebase
|
|
||||||
|
|
||||||
- name: Cleanup TLS credentials
|
|
||||||
if: always()
|
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
|
||||||
|
|
||||||
deploy-playstore:
|
deploy-playstore:
|
||||||
name: Build & Deploy to Play Store
|
name: Build & Deploy to Play Store
|
||||||
@@ -97,34 +143,29 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 100
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine (via stunnel)
|
- name: Setup Dagger Remote Engine
|
||||||
env:
|
env:
|
||||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
|
||||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
|
||||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Publish Android to Play Store
|
- name: Publish Android to Play Store
|
||||||
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
|
|
||||||
env:
|
env:
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task publish-android
|
run: task publish-android
|
||||||
|
|
||||||
- name: Cleanup TLS credentials
|
- name: Verify Play Store deployment
|
||||||
if: always()
|
run: |
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
python3 -m venv /tmp/playstore-venv
|
||||||
|
/tmp/playstore-venv/bin/pip install google-auth requests --quiet
|
||||||
|
/tmp/playstore-venv/bin/python3 scripts/verify_playstore_deploy.py
|
||||||
|
|
||||||
|
|
||||||
deploy-apk:
|
deploy-apk:
|
||||||
name: Build & Deploy APK to Server
|
name: Build & Deploy APK to Server
|
||||||
@@ -136,36 +177,23 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 100
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine (via stunnel)
|
- name: Setup Dagger Remote Engine
|
||||||
env:
|
env:
|
||||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
|
||||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
|
||||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Build & Deploy APK to server
|
- name: Build & Deploy APK to server
|
||||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task deploy-apk
|
run: task deploy-apk
|
||||||
|
|
||||||
- name: Cleanup TLS credentials
|
|
||||||
if: always()
|
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
name: Build Linux Release
|
name: Build Linux Release
|
||||||
@@ -177,83 +205,30 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 100
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine (via stunnel)
|
- name: Setup Dagger Remote Engine
|
||||||
env:
|
env:
|
||||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
|
||||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
|
||||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Build & Deploy Linux to server
|
- name: Build & Deploy Linux to server
|
||||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task deploy-linux
|
run: task deploy-linux
|
||||||
|
|
||||||
- name: Cleanup TLS credentials
|
|
||||||
if: always()
|
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
|
||||||
|
|
||||||
publish-website:
|
|
||||||
name: Publish Website Build History
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [build-linux, deploy-playstore, deploy-apk]
|
|
||||||
if: |
|
|
||||||
always() &&
|
|
||||||
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
|
|
||||||
timeout-minutes: 60
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Check runner tools
|
|
||||||
run: |
|
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine (via stunnel)
|
|
||||||
env:
|
|
||||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
|
||||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
|
||||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
|
||||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
|
||||||
run: scripts/setup_dagger_remote.sh
|
|
||||||
|
|
||||||
- name: Generate build history and deploy website
|
|
||||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
|
||||||
env:
|
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
|
||||||
run: task publish-website
|
|
||||||
|
|
||||||
- name: Cleanup TLS credentials
|
|
||||||
if: always()
|
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
|
||||||
|
|
||||||
label-deploy-health:
|
label-deploy-health:
|
||||||
name: Update Deploy Health Label
|
name: Update Deploy Health Label
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux]
|
needs: [deploy-playstore, deploy-apk, build-linux]
|
||||||
if: |
|
if: |
|
||||||
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
|
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
|
||||||
needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'failure' ||
|
|
||||||
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
|
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
|
||||||
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
|
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
|
||||||
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
|
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
|
||||||
@@ -266,7 +241,7 @@ jobs:
|
|||||||
FORGEJO_TOKEN: ${{ github.token }}
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
FORGEJO_URL: ${{ github.server_url }}
|
FORGEJO_URL: ${{ github.server_url }}
|
||||||
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
|
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
|
||||||
ALL_SUCCEEDED: ${{ (needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'skipped') && (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
|
ALL_SUCCEEDED: ${{ (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
|
||||||
run: |
|
run: |
|
||||||
python3 - << 'PYEOF'
|
python3 - << 'PYEOF'
|
||||||
import os, json, urllib.request, urllib.error
|
import os, json, urllib.request, urllib.error
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
name: Firebase Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *' # once per day at 3 AM
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-changes:
|
||||||
|
name: Detect Firebase-Relevant Changes
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
outputs:
|
||||||
|
has_changes: ${{ steps.diff.outputs.has_changes }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Detect Firebase-relevant changes in last 24 hours
|
||||||
|
id: diff
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# On workflow_dispatch always run
|
||||||
|
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
||||||
|
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
SINCE=$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%S')
|
||||||
|
CHANGED=$(git log --since="$SINCE" --name-only --format= -- \
|
||||||
|
'android/' 'integration_test/' 'lib/' 'pubspec.yaml' 'pubspec.lock' 'drift_schemas/' \
|
||||||
|
| sort -u | grep -v '^$')
|
||||||
|
|
||||||
|
if [ -n "$CHANGED" ]; then
|
||||||
|
echo "Firebase-relevant files changed since $SINCE:"
|
||||||
|
echo "$CHANGED"
|
||||||
|
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "No Firebase-relevant changes in the last 24 hours — skipping tests"
|
||||||
|
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
test-android-firebase:
|
||||||
|
name: Android Instrumented Tests (Firebase Test Lab)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.has_changes == 'true'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Check runner tools
|
||||||
|
run: |
|
||||||
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine
|
||||||
|
env:
|
||||||
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
|
- name: Run Android Tests on Firebase Test Lab
|
||||||
|
env:
|
||||||
|
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: task test-android-firebase
|
||||||
|
|
||||||
|
- name: Create issue on test failure
|
||||||
|
if: failure()
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
FORGEJO_URL: ${{ github.server_url }}
|
||||||
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
python3 - << 'PYEOF'
|
||||||
|
import os, json, urllib.request, urllib.error
|
||||||
|
|
||||||
|
token = os.environ["FORGEJO_TOKEN"]
|
||||||
|
url_base = os.environ["FORGEJO_URL"].rstrip("/")
|
||||||
|
run_url = os.environ["RUN_URL"]
|
||||||
|
|
||||||
|
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||||
|
api = f"{url_base}/api/v1/repos/guettli/sharedinbox"
|
||||||
|
|
||||||
|
def api_get(path):
|
||||||
|
req = urllib.request.Request(f"{api}{path}", headers=headers)
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
def api_post(path, body):
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
req = urllib.request.Request(f"{api}{path}", data=data, headers=headers, method="POST")
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
repo_labels = api_get("/labels")
|
||||||
|
label_map = {l["name"]: l["id"] for l in repo_labels}
|
||||||
|
|
||||||
|
label_ids = [label_map["Ready"]] if "Ready" in label_map else []
|
||||||
|
|
||||||
|
title = "Firebase Tests failed — find root cause and fix"
|
||||||
|
body = (
|
||||||
|
"Firebase instrumented tests failed in the daily run.\n\n"
|
||||||
|
f"**Failed run:** {run_url}\n\n"
|
||||||
|
"## Steps to resolve\n\n"
|
||||||
|
"1. **Find the root cause**: Check the test run logs linked above and identify which test(s) failed and why.\n"
|
||||||
|
"2. **Fix if possible**: If the failure is caused by a code bug, create a fix. If it is a flaky or infrastructure issue, document the findings.\n"
|
||||||
|
"3. Close this issue once the root cause is resolved and the tests pass.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
issue = api_post("/issues", {
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"labels": label_ids,
|
||||||
|
})
|
||||||
|
print(f"Created issue #{issue['number']}: {issue['html_url']}")
|
||||||
|
PYEOF
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
name: Renovate
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 6 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
name: Renovate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check runner tools
|
||||||
|
run: |
|
||||||
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine
|
||||||
|
env:
|
||||||
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
|
- name: Run Renovate
|
||||||
|
env:
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: task renovate
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
name: Deploy Website
|
name: Update Website
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 * * * *' # every hour on the hour
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
@@ -11,22 +13,31 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
name: Build & Deploy Website
|
name: Build & Update Website
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Build & Deploy Website
|
- name: Check runner tools
|
||||||
|
run: |
|
||||||
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
run: scripts/setup_dagger_remote.sh
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
run: task website-deploy
|
- name: Build & Update Website
|
||||||
|
env:
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: task publish-website
|
||||||
|
|
||||||
- name: Verify Website
|
- name: Verify Website
|
||||||
env:
|
env:
|
||||||
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
SSH_HOST: ${{ env.WEBSITE_SSH_HOST }}
|
||||||
run: scripts/website-verify.sh
|
run: scripts/website-verify.sh
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ jobs:
|
|||||||
# Disabled until a self-hosted runner with label "windows-runner" is registered.
|
# Disabled until a self-hosted runner with label "windows-runner" is registered.
|
||||||
name: Build & Deploy Windows (Nightly)
|
name: Build & Deploy Windows (Nightly)
|
||||||
runs-on: windows-runner
|
runs-on: windows-runner
|
||||||
|
timeout-minutes: 90
|
||||||
if: false
|
if: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -1,249 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analyze-and-test:
|
|
||||||
name: Analyze & unit test
|
|
||||||
runs-on: sharedinbox-runner
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
flutter-version: "3.41.6"
|
|
||||||
channel: stable
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Generate Drift code
|
|
||||||
run: flutter pub run build_runner build --delete-conflicting-outputs
|
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
run: dart format --set-exit-if-changed .
|
|
||||||
|
|
||||||
- name: Analyze
|
|
||||||
run: flutter analyze --fatal-infos
|
|
||||||
|
|
||||||
- name: Unit + widget tests with coverage
|
|
||||||
run: flutter test test/unit/ test/widget/ --coverage
|
|
||||||
|
|
||||||
- name: Coverage gate
|
|
||||||
run: dart run scripts/check_coverage.dart
|
|
||||||
|
|
||||||
integration:
|
|
||||||
name: Integration tests (Stalwart)
|
|
||||||
runs-on: sharedinbox-runner
|
|
||||||
# Run integration tests only on push to main, not on every PR.
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: DeterminateSystems/nix-installer-action@v14
|
|
||||||
|
|
||||||
- uses: DeterminateSystems/magic-nix-cache-action@v8
|
|
||||||
|
|
||||||
- name: Cache FVM Flutter SDK
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.fvm
|
|
||||||
key: fvm-${{ hashFiles('.fvm/fvm_config.json') }}
|
|
||||||
|
|
||||||
- name: Cache pub packages
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.pub-cache
|
|
||||||
key: pub-${{ hashFiles('pubspec.lock') }}
|
|
||||||
restore-keys: pub-
|
|
||||||
|
|
||||||
- name: Run integration tests
|
|
||||||
run: |
|
|
||||||
nix develop --command bash -c "
|
|
||||||
fvm install --skip-pub-get &&
|
|
||||||
fvm flutter pub get &&
|
|
||||||
fvm flutter pub run build_runner build --delete-conflicting-outputs &&
|
|
||||||
stalwart-dev/test.sh
|
|
||||||
"
|
|
||||||
|
|
||||||
integration-ui:
|
|
||||||
name: UI Integration tests (Stalwart + Xvfb)
|
|
||||||
runs-on: sharedinbox-runner
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: DeterminateSystems/nix-installer-action@v14
|
|
||||||
|
|
||||||
- uses: DeterminateSystems/magic-nix-cache-action@v8
|
|
||||||
|
|
||||||
- name: Install Flutter Linux build 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
|
|
||||||
|
|
||||||
- name: Cache FVM Flutter SDK
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.fvm
|
|
||||||
key: fvm-${{ hashFiles('.fvm/fvm_config.json') }}
|
|
||||||
|
|
||||||
- name: Cache pub packages
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.pub-cache
|
|
||||||
key: pub-${{ hashFiles('pubspec.lock') }}
|
|
||||||
restore-keys: pub-
|
|
||||||
|
|
||||||
- name: Cache Linux debug build
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
build/linux
|
|
||||||
.dart_tool/flutter_build
|
|
||||||
key: linux-debug-${{ hashFiles('pubspec.lock', 'lib/**/*.dart', 'integration_test/**/*.dart') }}
|
|
||||||
restore-keys: linux-debug-
|
|
||||||
|
|
||||||
- name: Run UI integration tests
|
|
||||||
run: |
|
|
||||||
nix develop --command bash -c "
|
|
||||||
fvm install --skip-pub-get &&
|
|
||||||
fvm flutter pub get &&
|
|
||||||
fvm flutter pub run build_runner build --delete-conflicting-outputs &&
|
|
||||||
stalwart-dev/integration_ui_test.sh
|
|
||||||
"
|
|
||||||
|
|
||||||
build-linux:
|
|
||||||
name: Build Linux desktop
|
|
||||||
runs-on: sharedinbox-runner
|
|
||||||
needs: analyze-and-test
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install GTK3, build tools and libsecret
|
|
||||||
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
|
|
||||||
|
|
||||||
- uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
flutter-version: "3.41.6"
|
|
||||||
channel: stable
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Generate Drift code
|
|
||||||
run: flutter pub run build_runner build --delete-conflicting-outputs
|
|
||||||
|
|
||||||
- name: Build Linux release
|
|
||||||
run: flutter build linux --release
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
name: Deploy Linux build & publish website
|
|
||||||
runs-on: sharedinbox-runner
|
|
||||||
needs: build-linux
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
env:
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install build & deploy dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -q
|
|
||||||
sudo apt-get install -y --no-install-recommends \
|
|
||||||
libgtk-3-dev pkg-config cmake ninja-build clang \
|
|
||||||
libsecret-1-dev hugo rsync
|
|
||||||
|
|
||||||
- uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
flutter-version: "3.41.6"
|
|
||||||
channel: stable
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Cache pub packages
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.pub-cache
|
|
||||||
key: pub-${{ hashFiles('pubspec.lock') }}
|
|
||||||
restore-keys: pub-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Generate Drift code
|
|
||||||
run: flutter pub run build_runner build --delete-conflicting-outputs
|
|
||||||
|
|
||||||
- name: Generate changelog
|
|
||||||
run: |
|
|
||||||
mkdir -p assets
|
|
||||||
git log -n 50 \
|
|
||||||
--pretty=format:'* %ad [%h](https://codeberg.org/guettli/sharedinbox/commit/%H): %s' \
|
|
||||||
--date=short > assets/changelog.txt
|
|
||||||
|
|
||||||
- name: Setup SSH
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
|
|
||||||
- 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/"
|
|
||||||
+3
-1
@@ -1,5 +1,6 @@
|
|||||||
# --- Flutter/Dart ---
|
# --- Flutter/Dart ---
|
||||||
coverage/
|
coverage/
|
||||||
|
screenshots/
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
.dart-tool/
|
.dart-tool/
|
||||||
.packages
|
.packages
|
||||||
@@ -28,7 +29,8 @@ android/.gradle/
|
|||||||
android/local.properties
|
android/local.properties
|
||||||
android/app/google-services.json
|
android/app/google-services.json
|
||||||
android/key.properties
|
android/key.properties
|
||||||
android/app/src/main/java/io/flutter/plugins/
|
# android/app/src/main/java/io/flutter/plugins/ intentionally tracked so that
|
||||||
|
# GeneratedPluginRegistrant.java (catch Throwable) is committed and used by CI.
|
||||||
.android/
|
.android/
|
||||||
Android/
|
Android/
|
||||||
.gradle/
|
.gradle/
|
||||||
|
|||||||
+14
-3
@@ -10,6 +10,11 @@ repos:
|
|||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
|
|
||||||
|
- repo: https://github.com/guettli/sync-branch
|
||||||
|
rev: v0.0.11
|
||||||
|
hooks:
|
||||||
|
- id: sync-branch
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-no-binary
|
- id: check-no-binary
|
||||||
@@ -27,18 +32,24 @@ repos:
|
|||||||
- id: dart-check
|
- id: dart-check
|
||||||
name: dart format (autofix) + check-fast (parallel)
|
name: dart format (autofix) + check-fast (parallel)
|
||||||
language: system
|
language: system
|
||||||
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 dagger call --progress=plain -q -m ci --source=. check-fast'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
- id: ci-no-direct-dagger
|
- id: ci-no-direct-dagger
|
||||||
name: check for direct dagger calls in workflows (use Task instead)
|
name: check for direct dagger calls in workflows (use Task instead)
|
||||||
language: system
|
language: system
|
||||||
entry: "bash -c 'git grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'"
|
entry: "bash -c 'git --no-pager grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'"
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
- id: dagger-progress-plain
|
- id: dagger-progress-plain
|
||||||
name: ensure all dagger calls use --progress=plain
|
name: ensure all dagger calls use --progress=plain
|
||||||
language: system
|
language: system
|
||||||
entry: "bash -c 'git grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
|
entry: "bash -c 'git --no-pager grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
|
- id: ci-image-exists
|
||||||
|
name: verify container images in ci/main.go are reachable
|
||||||
|
language: system
|
||||||
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-ci-images'
|
||||||
|
pass_filenames: false
|
||||||
|
files: ^(ci/main\.go|\.fvmrc)$
|
||||||
|
|||||||
@@ -8,32 +8,41 @@ CLI tool `fgj` is available to query issues/PRs/actions.
|
|||||||
|
|
||||||
## Issue Label Workflow
|
## Issue Label Workflow
|
||||||
|
|
||||||
We use issues, follow this label state machine:
|
Automation is handled by [agentloop](https://github.com/guettli/agentloop) running every 5 minutes via cron. Add a label to trigger an agent:
|
||||||
|
|
||||||
- **State/Ready** — Issue is available to pick up
|
| Label | Trigger | Outcome |
|
||||||
- **State/InProgress** — Set this when you start working on an issue
|
|---|---|---|
|
||||||
- **State/Question** — Set this when you hit a blocker or need clarification
|
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
|
||||||
|
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` |
|
||||||
|
|
||||||
List open issues ready to pick up:
|
**State machine:**
|
||||||
|
|
||||||
```bash
|
```
|
||||||
fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/Ready")] | .[] | {number, title, html_url}'
|
loop/plan → loop/plan-in-progress → loop/plan-done
|
||||||
|
↘ NeedSupervisor (on failure)
|
||||||
|
|
||||||
|
loop/code → loop/code-in-progress → loop/code-done
|
||||||
|
↘ NeedSupervisor (on failure)
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
**Rules:**
|
||||||
|
|
||||||
- Never start work on an issue without `State/Ready`
|
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
|
||||||
- When working via the agent loop: `State/Ready` → `State/InProgress` is set automatically
|
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
|
||||||
by `agent_loop.py` before the agent starts — do **not** set it yourself.
|
- The coding agent opens a PR but does NOT close the issue. A human reviews the PR and closes the issue after merging.
|
||||||
- When working manually: switch to `State/InProgress` as your **first action**:
|
- Planning agents only post a comment — they do NOT write code or open PRs.
|
||||||
```bash
|
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
|
||||||
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
|
|
||||||
```
|
**Typical lifecycle for a new feature:**
|
||||||
- If blocked, replace current state label with `State/Question` and leave a comment explaining the blocker
|
|
||||||
- When done and CI is green, close the issue:
|
```
|
||||||
```bash
|
1. Create issue
|
||||||
fgj issue close <NUMBER>
|
2. Add label loop/plan → agent writes plan as comment
|
||||||
```
|
3. Review plan, request changes or approve
|
||||||
|
4. Add label loop/code → agent implements + opens PR
|
||||||
|
5. Review PR, merge
|
||||||
|
6. Close issue
|
||||||
|
```
|
||||||
|
|
||||||
## Code conventions
|
## Code conventions
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ WorkingDirectory=/home/dagger-svc
|
|||||||
# Replace 1003 with the actual UID of dagger-svc
|
# Replace 1003 with the actual UID of dagger-svc
|
||||||
Environment=DOCKER_HOST=unix:///run/user/1003/podman/podman.sock
|
Environment=DOCKER_HOST=unix:///run/user/1003/podman/podman.sock
|
||||||
Environment=XDG_RUNTIME_DIR=/run/user/1003
|
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
|
ExecStart=/usr/bin/nix run github:dagger/nix/v0.20.8#dagger -- engine --addr tcp://0.0.0.0:8080
|
||||||
Restart=always
|
Restart=always
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -188,3 +188,5 @@ Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks.
|
|||||||
## Daily Workflow
|
## Daily Workflow
|
||||||
|
|
||||||
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
|
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
|
||||||
|
|
||||||
|
<!-- agentloop code test passed -->
|
||||||
|
|||||||
@@ -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,46 +0,0 @@
|
|||||||
# Snooze Feature Plan
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Allow users to snooze emails, moving them to a special folder and bringing them back to the Inbox at a specified time. Snooze data must be stored in the account (IMAP/JMAP) for cross-device synchronization.
|
|
||||||
|
|
||||||
## Technical Approach
|
|
||||||
|
|
||||||
### 1. Metadata Storage (Account Sync)
|
|
||||||
- **Keyword format:** `snz:<ISO8601_TIMESTAMP>` (e.g., `snz:2026-05-10T15:00:00Z`).
|
|
||||||
- **JMAP:** Use `keywords`.
|
|
||||||
- **IMAP:** Use User Flags (keywords).
|
|
||||||
|
|
||||||
### 2. Database Changes
|
|
||||||
- **Migration v22:**
|
|
||||||
- `Emails` table:
|
|
||||||
- `snoozedUntil` (DateTime, nullable)
|
|
||||||
- `snoozedFromMailboxPath` (String, nullable) - to remember where to move it back (usually INBOX).
|
|
||||||
- Index on `snoozedUntil`.
|
|
||||||
|
|
||||||
### 3. Repository Updates (`EmailRepository`)
|
|
||||||
- New method: `Future<void> snoozeEmail(String emailId, DateTime until)`
|
|
||||||
- Optimistically update local DB.
|
|
||||||
- Enqueue `snooze` change.
|
|
||||||
- New method: `Future<int> wakeUpEmails(String accountId)`
|
|
||||||
- Find local rows where `snoozedUntil <= now`.
|
|
||||||
- Enqueue `move` back to original mailbox.
|
|
||||||
- Clear snooze metadata.
|
|
||||||
|
|
||||||
### 4. Sync Loop Integration
|
|
||||||
- In `AccountSyncManager`, call `wakeUpEmails(accountId)` at the start of each sync cycle.
|
|
||||||
- Update IMAP/JMAP sync logic to parse `snz:` keywords and update local `snoozedUntil` / `snoozedFromMailboxPath`.
|
|
||||||
|
|
||||||
### 5. UI Implementation
|
|
||||||
- **Snooze Picker:** A dialog with options like "Later today", "Tomorrow morning", "Next week", "Custom".
|
|
||||||
- **Action:** Add "Snooze" icon to `EmailListScreen` selection bar and `EmailDetailScreen`.
|
|
||||||
- **Mailbox:** Ensure a "Snoozed" mailbox exists (create if missing).
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
1. [ ] Database migration and model updates.
|
|
||||||
2. [ ] Repository implementation for `snoozeEmail` and `wakeUpEmails`.
|
|
||||||
3. [ ] Update flush logic for IMAP and JMAP to handle `snooze` mutations.
|
|
||||||
4. [ ] Update sync logic to parse snooze keywords.
|
|
||||||
5. [ ] Integrate `wakeUpEmails` into the sync loop.
|
|
||||||
6. [ ] UI: Snooze picker dialog.
|
|
||||||
7. [ ] UI: Add Snooze action to list and detail screens.
|
|
||||||
8. [ ] Testing and validation.
|
|
||||||
+114
-61
@@ -37,6 +37,8 @@ tasks:
|
|||||||
run: once
|
run: once
|
||||||
deps: [_nix-check]
|
deps: [_nix-check]
|
||||||
preconditions:
|
preconditions:
|
||||||
|
- sh: '[ "$(id -u)" != "0" ]'
|
||||||
|
msg: "Do not run as root. Use the dedicated dev user (see DEVELOPMENT.md)."
|
||||||
- sh: test -n "${IN_NIX_SHELL}"
|
- sh: test -n "${IN_NIX_SHELL}"
|
||||||
msg: "Not in nix dev shell. Run: nix develop"
|
msg: "Not in nix dev shell. Run: nix develop"
|
||||||
cmds:
|
cmds:
|
||||||
@@ -96,34 +98,19 @@ tasks:
|
|||||||
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
|
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
codegen:
|
codegen:
|
||||||
desc: Generate Drift DB code (run after any schema change)
|
desc: Generate Drift DB code via Dagger (exports generated files back to host)
|
||||||
deps: [_preflight, _pub-get]
|
|
||||||
sources:
|
|
||||||
- lib/**/*.dart
|
|
||||||
- pubspec.yaml
|
|
||||||
generates:
|
|
||||||
- lib/**/*.g.dart
|
|
||||||
cmds:
|
cmds:
|
||||||
- fvm flutter pub run build_runner build --delete-conflicting-outputs
|
- dagger call --progress=plain -q -m ci --source=. codegen -o .
|
||||||
|
|
||||||
analyze:
|
analyze:
|
||||||
desc: Static analysis (flutter analyze)
|
desc: Static analysis via Dagger (dart analyze --fatal-infos)
|
||||||
deps: [_preflight, _codegen]
|
|
||||||
sources:
|
|
||||||
- lib/**/*.dart
|
|
||||||
- test/**/*.dart
|
|
||||||
- pubspec.yaml
|
|
||||||
- analysis_options.yaml
|
|
||||||
cmds:
|
cmds:
|
||||||
- scripts/run_analyze.sh
|
- dagger call --progress=plain -q -m ci --source=. analyze
|
||||||
|
|
||||||
format:
|
format:
|
||||||
desc: Format all Dart source files
|
desc: Format all Dart source files via Dagger (writes back to host)
|
||||||
deps: [_preflight]
|
|
||||||
sources:
|
|
||||||
- "**/*.dart"
|
|
||||||
cmds:
|
cmds:
|
||||||
- fvm dart format lib test
|
- dagger call --progress=plain -q -m ci --source=. format-write -o .
|
||||||
|
|
||||||
check-mocks:
|
check-mocks:
|
||||||
desc: Fail if any *.mocks.dart file is out of date (re-runs build_runner)
|
desc: Fail if any *.mocks.dart file is out of date (re-runs build_runner)
|
||||||
@@ -136,13 +123,9 @@ tasks:
|
|||||||
- scripts/check_mocks_fresh.sh
|
- scripts/check_mocks_fresh.sh
|
||||||
|
|
||||||
analyze-fix:
|
analyze-fix:
|
||||||
desc: Auto-fix lint issues with dart fix --apply
|
desc: Auto-fix lint issues via Dagger (dart fix --apply, writes back to host)
|
||||||
deps: [_preflight]
|
|
||||||
sources:
|
|
||||||
- lib/**/*.dart
|
|
||||||
- test/**/*.dart
|
|
||||||
cmds:
|
cmds:
|
||||||
- fvm dart fix --apply
|
- dagger call --progress=plain -q -m ci --source=. analyze-fix -o .
|
||||||
|
|
||||||
test:
|
test:
|
||||||
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
|
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
|
||||||
@@ -177,17 +160,17 @@ tasks:
|
|||||||
test-backend:
|
test-backend:
|
||||||
desc: Backend tests against a local Stalwart mail server (via Dagger)
|
desc: Backend tests against a local Stalwart mail server (via Dagger)
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. test-backend
|
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-backend
|
||||||
|
|
||||||
integration-ui:
|
integration-ui:
|
||||||
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
|
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. test-integration
|
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-integration
|
||||||
|
|
||||||
sync-reliability:
|
sync-reliability:
|
||||||
desc: Run sync reliability runner (via Dagger)
|
desc: Run sync reliability runner (via Dagger)
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. test-sync-reliability
|
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-sync-reliability
|
||||||
|
|
||||||
test-android-firebase:
|
test-android-firebase:
|
||||||
desc: Build Android debug APKs and run instrumented tests on Firebase Test Lab (via Dagger)
|
desc: Build Android debug APKs and run instrumented tests on Firebase Test Lab (via Dagger)
|
||||||
@@ -202,7 +185,7 @@ tasks:
|
|||||||
ci-graph:
|
ci-graph:
|
||||||
desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer
|
desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. graph
|
- timeout --kill-after=10 60 dagger call --progress=plain -q -m ci --source=. graph
|
||||||
|
|
||||||
stalwart:
|
stalwart:
|
||||||
desc: Start a Stalwart instance for local development (via Dagger)
|
desc: Start a Stalwart instance for local development (via Dagger)
|
||||||
@@ -215,14 +198,16 @@ tasks:
|
|||||||
preconditions:
|
preconditions:
|
||||||
- sh: test -n "$SSH_PRIVATE_KEY"
|
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||||
msg: "SSH_PRIVATE_KEY is not set"
|
msg: "SSH_PRIVATE_KEY is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
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"
|
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||||
|
|
||||||
build-android-bundle:
|
build-android-bundle:
|
||||||
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
||||||
cmds:
|
cmds:
|
||||||
- mkdir -p build/app/outputs/bundle/release
|
- mkdir -p build/app/outputs/bundle/release
|
||||||
- dagger call --progress=plain -q -m ci --source=. build-android-release -o build/app/outputs/bundle/release/app-release.aab
|
- HASH=$(git rev-parse --short HEAD) && timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
|
||||||
|
|
||||||
upload-android-bundle:
|
upload-android-bundle:
|
||||||
desc: Upload AAB from build/ to Play Store via Dagger
|
desc: Upload AAB from build/ to Play Store via Dagger
|
||||||
@@ -232,10 +217,11 @@ tasks:
|
|||||||
- sh: test -f build/app/outputs/bundle/release/app-release.aab
|
- sh: test -f build/app/outputs/bundle/release/app-release.aab
|
||||||
msg: "AAB not found — run build-android-bundle first"
|
msg: "AAB not found — run build-android-bundle first"
|
||||||
cmds:
|
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
|
- timeout --kill-after=10 600 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:
|
publish-android:
|
||||||
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
||||||
|
deps: [generate-changelog]
|
||||||
preconditions:
|
preconditions:
|
||||||
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
||||||
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
||||||
@@ -244,24 +230,31 @@ tasks:
|
|||||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- 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
|
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
|
||||||
|
|
||||||
deploy-apk:
|
deploy-apk:
|
||||||
desc: Build and deploy Android APK via Dagger
|
desc: Build and deploy Android APK via Dagger
|
||||||
preconditions:
|
preconditions:
|
||||||
- sh: test -n "$SSH_PRIVATE_KEY"
|
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||||
msg: "SSH_PRIVATE_KEY is not set"
|
msg: "SSH_PRIVATE_KEY is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
- sh: test -n "$ANDROID_KEYSTORE_BASE64"
|
- sh: test -n "$ANDROID_KEYSTORE_BASE64"
|
||||||
msg: "ANDROID_KEYSTORE_BASE64 is not set"
|
msg: "ANDROID_KEYSTORE_BASE64 is not set"
|
||||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. 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)"
|
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
|
||||||
|
|
||||||
publish-website:
|
publish-website:
|
||||||
desc: Build and publish website via Dagger
|
desc: Build and publish website via Dagger
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||||
|
msg: "SSH_PRIVATE_KEY is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
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"
|
- HASH=$(git rev-parse --short HEAD) && timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||||
|
|
||||||
check-dagger:
|
check-dagger:
|
||||||
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
||||||
@@ -284,11 +277,11 @@ tasks:
|
|||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
run_dagger "$@" && return 0
|
run_dagger "$@" && return 0
|
||||||
RC=$?
|
RC=$?
|
||||||
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|invalid return status code" "$DAGGER_OUT"; then
|
if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|context deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; then
|
||||||
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
||||||
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
|
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
|
||||||
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
|
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
|
||||||
dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
|
timeout 120 dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
|
||||||
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
|
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
|
||||||
sleep 90
|
sleep 90
|
||||||
else
|
else
|
||||||
@@ -309,7 +302,16 @@ tasks:
|
|||||||
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
until [ -s "$PORTFILE" ]; do sleep 0.05; done
|
until [ -s "$PORTFILE" ]; do
|
||||||
|
sleep 0.05
|
||||||
|
if ! kill -0 "$RECV_PID" 2>/dev/null; then
|
||||||
|
echo "$(_ts) otel-receiver.py died before writing port file; falling back to plain run" >&2
|
||||||
|
retry_dagger dagger call --progress=plain -q -m ci --source=. check
|
||||||
|
RC=$?
|
||||||
|
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
||||||
|
exit $RC
|
||||||
|
fi
|
||||||
|
done
|
||||||
PORT=$(cat "$PORTFILE")
|
PORT=$(cat "$PORTFILE")
|
||||||
retry_dagger env \
|
retry_dagger env \
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
|
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
|
||||||
@@ -326,6 +328,14 @@ tasks:
|
|||||||
- |
|
- |
|
||||||
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
|
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
|
||||||
|
|
||||||
|
renovate:
|
||||||
|
desc: Run Renovate bot against the repository via Dagger
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
|
||||||
|
msg: "RENOVATE_FORGEJO_TOKEN is not set"
|
||||||
|
cmds:
|
||||||
|
- timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
|
||||||
|
|
||||||
integration-android:
|
integration-android:
|
||||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
||||||
deps: [_preflight, _android-sdk-check, _android-avd-setup]
|
deps: [_preflight, _android-sdk-check, _android-avd-setup]
|
||||||
@@ -373,28 +383,48 @@ tasks:
|
|||||||
msg: "SSH_USER is not set"
|
msg: "SSH_USER is not set"
|
||||||
- sh: test -n "$SSH_HOST"
|
- sh: test -n "$SSH_HOST"
|
||||||
msg: "SSH_HOST is not set"
|
msg: "SSH_HOST is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- |
|
- |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||||
HASH=$(git rev-parse --short HEAD)
|
HASH=$(git rev-parse --short HEAD)
|
||||||
DATE_PATH=$(date -u +%Y/%m/%d)
|
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||||
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
|
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
|
||||||
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
|
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
|
||||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||||
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
||||||
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
|
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
|
||||||
# Merge with any existing latest.json so we don't overwrite the windows key
|
# 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 '{}'")
|
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||||
WINDOWS_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" 2>/dev/null || true)
|
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
|
if [ -n "$WINDOWS_URL" ]; then
|
||||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
|
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
|
||||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
else
|
else
|
||||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
||||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
fi
|
fi
|
||||||
echo "Uploaded $TARBALL and updated latest.json"
|
echo "Uploaded $TARBALL and updated latest.json"
|
||||||
|
|
||||||
|
deploy-bugreport:
|
||||||
|
desc: Deploy the Go bugreport server by restarting the systemd service (it pulls latest code from Codeberg)
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_USER"
|
||||||
|
msg: "SSH_USER is not set"
|
||||||
|
- sh: test -n "$SSH_HOST"
|
||||||
|
msg: "SSH_HOST is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||||
|
ssh "root@$SSH_HOST" "systemctl restart bugreport"
|
||||||
|
echo "Restarted bugreport service on $SSH_HOST to pull latest code from Codeberg"
|
||||||
|
|
||||||
build-windows-release:
|
build-windows-release:
|
||||||
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
|
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
|
||||||
deps: [_pub-get, generate-changelog]
|
deps: [_pub-get, generate-changelog]
|
||||||
@@ -416,24 +446,28 @@ tasks:
|
|||||||
msg: "SSH_USER is not set"
|
msg: "SSH_USER is not set"
|
||||||
- sh: test -n "$SSH_HOST"
|
- sh: test -n "$SSH_HOST"
|
||||||
msg: "SSH_HOST is not set"
|
msg: "SSH_HOST is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- |
|
- |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||||
HASH=$(git rev-parse --short HEAD)
|
HASH=$(git rev-parse --short HEAD)
|
||||||
DATE_PATH=$(date -u +%Y/%m/%d)
|
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||||
ZIPFILE="sharedinbox-windows-x64-$HASH.zip"
|
ZIPFILE="sharedinbox-windows-x64-$HASH.zip"
|
||||||
cd build/windows/x64/runner && zip -r /tmp/$ZIPFILE Release/ && cd -
|
cd build/windows/x64/runner && zip -r /tmp/$ZIPFILE Release/ && cd -
|
||||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||||
scp -o StrictHostKeyChecking=no /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
|
scp /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
|
||||||
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$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 '{}'")
|
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||||
LINUX_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('linux',''))" 2>/dev/null || true)
|
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
|
if [ -n "$LINUX_URL" ]; then
|
||||||
echo "{\"version\":\"$HASH\",\"linux\":\"$LINUX_URL\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
echo "{\"version\":\"$HASH\",\"linux\":\"$LINUX_URL\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
||||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
else
|
else
|
||||||
echo "{\"version\":\"$HASH\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
echo "{\"version\":\"$HASH\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
||||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
fi
|
fi
|
||||||
echo "Uploaded $ZIPFILE and updated latest.json"
|
echo "Uploaded $ZIPFILE and updated latest.json"
|
||||||
|
|
||||||
@@ -490,15 +524,14 @@ tasks:
|
|||||||
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 (local/fvm)
|
||||||
deps: [build-android-bundle-local]
|
deps: [build-android-bundle-local]
|
||||||
preconditions:
|
dotenv: [".env"]
|
||||||
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
|
||||||
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
|
||||||
cmds:
|
cmds:
|
||||||
- python3 scripts/deploy_playstore.py
|
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
|
||||||
|
|
||||||
build-android-bundle-local:
|
build-android-bundle-local:
|
||||||
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
||||||
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
||||||
|
dotenv: [".env"]
|
||||||
method: timestamp
|
method: timestamp
|
||||||
sources:
|
sources:
|
||||||
- lib/**/*.dart
|
- lib/**/*.dart
|
||||||
@@ -507,7 +540,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"
|
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
|
||||||
|
|
||||||
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
|
||||||
@@ -534,7 +567,7 @@ tasks:
|
|||||||
|
|
||||||
run:
|
run:
|
||||||
desc: Run the app on Linux desktop
|
desc: Run the app on Linux desktop
|
||||||
deps: [_preflight, _linux-deps-check, _pub-get]
|
deps: [_preflight, _linux-deps-check, _pub-get, _codegen]
|
||||||
cmds:
|
cmds:
|
||||||
- fvm flutter run -d linux --no-pub
|
- fvm flutter run -d linux --no-pub
|
||||||
|
|
||||||
@@ -583,14 +616,18 @@ tasks:
|
|||||||
msg: "SSH_USER is not set"
|
msg: "SSH_USER is not set"
|
||||||
- sh: test -n "$SSH_HOST"
|
- sh: test -n "$SSH_HOST"
|
||||||
msg: "SSH_HOST is not set"
|
msg: "SSH_HOST is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- |
|
- |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||||
HASH=$(git rev-parse --short HEAD)
|
HASH=$(git rev-parse --short HEAD)
|
||||||
DATE_PATH=$(date -u +%Y/%m/%d)
|
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||||
APK_NAME="sharedinbox-mua-$HASH.apk"
|
APK_NAME="sharedinbox-mua-$HASH.apk"
|
||||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||||
scp -o StrictHostKeyChecking=no \
|
scp \
|
||||||
build/app/outputs/flutter-apk/app-release.apk \
|
build/app/outputs/flutter-apk/app-release.apk \
|
||||||
"$SSH_USER@$SSH_HOST:$REMOTE_DIR/$APK_NAME"
|
"$SSH_USER@$SSH_HOST:$REMOTE_DIR/$APK_NAME"
|
||||||
echo "Uploaded $APK_NAME to $REMOTE_DIR"
|
echo "Uploaded $APK_NAME to $REMOTE_DIR"
|
||||||
@@ -619,18 +656,23 @@ tasks:
|
|||||||
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]
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- |
|
- |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||||
rsync -avz --delete \
|
rsync -avz --delete \
|
||||||
--exclude='*.apk' \
|
--exclude='*.apk' \
|
||||||
--exclude='*.tar.gz' \
|
--exclude='*.tar.gz' \
|
||||||
-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 via Dagger (format, analyze, mocks, coverage — no integration or backend)
|
||||||
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks]
|
cmds:
|
||||||
|
- dagger call --progress=plain -q -m ci --source=. check-fast
|
||||||
|
|
||||||
check-layers:
|
check-layers:
|
||||||
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
|
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
|
||||||
@@ -657,6 +699,11 @@ tasks:
|
|||||||
fi
|
fi
|
||||||
echo "Hygiene check passed."
|
echo "Hygiene check passed."
|
||||||
|
|
||||||
|
check-ci-images:
|
||||||
|
desc: Verify that all container images referenced in ci/main.go are reachable
|
||||||
|
cmds:
|
||||||
|
- scripts/check_ci_images.sh
|
||||||
|
|
||||||
_integrations:
|
_integrations:
|
||||||
internal: true
|
internal: true
|
||||||
run: once
|
run: once
|
||||||
@@ -669,6 +716,12 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- scripts/ci_logs.sh "{{.RUN}}" "{{.JOB}}"
|
- scripts/ci_logs.sh "{{.RUN}}" "{{.JOB}}"
|
||||||
|
|
||||||
|
screenshots:
|
||||||
|
desc: Generate Play Store promotional screenshots (30 golden files — 3 devices × 2 themes × 5 scenes)
|
||||||
|
deps: [_preflight, _codegen]
|
||||||
|
cmds:
|
||||||
|
- fvm flutter test test/screenshot_automation_test.dart --update-goldens
|
||||||
|
|
||||||
check:
|
check:
|
||||||
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
|
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
|
||||||
deps: [analyze, build-linux, test]
|
deps: [analyze, build-linux, test]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ gradle-wrapper.jar
|
|||||||
/gradlew
|
/gradlew
|
||||||
/gradlew.bat
|
/gradlew.bat
|
||||||
/local.properties
|
/local.properties
|
||||||
GeneratedPluginRegistrant.java
|
|
||||||
.cxx/
|
.cxx/
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
# Remember to never publicly share your keystore.
|
||||||
|
|||||||
@@ -16,19 +16,23 @@ android {
|
|||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlin {
|
||||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
val ksPath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
|
||||||
create("release") {
|
|
||||||
// Hardcoded alias matching t.sh
|
if (ksPath != null) {
|
||||||
keyAlias = "upload"
|
signingConfigs {
|
||||||
// Use the same password for both key and keystore
|
create("release") {
|
||||||
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
keyAlias = "upload"
|
||||||
storePassword = pass
|
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD") ?: ""
|
||||||
keyPassword = pass
|
storePassword = pass
|
||||||
storeFile = file("upload-keystore.jks")
|
keyPassword = pass
|
||||||
|
storeFile = file(ksPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,14 +48,9 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// Use the signing config defined above for release builds.
|
if (ksPath != null) {
|
||||||
// If the keystore file exists (e.g. in CI or manually placed), sign it.
|
signingConfig = signingConfigs.getByName("release")
|
||||||
signingConfig = if (signingConfigs.getByName("release").storeFile?.exists() == true) {
|
|
||||||
signingConfigs.getByName("release")
|
|
||||||
} else {
|
|
||||||
signingConfigs.getByName("debug")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
isShrinkResources = false
|
isShrinkResources = false
|
||||||
ndk {
|
ndk {
|
||||||
@@ -67,7 +66,7 @@ flutter {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26.
|
// Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26.
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||||
// 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
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package io.flutter.plugins;
|
||||||
|
|
||||||
|
import androidx.annotation.Keep;
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import io.flutter.Log;
|
||||||
|
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generated file. Do not edit.
|
||||||
|
* This file is generated by the Flutter tool based on the
|
||||||
|
* plugins that support the Android platform.
|
||||||
|
*/
|
||||||
|
@Keep
|
||||||
|
public final class GeneratedPluginRegistrant {
|
||||||
|
private static final String TAG = "GeneratedPluginRegistrant";
|
||||||
|
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin device_info_plus, dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.steenbakker.mobile_scanner.MobileScannerPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin mobile_scanner, dev.steenbakker.mobile_scanner.MobileScannerPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new com.crazecoder.openfile.OpenFilePlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin open_filex, com.crazecoder.openfile.OpenFilePlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.share.SharePlusPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin share_plus, dev.fluttercommunity.plus.share.SharePlusPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-all.zip
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "8.13.2" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -2,52 +2,4 @@ module dagger/ci
|
|||||||
|
|
||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require golang.org/x/sync v0.20.0
|
||||||
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 +1,2 @@
|
|||||||
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 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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=
|
|
||||||
|
|||||||
+237
-53
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"dagger/ci/internal/dagger"
|
"dagger/ci/internal/dagger"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -148,16 +149,33 @@ if __name__ == "__main__":
|
|||||||
`
|
`
|
||||||
|
|
||||||
type Ci struct {
|
type Ci struct {
|
||||||
Source *dagger.Directory
|
Source *dagger.Directory
|
||||||
|
FlutterVersion string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
|
ctx context.Context,
|
||||||
// +defaultPath=".."
|
// +defaultPath=".."
|
||||||
source *dagger.Directory,
|
source *dagger.Directory,
|
||||||
) *Ci {
|
) (*Ci, error) {
|
||||||
|
fvmrcContents, err := source.File(".fvmrc").Contents(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read .fvmrc: %w", err)
|
||||||
|
}
|
||||||
|
var fvmrc struct {
|
||||||
|
Flutter string `json:"flutter"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(fvmrcContents), &fvmrc); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse .fvmrc: %w", err)
|
||||||
|
}
|
||||||
|
if fvmrc.Flutter == "" {
|
||||||
|
return nil, fmt.Errorf(".fvmrc is missing the 'flutter' field")
|
||||||
|
}
|
||||||
return &Ci{
|
return &Ci{
|
||||||
|
FlutterVersion: fvmrc.Flutter,
|
||||||
Source: source.Filter(dagger.DirectoryFilterOpts{
|
Source: source.Filter(dagger.DirectoryFilterOpts{
|
||||||
Include: []string{
|
Include: []string{
|
||||||
|
".fvmrc",
|
||||||
"lib/",
|
"lib/",
|
||||||
"test/",
|
"test/",
|
||||||
"assets/",
|
"assets/",
|
||||||
@@ -173,7 +191,7 @@ func New(
|
|||||||
"website/",
|
"website/",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolchain returns the Flutter+Android toolchain without any mutable cache mounts.
|
// toolchain returns the Flutter+Android toolchain without any mutable cache mounts.
|
||||||
@@ -181,7 +199,7 @@ func New(
|
|||||||
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
|
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
|
||||||
func (m *Ci) toolchain() *dagger.Container {
|
func (m *Ci) toolchain() *dagger.Container {
|
||||||
return dag.Container().
|
return dag.Container().
|
||||||
From("ghcr.io/cirruslabs/flutter:3.41.6").
|
From("ghcr.io/cirruslabs/flutter:"+m.FlutterVersion).
|
||||||
WithExec([]string{"apt-get", "-qq", "update"}).
|
WithExec([]string{"apt-get", "-qq", "update"}).
|
||||||
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
|
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
|
||||||
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
||||||
@@ -195,7 +213,8 @@ func (m *Ci) toolchain() *dagger.Container {
|
|||||||
WithUser("ci").
|
WithUser("ci").
|
||||||
WithExec([]string{"/bin/sh", "-c",
|
WithExec([]string{"/bin/sh", "-c",
|
||||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`})
|
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`}).
|
||||||
|
WithExec([]string{"flutter", "precache", "--linux", "--no-android", "--no-ios"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base is the Flutter toolchain container with mutable cache mounts attached.
|
// Base is the Flutter toolchain container with mutable cache mounts attached.
|
||||||
@@ -285,6 +304,21 @@ func (m *Ci) firebaseSrc() *dagger.Directory {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// androidBase wraps setup(androidSrc()) with the Gradle named-cache so that
|
||||||
|
// Gradle dependencies survive across Dagger execution-cache misses.
|
||||||
|
func (m *Ci) androidBase() *dagger.Container {
|
||||||
|
return m.setup(m.androidSrc()).
|
||||||
|
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
|
||||||
|
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// firebaseBase wraps setup(firebaseSrc()) with the Gradle named-cache.
|
||||||
|
func (m *Ci) firebaseBase() *dagger.Container {
|
||||||
|
return m.setup(m.firebaseSrc()).
|
||||||
|
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
|
||||||
|
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
|
||||||
|
}
|
||||||
|
|
||||||
// linuxSrc is the source subset for Linux builds and integration tests.
|
// linuxSrc is the source subset for Linux builds and integration tests.
|
||||||
func (m *Ci) linuxSrc() *dagger.Directory {
|
func (m *Ci) linuxSrc() *dagger.Directory {
|
||||||
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
@@ -318,12 +352,23 @@ func (m *Ci) Hugo() *dagger.Container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Deploy container for rsync/ssh
|
// Deploy container for rsync/ssh
|
||||||
func (m *Ci) Deployer(sshKey *dagger.Secret) *dagger.Container {
|
func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.Container {
|
||||||
return dag.Container().
|
return dag.Container().
|
||||||
From("alpine:3.21").
|
From("alpine:3.21").
|
||||||
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
|
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
|
||||||
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
// Create .ssh with strict permissions before Dagger mounts anything there,
|
||||||
WithEnvVariable("RSYNC_RSH", "ssh -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519")
|
// so the directory is 700 (not Dagger's default 755).
|
||||||
|
WithExec([]string{"sh", "-c", "mkdir -p /root/.ssh && chmod 700 /root/.ssh"}).
|
||||||
|
// Mount the raw key outside .ssh so Dagger cannot override the directory
|
||||||
|
// permissions we just set above.
|
||||||
|
WithMountedSecret("/tmp/id_ed25519.raw", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||||
|
// Normalise with Python3: strip CRLF/bare-CR, ensure trailing newline.
|
||||||
|
// Using Python3 (not tr) changes the Dagger cache key so stale cached
|
||||||
|
// results from the old tr-based step are not reused.
|
||||||
|
WithExec([]string{"python3", "-c",
|
||||||
|
"import os; raw=open('/tmp/id_ed25519.raw','rb').read(); key=raw.replace(b'\\r\\n',b'\\n').replace(b'\\r',b'\\n'); key=key if key.endswith(b'\\n') else key+b'\\n'; open('/root/.ssh/id_ed25519','wb').write(key); os.chmod('/root/.ssh/id_ed25519',0o600)"}).
|
||||||
|
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
|
||||||
|
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stalwart mail server service for backend and integration tests.
|
// Stalwart mail server service for backend and integration tests.
|
||||||
@@ -395,11 +440,73 @@ func (m *Ci) Format(ctx context.Context) (string, error) {
|
|||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckMocks verifies that generated mocks are up to date.
|
// FormatWrite formats Dart files and exports the modified /src directory.
|
||||||
// It snapshots the committed source (including any stale *.mocks.dart) before
|
func (m *Ci) FormatWrite() *dagger.Directory {
|
||||||
|
return m.setup(m.checkSrc()).
|
||||||
|
WithExec([]string{"dart", "format", "lib", "test"}).
|
||||||
|
Directory("/src")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze runs static analysis with dart analyze --fatal-infos.
|
||||||
|
func (m *Ci) Analyze(ctx context.Context) (string, error) {
|
||||||
|
return m.setup(m.checkSrc()).
|
||||||
|
WithExec([]string{"dart", "analyze", "--fatal-infos"}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codegen runs build_runner and exports the modified /src directory.
|
||||||
|
func (m *Ci) Codegen() *dagger.Directory {
|
||||||
|
return m.codegenBase().Directory("/src")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnalyzeFix runs dart fix --apply and exports the modified /src directory.
|
||||||
|
func (m *Ci) AnalyzeFix() *dagger.Directory {
|
||||||
|
return m.setup(m.checkSrc()).
|
||||||
|
WithExec([]string{"dart", "fix", "--apply"}).
|
||||||
|
Directory("/src")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckFast runs fast checks (hygiene, layers, format, analyze, mocks, coverage) in parallel.
|
||||||
|
func (m *Ci) CheckFast(ctx context.Context) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var eg errgroup.Group
|
||||||
|
eg.Go(func() error {
|
||||||
|
_, err := m.CheckHygiene(ctx)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
_, err := m.CheckLayers(ctx)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
_, err := m.Format(ctx)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
_, err := m.Analyze(ctx)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
_, err := m.CheckGenerated(ctx)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
_, err := m.Coverage(ctx)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err := eg.Wait(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "All fast checks passed!", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckGenerated verifies that all generated files (*.g.dart, *.mocks.dart) are up to date.
|
||||||
|
// It snapshots the committed source (including any stale generated files) before
|
||||||
// running build_runner, so git diff detects real staleness instead of always
|
// running build_runner, so git diff detects real staleness instead of always
|
||||||
// comparing two freshly-generated outputs.
|
// comparing two freshly-generated outputs.
|
||||||
func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
|
func (m *Ci) CheckGenerated(ctx context.Context) (string, error) {
|
||||||
return m.pubGetLayer().
|
return m.pubGetLayer().
|
||||||
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||||
WithWorkdir("/src").
|
WithWorkdir("/src").
|
||||||
@@ -412,16 +519,16 @@ func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
|
|||||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
|
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
|
||||||
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
|
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . \\( -name '*.g.dart' -o -name '*.mocks.dart' \\) | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Generated files are out of date — run: dart run build_runner build\"; exit 1; fi; echo \"Generated files are up to date.\""}).
|
||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coverage runs unit tests with coverage gate.
|
// Coverage runs unit and widget tests with coverage gate.
|
||||||
func (m *Ci) Coverage(ctx context.Context) (string, error) {
|
func (m *Ci) Coverage(ctx context.Context) (string, error) {
|
||||||
return m.setup(m.checkSrc()).
|
return m.setup(m.checkSrc()).
|
||||||
WithExec([]string{"/bin/bash", "-c",
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
`flutter test test/unit --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
`flutter test test/unit test/widget --exclude-tags golden --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||||
WithExec([]string{"dart", "scripts/check_coverage.dart"}).
|
WithExec([]string{"dart", "scripts/check_coverage.dart"}).
|
||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
@@ -463,11 +570,18 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
|||||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if _, err := m.CheckHygiene(ctx); err != nil {
|
// Run cheap structural checks in parallel for faster fail detection.
|
||||||
return "Hygiene check failed", err
|
var fastEg errgroup.Group
|
||||||
}
|
fastEg.Go(func() error {
|
||||||
if _, err := m.CheckLayers(ctx); err != nil {
|
_, err := m.CheckHygiene(ctx)
|
||||||
return "Layer check failed", err
|
return err
|
||||||
|
})
|
||||||
|
fastEg.Go(func() error {
|
||||||
|
_, err := m.CheckLayers(ctx)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err := fastEg.Wait(); err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
checkSetup := m.setup(m.checkSrc())
|
checkSetup := m.setup(m.checkSrc())
|
||||||
@@ -481,7 +595,7 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
|||||||
return analyze, err
|
return analyze, err
|
||||||
}
|
}
|
||||||
|
|
||||||
mocks, err := m.CheckMocks(ctx)
|
mocks, err := m.CheckGenerated(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return mocks, err
|
return mocks, err
|
||||||
}
|
}
|
||||||
@@ -491,16 +605,19 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
|||||||
return coverage, err
|
return coverage, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use errgroup.Group (not WithContext) so a failing test does not cancel its
|
||||||
|
// sibling via context — which would surface as "context canceled" in dagger
|
||||||
|
// output and trigger spurious retries in check-dagger.
|
||||||
var testBackend, testIntegration string
|
var testBackend, testIntegration string
|
||||||
eg, egCtx := errgroup.WithContext(ctx)
|
var eg errgroup.Group
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
var e error
|
var e error
|
||||||
testBackend, e = m.TestBackend(egCtx)
|
testBackend, e = m.TestBackend(ctx)
|
||||||
return e
|
return e
|
||||||
})
|
})
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
var e error
|
var e error
|
||||||
testIntegration, e = m.TestIntegration(egCtx)
|
testIntegration, e = m.TestIntegration(ctx)
|
||||||
return e
|
return e
|
||||||
})
|
})
|
||||||
if err := eg.Wait(); err != nil {
|
if err := eg.Wait(); err != nil {
|
||||||
@@ -514,6 +631,7 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
|||||||
func (m *Ci) GenerateBuildHistory(
|
func (m *Ci) GenerateBuildHistory(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sshKey *dagger.Secret,
|
sshKey *dagger.Secret,
|
||||||
|
knownHosts *dagger.Secret,
|
||||||
sshUser string,
|
sshUser string,
|
||||||
sshHost string,
|
sshHost string,
|
||||||
) *dagger.Directory {
|
) *dagger.Directory {
|
||||||
@@ -525,7 +643,7 @@ func (m *Ci) GenerateBuildHistory(
|
|||||||
From("python:3.12-alpine").
|
From("python:3.12-alpine").
|
||||||
WithExec([]string{"apk", "add", "--no-cache", "openssh-client"}).
|
WithExec([]string{"apk", "add", "--no-cache", "openssh-client"}).
|
||||||
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||||
WithExec([]string{"chmod", "700", "/root/.ssh"}).
|
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
|
||||||
WithEnvVariable("SSH_USER", sshUser).
|
WithEnvVariable("SSH_USER", sshUser).
|
||||||
WithEnvVariable("SSH_HOST", sshHost).
|
WithEnvVariable("SSH_HOST", sshHost).
|
||||||
WithDirectory("/src", scriptSource).
|
WithDirectory("/src", scriptSource).
|
||||||
@@ -538,18 +656,25 @@ func (m *Ci) GenerateBuildHistory(
|
|||||||
func (m *Ci) BuildWebsite(
|
func (m *Ci) BuildWebsite(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sshKey *dagger.Secret,
|
sshKey *dagger.Secret,
|
||||||
|
knownHosts *dagger.Secret,
|
||||||
sshUser string,
|
sshUser string,
|
||||||
sshHost string,
|
sshHost string,
|
||||||
|
// +optional
|
||||||
|
commitHash string,
|
||||||
) *dagger.Directory {
|
) *dagger.Directory {
|
||||||
buildHistory := m.GenerateBuildHistory(ctx, sshKey, sshUser, sshHost)
|
buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
|
||||||
|
|
||||||
websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{
|
websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
Include: []string{"website/"},
|
Include: []string{"website/"},
|
||||||
}).WithDirectory("website/content/builds", buildHistory)
|
}).WithDirectory("website/content/builds", buildHistory)
|
||||||
|
|
||||||
return m.Hugo().
|
hugo := m.Hugo().
|
||||||
WithDirectory("/src", websiteSource).
|
WithDirectory("/src", websiteSource).
|
||||||
WithWorkdir("/src/website").
|
WithWorkdir("/src/website")
|
||||||
|
if commitHash != "" {
|
||||||
|
hugo = hugo.WithEnvVariable("HUGO_PARAMS_GITVERSION", commitHash)
|
||||||
|
}
|
||||||
|
return hugo.
|
||||||
WithExec([]string{"hugo", "--minify"}).
|
WithExec([]string{"hugo", "--minify"}).
|
||||||
Directory("public")
|
Directory("public")
|
||||||
}
|
}
|
||||||
@@ -558,12 +683,15 @@ func (m *Ci) BuildWebsite(
|
|||||||
func (m *Ci) PublishWebsite(
|
func (m *Ci) PublishWebsite(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sshKey *dagger.Secret,
|
sshKey *dagger.Secret,
|
||||||
|
knownHosts *dagger.Secret,
|
||||||
sshUser string,
|
sshUser string,
|
||||||
sshHost string,
|
sshHost string,
|
||||||
|
// +optional
|
||||||
|
commitHash string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
public := m.BuildWebsite(ctx, sshKey, sshUser, sshHost)
|
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost, commitHash)
|
||||||
|
|
||||||
return m.Deployer(sshKey).
|
return m.Deployer(sshKey, knownHosts).
|
||||||
WithDirectory("/public", public).
|
WithDirectory("/public", public).
|
||||||
WithExec([]string{"rsync", "-avz", "--delete",
|
WithExec([]string{"rsync", "-avz", "--delete",
|
||||||
"--exclude=*.apk", "--exclude=*.tar.gz",
|
"--exclude=*.apk", "--exclude=*.tar.gz",
|
||||||
@@ -579,9 +707,17 @@ func (m *Ci) BuildLinux() *dagger.Directory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BuildLinuxRelease builds the Linux release bundle.
|
// BuildLinuxRelease builds the Linux release bundle.
|
||||||
func (m *Ci) BuildLinuxRelease() *dagger.Directory {
|
func (m *Ci) BuildLinuxRelease(
|
||||||
|
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||||
|
// +optional
|
||||||
|
commitHash string,
|
||||||
|
) *dagger.Directory {
|
||||||
|
args := []string{"flutter", "build", "linux", "--release"}
|
||||||
|
if commitHash != "" {
|
||||||
|
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
||||||
|
}
|
||||||
return m.setup(m.linuxSrc()).
|
return m.setup(m.linuxSrc()).
|
||||||
WithExec([]string{"flutter", "build", "linux", "--release"}).
|
WithExec(args).
|
||||||
Directory("build/linux/x64/release/bundle")
|
Directory("build/linux/x64/release/bundle")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,36 +725,49 @@ func (m *Ci) BuildLinuxRelease() *dagger.Directory {
|
|||||||
func (m *Ci) DeployLinux(
|
func (m *Ci) DeployLinux(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sshKey *dagger.Secret,
|
sshKey *dagger.Secret,
|
||||||
|
knownHosts *dagger.Secret,
|
||||||
sshUser string,
|
sshUser string,
|
||||||
sshHost string,
|
sshHost string,
|
||||||
commitHash string,
|
commitHash string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
bundle := m.BuildLinuxRelease()
|
bundle := m.BuildLinuxRelease(commitHash)
|
||||||
|
|
||||||
datePath := time.Now().Format("2006/01/02")
|
datePath := time.Now().Format("2006/01/02")
|
||||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||||
tarball := fmt.Sprintf("sharedinbox-linux-amd64-%s.tar.gz", commitHash)
|
tarball := fmt.Sprintf("sharedinbox-linux-amd64-%s.tar.gz", commitHash)
|
||||||
|
|
||||||
return m.Deployer(sshKey).
|
return m.Deployer(sshKey, knownHosts).
|
||||||
WithDirectory("/bundle", bundle).
|
WithDirectory("/bundle", bundle).
|
||||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("tar -czf /tmp/%s -C /bundle .", tarball)}).
|
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{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
||||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}).
|
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}).
|
||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupKeystore decodes the base64 keystore into the android build container.
|
// setupKeystore decodes the base64 keystore into the android build container.
|
||||||
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
|
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
|
||||||
return m.setup(m.androidSrc()).
|
return m.androidBase().
|
||||||
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
||||||
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
||||||
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
|
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/upload-keystore.jks`}).
|
||||||
|
WithEnvVariable("ANDROID_KEYSTORE_PATH", "/tmp/upload-keystore.jks")
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildAndroidApk builds a release APK signed with the upload key.
|
// BuildAndroidApk builds a release APK signed with the upload key.
|
||||||
func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string) *dagger.File {
|
func (m *Ci) BuildAndroidApk(
|
||||||
|
keystoreBase64 *dagger.Secret,
|
||||||
|
keystorePassword *dagger.Secret,
|
||||||
|
buildNumber string,
|
||||||
|
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||||
|
// +optional
|
||||||
|
commitHash string,
|
||||||
|
) *dagger.File {
|
||||||
|
args := []string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}
|
||||||
|
if commitHash != "" {
|
||||||
|
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
||||||
|
}
|
||||||
return m.setupKeystore(keystoreBase64, keystorePassword).
|
return m.setupKeystore(keystoreBase64, keystorePassword).
|
||||||
WithExec([]string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}).
|
WithExec(args).
|
||||||
File("build/app/outputs/flutter-apk/app-release.apk")
|
File("build/app/outputs/flutter-apk/app-release.apk")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,6 +775,7 @@ func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *da
|
|||||||
func (m *Ci) DeployApk(
|
func (m *Ci) DeployApk(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sshKey *dagger.Secret,
|
sshKey *dagger.Secret,
|
||||||
|
knownHosts *dagger.Secret,
|
||||||
sshUser string,
|
sshUser string,
|
||||||
sshHost string,
|
sshHost string,
|
||||||
commitHash string,
|
commitHash string,
|
||||||
@@ -633,24 +783,23 @@ func (m *Ci) DeployApk(
|
|||||||
keystorePassword *dagger.Secret,
|
keystorePassword *dagger.Secret,
|
||||||
buildNumber string,
|
buildNumber string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber)
|
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber, commitHash)
|
||||||
|
|
||||||
datePath := time.Now().Format("2006/01/02")
|
datePath := time.Now().Format("2006/01/02")
|
||||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||||
apkName := fmt.Sprintf("sharedinbox-mua-%s.apk", commitHash)
|
apkName := fmt.Sprintf("sharedinbox-mua-%s.apk", commitHash)
|
||||||
|
|
||||||
return m.Deployer(sshKey).
|
return m.Deployer(sshKey, knownHosts).
|
||||||
WithFile("/tmp/app.apk", apk).
|
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{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
||||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}).
|
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}).
|
||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
|
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
|
||||||
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
||||||
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
||||||
built := m.setup(m.firebaseSrc()).
|
built := m.firebaseBase().
|
||||||
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"}).
|
|
||||||
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
||||||
WithWorkdir("/src/android").
|
WithWorkdir("/src/android").
|
||||||
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
||||||
@@ -709,9 +858,17 @@ func (m *Ci) TestAndroidFirebase(
|
|||||||
|
|
||||||
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
|
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
|
||||||
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
|
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
|
||||||
func (m *Ci) BuildAndroidRelease() *dagger.File {
|
func (m *Ci) BuildAndroidRelease(
|
||||||
return m.setup(m.androidSrc()).
|
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||||
WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}).
|
// +optional
|
||||||
|
commitHash string,
|
||||||
|
) *dagger.File {
|
||||||
|
args := []string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}
|
||||||
|
if commitHash != "" {
|
||||||
|
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
||||||
|
}
|
||||||
|
return m.androidBase().
|
||||||
|
WithExec(args).
|
||||||
File("build/app/outputs/bundle/release/app-release.aab")
|
File("build/app/outputs/bundle/release/app-release.aab")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,14 +940,41 @@ func (m *Ci) PublishAndroid(
|
|||||||
playStoreConfig *dagger.Secret,
|
playStoreConfig *dagger.Secret,
|
||||||
keystoreBase64 *dagger.Secret,
|
keystoreBase64 *dagger.Secret,
|
||||||
keystorePassword *dagger.Secret,
|
keystorePassword *dagger.Secret,
|
||||||
|
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||||
|
// +optional
|
||||||
|
commitHash string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
versionCode := int(time.Now().Unix())
|
versionCode := int(time.Now().Unix())
|
||||||
aab := m.BuildAndroidRelease()
|
aab := m.BuildAndroidRelease(commitHash)
|
||||||
stamped := m.StampAndroidVersionCode(aab, versionCode)
|
stamped := m.StampAndroidVersionCode(aab, versionCode)
|
||||||
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
|
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
|
||||||
return m.UploadToPlayStore(ctx, signed, playStoreConfig)
|
return m.UploadToPlayStore(ctx, signed, playStoreConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renovate runs Renovate bot against the repository on Forgejo/Codeberg.
|
||||||
|
func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string, error) {
|
||||||
|
// Codeberg's GET /pulls?state=all&limit=100 times out with a 504, but limit=10
|
||||||
|
// completes in ~9 s. Patch the compiled pr-cache.js to use 10 instead of the
|
||||||
|
// hardcoded 20/100 values before launching renovate.
|
||||||
|
const patchCmd = `for f in \
|
||||||
|
/usr/local/renovate/dist/modules/platform/forgejo/pr-cache.js \
|
||||||
|
/usr/local/renovate/dist/modules/platform/gitea/pr-cache.js; do \
|
||||||
|
sed -i 's/limit: this\.items\.length ? 20 : 100/limit: this.items.length ? 10 : 10/' "$f" && echo "patched $f"; \
|
||||||
|
done`
|
||||||
|
return dag.Container().
|
||||||
|
From("renovate/renovate:43").
|
||||||
|
WithSecretVariable("RENOVATE_TOKEN", renovateToken).
|
||||||
|
WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
|
||||||
|
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
|
||||||
|
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
|
||||||
|
WithEnvVariable("LOG_LEVEL", "info").
|
||||||
|
WithUser("root").
|
||||||
|
WithExec([]string{"/bin/sh", "-c", patchCmd}).
|
||||||
|
WithUser("ubuntu").
|
||||||
|
WithExec([]string{"renovate"}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// Graph returns a Mermaid diagram of the CI pipeline structure.
|
// Graph returns a Mermaid diagram of the CI pipeline structure.
|
||||||
// Paste the output into any Mermaid renderer (codeberg, github, mermaid.live)
|
// Paste the output into any Mermaid renderer (codeberg, github, mermaid.live)
|
||||||
// or save it as a .md file to get a rendered diagram.
|
// or save it as a .md file to get a rendered diagram.
|
||||||
@@ -799,12 +983,12 @@ func (m *Ci) PublishAndroid(
|
|||||||
//
|
//
|
||||||
// dagger call --progress=plain -q -m ci --source=. graph
|
// dagger call --progress=plain -q -m ci --source=. graph
|
||||||
func (m *Ci) Graph() string {
|
func (m *Ci) Graph() string {
|
||||||
return `# CI Pipeline Graph
|
return fmt.Sprintf(`# CI Pipeline Graph
|
||||||
|
|
||||||
` + "```" + `mermaid
|
`+"```"+`mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
subgraph dagger ["Dagger · Check pipeline"]
|
subgraph dagger ["Dagger · Check pipeline"]
|
||||||
toolchain["toolchain\nflutter:3.41.6 + NDK + apt"]
|
toolchain["toolchain\nflutter:%s + NDK + apt + precache"]`, m.FlutterVersion) + `
|
||||||
pubGet["pubGetLayer\nflutter pub get"]
|
pubGet["pubGetLayer\nflutter pub get"]
|
||||||
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
||||||
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
||||||
@@ -814,7 +998,7 @@ flowchart TD
|
|||||||
|
|
||||||
pubGet --> hygiene["CheckHygiene"]
|
pubGet --> hygiene["CheckHygiene"]
|
||||||
pubGet --> layers["CheckLayers"]
|
pubGet --> layers["CheckLayers"]
|
||||||
pubGet --> mocks["CheckMocks\n(own build_runner run)"]
|
pubGet --> mocks["CheckGenerated\n(own build_runner run)"]
|
||||||
|
|
||||||
codegen --> fmt["Format"]
|
codegen --> fmt["Format"]
|
||||||
codegen --> analyze["Analyze"]
|
codegen --> analyze["Analyze"]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
|
||||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
# Load .env into environment
|
# Load .env into environment
|
||||||
@@ -13,7 +14,7 @@ export SSH_PRIVATE_KEY=$(cat "$HOME/.ssh/id_ed25519")
|
|||||||
|
|
||||||
# Add nix profile and nix store tools (task, dagger) to PATH
|
# Add nix profile and nix store tools (task, dagger) to PATH
|
||||||
export PATH="$HOME/.nix-profile/bin:$PATH"
|
export PATH="$HOME/.nix-profile/bin:$PATH"
|
||||||
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger"; do
|
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger" "*fgj-*/bin/fgj"; do
|
||||||
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
|
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
|
||||||
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
|
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -4,6 +4,28 @@ This file contains tasks which got implemented.
|
|||||||
|
|
||||||
Tasks get moved from next.md to done.md
|
Tasks get moved from next.md to done.md
|
||||||
|
|
||||||
|
## Tasks (2026-05-29)
|
||||||
|
|
||||||
|
- **Merge PR #307 — user preferences and configurable navigation (Issue #315)**: Confirmed that
|
||||||
|
all features from PR #307 (issue #299) were already merged into main via separate PRs:
|
||||||
|
- Configurable menu bar position (bottom/top) for mailbox view — merged via #298/#303
|
||||||
|
- Configurable back button position for single mail view — merged via #299/#307 features in #300
|
||||||
|
- Configurable "after mail action" (next message / return to mailbox) — merged via #300/#308
|
||||||
|
- Archive button with `resolveMailboxByRole` helper — merged via #287/#291, #286/#290
|
||||||
|
- User preferences DB schema (v34–v36: `user_preferences` table) — in main
|
||||||
|
- PR #307 and issue #299 closed.
|
||||||
|
- Issue #315 closed.
|
||||||
|
|
||||||
|
## Tasks (2026-05-26)
|
||||||
|
|
||||||
|
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
|
||||||
|
dependencies up to date. All required components are in main:
|
||||||
|
- `renovate.json` — Renovate configuration covering pub, Dockerfile, and Forgejo Actions
|
||||||
|
- `ci/main.go` — `Renovate()` Dagger function using Forgejo platform and Codeberg endpoint
|
||||||
|
- `.forgejo/workflows/renovate.yml` — daily cron (06:00 UTC) workflow
|
||||||
|
- `Taskfile.yml` — `renovate` task
|
||||||
|
- Issue #257 closed.
|
||||||
|
|
||||||
## Tasks (2026-05-11)
|
## Tasks (2026-05-11)
|
||||||
|
|
||||||
- **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering
|
- **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering
|
||||||
|
|||||||
@@ -99,6 +99,7 @@
|
|||||||
httplib2
|
httplib2
|
||||||
])) # 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)
|
||||||
|
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" shape-rendering="geometricPrecision">
|
||||||
|
<!-- White Background -->
|
||||||
|
<rect width="512" height="512" fill="white"/>
|
||||||
|
|
||||||
|
<!-- 6 Concentric Rainbow Rings (Tunnel Vision Geometry) -->
|
||||||
|
<g fill-rule="evenodd" stroke="black" stroke-width="2.5">
|
||||||
|
<!-- Red -->
|
||||||
|
<path fill="#FF0000" d="M256,256 m-242,0 a242,242 0 1,0 484,0 a242,242 0 1,0 -484,0 Z M256,256 m-190,0 a190,190 0 1,0 380,0 a190,190 0 1,0 -380,0 Z" />
|
||||||
|
|
||||||
|
<!-- Orange -->
|
||||||
|
<path fill="#FF8C00" d="M256,256 m-170,0 a170,170 0 1,0 340,0 a170,170 0 1,0 -340,0 Z M256,256 m-131,0 a131,131 0 1,0 262,0 a131,131 0 1,0 -262,0 Z" />
|
||||||
|
|
||||||
|
<!-- Yellow -->
|
||||||
|
<path fill="#FFD700" d="M256,256 m-115,0 a115,115 0 1,0 230,0 a115,115 0 1,0 -230,0 Z M256,256 m-85,0 a85,85 0 1,0 170,0 a85,85 0 1,0 -170,0 Z" />
|
||||||
|
|
||||||
|
<!-- Green -->
|
||||||
|
<path fill="#22AA00" d="M256,256 m-73,0 a73,73 0 1,0 146,0 a73,73 0 1,0 -146,0 Z M256,256 m-51,0 a51,51 0 1,0 102,0 a51,51 0 1,0 -102,0 Z" />
|
||||||
|
|
||||||
|
<!-- Blue -->
|
||||||
|
<path fill="#0055FF" d="M256,256 m-41,0 a41,41 0 1,0 82,0 a41,41 0 1,0 -82,0 Z M256,256 m-24,0 a24,24 0 1,0 48,0 a24,24 0 1,0 -48,0 Z" />
|
||||||
|
|
||||||
|
<!-- Purple -->
|
||||||
|
<path fill="#8B00FF" d="M256,256 m-16,0 a16,16 0 1,0 32,0 a16,16 0 1,0 -32,0 Z M256,256 m-3,0 a3,3 0 1,0 6,0 a3,3 0 1,0 -6,0 Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -317,7 +317,7 @@ void main() {
|
|||||||
|
|
||||||
// ── Check Sent folder ──────────────────────────────────────────────────
|
// ── Check Sent folder ──────────────────────────────────────────────────
|
||||||
// Use the drawer to switch folders (no back button on Linux desktop).
|
// Use the drawer to switch folders (no back button on Linux desktop).
|
||||||
await tester.tap(find.byTooltip('Open navigation menu'));
|
await tester.tap(find.byTooltip('Open folders'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Sent'));
|
await tester.tap(find.text('Sent'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -331,7 +331,7 @@ void main() {
|
|||||||
expect(find.text(subject), findsOneWidget);
|
expect(find.text(subject), findsOneWidget);
|
||||||
|
|
||||||
// ── Check Inbox ────────────────────────────────────────────────────────
|
// ── Check Inbox ────────────────────────────────────────────────────────
|
||||||
await tester.tap(find.byTooltip('Open navigation menu'));
|
await tester.tap(find.byTooltip('Open folders'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('INBOX'));
|
await tester.tap(find.text('INBOX'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
const int dbSchemaVersion = 38;
|
||||||
@@ -192,6 +192,22 @@ class EmailThread {
|
|||||||
required this.accountId,
|
required this.accountId,
|
||||||
required this.mailboxPath,
|
required this.mailboxPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Wraps a single [Email] as a one-message thread for uniform rendering.
|
||||||
|
factory EmailThread.fromEmail(Email e) => EmailThread(
|
||||||
|
threadId: e.threadId ?? e.id,
|
||||||
|
subject: e.subject,
|
||||||
|
participants: e.from,
|
||||||
|
latestDate: e.sentAt ?? e.receivedAt,
|
||||||
|
messageCount: 1,
|
||||||
|
hasUnread: !e.isSeen,
|
||||||
|
isFlagged: e.isFlagged,
|
||||||
|
latestEmailId: e.id,
|
||||||
|
preview: e.preview,
|
||||||
|
emailIds: [e.id],
|
||||||
|
accountId: e.accountId,
|
||||||
|
mailboxPath: e.mailboxPath,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class EmailAddress {
|
class EmailAddress {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
enum MenuPosition { bottom, top }
|
||||||
|
|
||||||
|
enum AfterMailViewAction { nextMessage, showMailbox }
|
||||||
|
|
||||||
|
enum PrefetchMode {
|
||||||
|
disabled,
|
||||||
|
wifiOnly,
|
||||||
|
always;
|
||||||
|
|
||||||
|
static PrefetchMode fromString(String? value) {
|
||||||
|
return PrefetchMode.values.firstWhere(
|
||||||
|
(e) => e.name == value,
|
||||||
|
orElse: () => PrefetchMode.wifiOnly,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserPreferences {
|
||||||
|
const UserPreferences({
|
||||||
|
this.menuPosition = MenuPosition.bottom,
|
||||||
|
this.mailViewButtonPosition = MenuPosition.bottom,
|
||||||
|
this.afterMailViewAction = AfterMailViewAction.nextMessage,
|
||||||
|
this.prefetchMode = PrefetchMode.wifiOnly,
|
||||||
|
this.bodyCacheLimitMb = 100,
|
||||||
|
});
|
||||||
|
final MenuPosition menuPosition;
|
||||||
|
final MenuPosition mailViewButtonPosition;
|
||||||
|
final AfterMailViewAction afterMailViewAction;
|
||||||
|
final PrefetchMode prefetchMode;
|
||||||
|
final int bodyCacheLimitMb;
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@ abstract class EmailRepository {
|
|||||||
int limit = 50,
|
int limit = 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Returns threads from the INBOX mailbox of every account, sorted by latest
|
||||||
|
/// message date descending. Inbox mailboxes are identified by role = 'inbox'.
|
||||||
|
Stream<List<EmailThread>> observeAllInboxThreads({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(
|
||||||
String accountId,
|
String accountId,
|
||||||
|
|||||||
@@ -11,4 +11,17 @@ abstract class MailboxRepository {
|
|||||||
|
|
||||||
/// Deletes all locally-cached mailbox rows for [accountId].
|
/// Deletes all locally-cached mailbox rows for [accountId].
|
||||||
Future<void> clearForResync(String accountId);
|
Future<void> clearForResync(String accountId);
|
||||||
|
|
||||||
|
/// Creates a new mailbox named [name] for [accountId] and tags it with
|
||||||
|
/// [role] in the local database. For JMAP accounts the role is also sent
|
||||||
|
/// to the server. Returns the newly created [Mailbox].
|
||||||
|
Future<Mailbox> createMailboxWithRole(
|
||||||
|
String accountId,
|
||||||
|
String name,
|
||||||
|
String role,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates a new mailbox named [name] for [accountId] without a special role.
|
||||||
|
/// Returns the newly created [Mailbox].
|
||||||
|
Future<Mailbox> createMailbox(String accountId, String name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class SyncLogEntry {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.result,
|
required this.result,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
|
this.stackTrace,
|
||||||
|
this.isPermanent = false,
|
||||||
required this.protocol,
|
required this.protocol,
|
||||||
required this.emailsFetched,
|
required this.emailsFetched,
|
||||||
required this.emailsSkipped,
|
required this.emailsSkipped,
|
||||||
@@ -34,6 +36,8 @@ class SyncLogEntry {
|
|||||||
final int id;
|
final int id;
|
||||||
final String result; // 'ok' or 'error'
|
final String result; // 'ok' or 'error'
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
final String? stackTrace;
|
||||||
|
final bool isPermanent;
|
||||||
final String protocol; // 'imap' or 'jmap'
|
final String protocol; // 'imap' or 'jmap'
|
||||||
final int emailsFetched;
|
final int emailsFetched;
|
||||||
final int emailsSkipped;
|
final int emailsSkipped;
|
||||||
@@ -54,6 +58,8 @@ abstract class SyncLogRepository {
|
|||||||
required String accountId,
|
required String accountId,
|
||||||
required bool success,
|
required bool success,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
String? stackTrace,
|
||||||
|
bool isPermanent = false,
|
||||||
required String protocol,
|
required String protocol,
|
||||||
required int emailsFetched,
|
required int emailsFetched,
|
||||||
required int emailsSkipped,
|
required int emailsSkipped,
|
||||||
@@ -81,6 +87,8 @@ class NoOpSyncLogRepository implements SyncLogRepository {
|
|||||||
required String accountId,
|
required String accountId,
|
||||||
required bool success,
|
required bool success,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
String? stackTrace,
|
||||||
|
bool isPermanent = false,
|
||||||
required String protocol,
|
required String protocol,
|
||||||
required int emailsFetched,
|
required int emailsFetched,
|
||||||
required int emailsSkipped,
|
required int emailsSkipped,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
|
|
||||||
|
abstract class UserPreferencesRepository {
|
||||||
|
Stream<UserPreferences> observePreferences();
|
||||||
|
Future<void> updateMenuPosition(MenuPosition position);
|
||||||
|
Future<void> updateMailViewButtonPosition(MenuPosition position);
|
||||||
|
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
|
||||||
|
Future<void> updatePrefetchMode(PrefetchMode mode);
|
||||||
|
Future<void> updateBodyCacheLimitMb(int mb);
|
||||||
|
|
||||||
|
Stream<List<String>> observeTrustedImageSenders();
|
||||||
|
Future<void> addTrustedImageSender(String senderEmail);
|
||||||
|
Future<void> removeTrustedImageSender(String senderEmail);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||||
|
|
||||||
|
/// Prefetches email bodies in the background and enforces a local cache size
|
||||||
|
/// limit by evicting the oldest cached bodies when the limit is exceeded.
|
||||||
|
class BodyCacheService {
|
||||||
|
BodyCacheService(this._db, this._accountRepo);
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
final AccountRepository _accountRepo;
|
||||||
|
|
||||||
|
static const _batchSize = 20;
|
||||||
|
|
||||||
|
Future<void> run() async {
|
||||||
|
final prefs = await (_db.select(
|
||||||
|
_db.userPreferences,
|
||||||
|
)).getSingleOrNull();
|
||||||
|
final limitMb = prefs?.bodyCacheLimitMb ?? 100;
|
||||||
|
final limitBytes = limitMb * 1024 * 1024;
|
||||||
|
|
||||||
|
await _evictIfNeeded(limitBytes);
|
||||||
|
|
||||||
|
final candidates = await _fetchCandidates();
|
||||||
|
if (candidates.isEmpty) return;
|
||||||
|
|
||||||
|
final emailRepo = EmailRepositoryImpl(_db, _accountRepo);
|
||||||
|
|
||||||
|
for (final emailId in candidates) {
|
||||||
|
final currentSize = await _getCacheSizeBytes();
|
||||||
|
if (currentSize >= limitBytes) break;
|
||||||
|
try {
|
||||||
|
await emailRepo.getEmailBody(emailId);
|
||||||
|
} catch (_) {
|
||||||
|
// Skip emails that fail to fetch.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _evictIfNeeded(int limitBytes) async {
|
||||||
|
final currentSize = await _getCacheSizeBytes();
|
||||||
|
if (currentSize <= limitBytes) return;
|
||||||
|
|
||||||
|
final bodies = await (_db.select(_db.emailBodies)
|
||||||
|
..where((t) => t.cachedAt.isNotNull())
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.cachedAt)]))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
var remaining = currentSize;
|
||||||
|
for (final body in bodies) {
|
||||||
|
if (remaining <= limitBytes) break;
|
||||||
|
final bodySize =
|
||||||
|
(body.textBody?.length ?? 0) + (body.htmlBody?.length ?? 0);
|
||||||
|
await (_db.delete(_db.emailBodies)
|
||||||
|
..where((t) => t.emailId.equals(body.emailId)))
|
||||||
|
.go();
|
||||||
|
remaining -= bodySize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> _getCacheSizeBytes() async {
|
||||||
|
final result = await _db
|
||||||
|
.customSelect(
|
||||||
|
"SELECT COALESCE(SUM(LENGTH(COALESCE(text_body, '')) + LENGTH(COALESCE(html_body, ''))), 0) AS total FROM email_bodies",
|
||||||
|
)
|
||||||
|
.getSingle();
|
||||||
|
return result.read<int>('total');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> _fetchCandidates() async {
|
||||||
|
final rows = await _db.customSelect(
|
||||||
|
'SELECT e.id FROM emails e '
|
||||||
|
'LEFT JOIN email_bodies eb ON eb.email_id = e.id '
|
||||||
|
'WHERE eb.email_id IS NULL '
|
||||||
|
'ORDER BY e.received_at DESC '
|
||||||
|
'LIMIT ?',
|
||||||
|
variables: [Variable.withInt(_batchSize)],
|
||||||
|
).get();
|
||||||
|
return rows.map((r) => r.read<String>('id')).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,8 +92,9 @@ class ShareEncryptionService {
|
|||||||
) {
|
) {
|
||||||
if (!s.startsWith(_pubKeyPrefix)) return null;
|
if (!s.startsWith(_pubKeyPrefix)) return null;
|
||||||
try {
|
try {
|
||||||
final data =
|
final data = Uint8List.fromList(
|
||||||
Uint8List.fromList(base64.decode(s.substring(_pubKeyPrefix.length)));
|
base64.decode(s.substring(_pubKeyPrefix.length)),
|
||||||
|
);
|
||||||
if (data.length != _keyIdLen + _pubKeyLen) return null;
|
if (data.length != _keyIdLen + _pubKeyLen) return null;
|
||||||
return (
|
return (
|
||||||
keyId: data.sublist(0, _keyIdLen),
|
keyId: data.sublist(0, _keyIdLen),
|
||||||
|
|||||||
@@ -108,8 +108,9 @@ class SieveInterpreter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _globMatch(String value, String pattern) {
|
bool _globMatch(String value, String pattern) {
|
||||||
final regexStr =
|
final regexStr = RegExp.escape(
|
||||||
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
pattern,
|
||||||
|
).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
||||||
return RegExp('^$regexStr\$').hasMatch(value);
|
return RegExp('^$regexStr\$').hasMatch(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -466,9 +466,7 @@ class _Scanner {
|
|||||||
|
|
||||||
String readTaggedArg() {
|
String readTaggedArg() {
|
||||||
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
||||||
throw SieveParseException(
|
throw SieveParseException('Expected tagged argument at position $_pos');
|
||||||
'Expected tagged argument at position $_pos',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String? peekSizeUnit() {
|
String? peekSizeUnit() {
|
||||||
@@ -480,9 +478,7 @@ class _Scanner {
|
|||||||
|
|
||||||
String readDigits() {
|
String readDigits() {
|
||||||
if (isAtEnd || !_isDigit(_src[_pos])) {
|
if (isAtEnd || !_isDigit(_src[_pos])) {
|
||||||
throw SieveParseException(
|
throw SieveParseException('Expected number at position $_pos');
|
||||||
'Expected number at position $_pos',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
final start = _pos;
|
final start = _pos;
|
||||||
while (!isAtEnd && _isDigit(_src[_pos])) {
|
while (!isAtEnd && _isDigit(_src[_pos])) {
|
||||||
@@ -493,9 +489,7 @@ class _Scanner {
|
|||||||
|
|
||||||
String readQuotedString() {
|
String readQuotedString() {
|
||||||
if (_src[_pos] != '"') {
|
if (_src[_pos] != '"') {
|
||||||
throw SieveParseException(
|
throw SieveParseException('Expected " at position $_pos');
|
||||||
'Expected " at position $_pos',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
_pos++; // skip opening quote
|
_pos++; // skip opening quote
|
||||||
final buf = StringBuffer();
|
final buf = StringBuffer();
|
||||||
|
|||||||
@@ -260,6 +260,8 @@ class _AccountSync implements _SyncLoop {
|
|||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
success: false,
|
success: false,
|
||||||
errorMessage: e.toString(),
|
errorMessage: e.toString(),
|
||||||
|
stackTrace: st.toString(),
|
||||||
|
isPermanent: isPermanent,
|
||||||
protocol: 'imap',
|
protocol: 'imap',
|
||||||
emailsFetched: 0,
|
emailsFetched: 0,
|
||||||
emailsSkipped: 0,
|
emailsSkipped: 0,
|
||||||
@@ -513,6 +515,8 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
success: false,
|
success: false,
|
||||||
errorMessage: e.toString(),
|
errorMessage: e.toString(),
|
||||||
|
stackTrace: st.toString(),
|
||||||
|
isPermanent: isPermanent,
|
||||||
protocol: 'jmap',
|
protocol: 'jmap',
|
||||||
emailsFetched: 0,
|
emailsFetched: 0,
|
||||||
emailsSkipped: 0,
|
emailsSkipped: 0,
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import 'package:path/path.dart' as p;
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/account.dart' as model;
|
import 'package:sharedinbox/core/models/account.dart' as model;
|
||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/services/body_cache_service.dart';
|
||||||
import 'package:sharedinbox/core/services/notification_service.dart';
|
import 'package:sharedinbox/core/services/notification_service.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';
|
||||||
@@ -21,6 +23,7 @@ import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
|||||||
import 'package:workmanager/workmanager.dart';
|
import 'package:workmanager/workmanager.dart';
|
||||||
|
|
||||||
const _kTaskName = 'si_bg_sync';
|
const _kTaskName = 'si_bg_sync';
|
||||||
|
const _kPrefetchTaskName = 'si_bg_prefetch';
|
||||||
const _kResourceType = 'background_check';
|
const _kResourceType = 'background_check';
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
@@ -28,9 +31,13 @@ void callbackDispatcher() {
|
|||||||
// Required so that path_provider and other plugins are available in this
|
// Required so that path_provider and other plugins are available in this
|
||||||
// background isolate (issue #192).
|
// background isolate (issue #192).
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
Workmanager().executeTask((_, __) async {
|
Workmanager().executeTask((taskName, __) async {
|
||||||
try {
|
try {
|
||||||
await _doBackgroundSync();
|
if (taskName == _kPrefetchTaskName) {
|
||||||
|
await _doBodyPrefetch();
|
||||||
|
} else {
|
||||||
|
await _doBackgroundSync();
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -55,6 +62,31 @@ Future<void> registerBackgroundSync() async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Registers (or cancels) the body-prefetch WorkManager task based on [mode].
|
||||||
|
/// Call on app startup and whenever the user changes the prefetch preference.
|
||||||
|
Future<void> registerBodyPrefetchTask(PrefetchMode mode) async {
|
||||||
|
try {
|
||||||
|
if (mode == PrefetchMode.disabled) {
|
||||||
|
await Workmanager().cancelByUniqueName(_kPrefetchTaskName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final networkType = mode == PrefetchMode.wifiOnly
|
||||||
|
? NetworkType.unmetered
|
||||||
|
: NetworkType.connected;
|
||||||
|
await Workmanager().registerPeriodicTask(
|
||||||
|
_kPrefetchTaskName,
|
||||||
|
_kPrefetchTaskName,
|
||||||
|
frequency: const Duration(hours: 1),
|
||||||
|
constraints: Constraints(networkType: networkType),
|
||||||
|
existingWorkPolicy: ExistingPeriodicWorkPolicy.replace,
|
||||||
|
);
|
||||||
|
} on PlatformException {
|
||||||
|
// Ignore — WorkManager unavailable.
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Ignore — plugin not registered.
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _doBackgroundSync() async {
|
Future<void> _doBackgroundSync() async {
|
||||||
final dir = await getApplicationSupportDirectory();
|
final dir = await getApplicationSupportDirectory();
|
||||||
final db = AppDatabase(
|
final db = AppDatabase(
|
||||||
@@ -76,6 +108,22 @@ Future<void> _doBackgroundSync() async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _doBodyPrefetch() async {
|
||||||
|
final dir = await getApplicationSupportDirectory();
|
||||||
|
final db = AppDatabase(
|
||||||
|
NativeDatabase(File(p.join(dir.path, 'sharedinbox.db'))),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final accountRepo = AccountRepositoryImpl(
|
||||||
|
db,
|
||||||
|
const FlutterSecureStorageImpl(),
|
||||||
|
);
|
||||||
|
await BodyCacheService(db, accountRepo).run();
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _checkAccount(
|
Future<void> _checkAccount(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
AccountRepository accountRepo,
|
AccountRepository accountRepo,
|
||||||
|
|||||||
@@ -35,10 +35,7 @@ String injectInlineImages(String html, imap.MimeMessage msg) {
|
|||||||
.replaceAll('src="cid:$bareCid"', 'src="$dataUri"')
|
.replaceAll('src="cid:$bareCid"', 'src="$dataUri"')
|
||||||
.replaceAll("src='cid:$bareCid'", "src='$dataUri'")
|
.replaceAll("src='cid:$bareCid'", "src='$dataUri'")
|
||||||
.replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"')
|
.replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"')
|
||||||
.replaceAll(
|
.replaceAll("src='cid:${bareCid.toLowerCase()}'", "src='$dataUri'");
|
||||||
"src='cid:${bareCid.toLowerCase()}'",
|
|
||||||
"src='$dataUri'",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:drift/native.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:sharedinbox/core/db_schema_version.dart';
|
||||||
|
|
||||||
part 'database.g.dart';
|
part 'database.g.dart';
|
||||||
|
|
||||||
@@ -192,6 +193,9 @@ class SyncLogs extends Table {
|
|||||||
DateTimeColumn get finishedAt => dateTime()();
|
DateTimeColumn get finishedAt => dateTime()();
|
||||||
// Added in schema v13: raw protocol log when account.verbose == true.
|
// Added in schema v13: raw protocol log when account.verbose == true.
|
||||||
TextColumn get protocolLog => text().nullable()();
|
TextColumn get protocolLog => text().nullable()();
|
||||||
|
// Added in schema v33: stack trace and permanent flag for error entries.
|
||||||
|
TextColumn get errorStackTrace => text().nullable()();
|
||||||
|
BoolColumn get isPermanent => boolean().withDefault(const Constant(false))();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-mailbox breakdown for a single sync cycle.
|
/// Per-mailbox breakdown for a single sync cycle.
|
||||||
@@ -303,6 +307,40 @@ class LocalSieveApplied extends Table {
|
|||||||
Set<Column> get primaryKey => {accountId, messageId};
|
Set<Column> get primaryKey => {accountId, messageId};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Senders for whom remote images are loaded automatically.
|
||||||
|
/// Per-device/per-user — not tied to any email account.
|
||||||
|
@DataClassName('ImageTrustedSenderRow')
|
||||||
|
class ImageTrustedSenders extends Table {
|
||||||
|
TextColumn get senderEmail => text()();
|
||||||
|
DateTimeColumn get addedAt => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {senderEmail};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App-wide user preferences, stored as a singleton row (id always 1).
|
||||||
|
@DataClassName('UserPreferencesRow')
|
||||||
|
class UserPreferences extends Table {
|
||||||
|
IntColumn get id => integer()();
|
||||||
|
// 'bottom' (default) | 'top'
|
||||||
|
TextColumn get menuPosition => text().withDefault(const Constant('bottom'))();
|
||||||
|
// Added in schema v35: 'bottom' (default) | 'top'
|
||||||
|
TextColumn get mailViewButtonPosition =>
|
||||||
|
text().withDefault(const Constant('bottom'))();
|
||||||
|
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
|
||||||
|
TextColumn get afterMailViewAction =>
|
||||||
|
text().withDefault(const Constant('nextMessage'))();
|
||||||
|
// Added in schema v38: 'disabled' | 'wifiOnly' (default) | 'always'
|
||||||
|
TextColumn get prefetchMode =>
|
||||||
|
text().withDefault(const Constant('wifiOnly'))();
|
||||||
|
// Added in schema v38: max cache size for offline email bodies, in megabytes.
|
||||||
|
IntColumn get bodyCacheLimitMb =>
|
||||||
|
integer().withDefault(const Constant(100))();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
// ── Database ──────────────────────────────────────────────────────────────────
|
// ── Database ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@DriftDatabase(
|
@DriftDatabase(
|
||||||
@@ -323,13 +361,15 @@ class LocalSieveApplied extends Table {
|
|||||||
LocalSieveScripts,
|
LocalSieveScripts,
|
||||||
LocalSieveApplied,
|
LocalSieveApplied,
|
||||||
ShareKeys,
|
ShareKeys,
|
||||||
|
UserPreferences,
|
||||||
|
ImageTrustedSenders,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
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 => dbSchemaVersion;
|
||||||
|
|
||||||
Future<void> _createEmailFts() async {
|
Future<void> _createEmailFts() async {
|
||||||
await customStatement('''
|
await customStatement('''
|
||||||
@@ -570,6 +610,35 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
if (from < 32) {
|
if (from < 32) {
|
||||||
await m.createTable(localSieveApplied);
|
await m.createTable(localSieveApplied);
|
||||||
}
|
}
|
||||||
|
if (from >= 7 && from < 33) {
|
||||||
|
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
|
||||||
|
await m.addColumn(syncLogs, syncLogs.isPermanent);
|
||||||
|
}
|
||||||
|
if (from < 34) {
|
||||||
|
await m.createTable(userPreferences);
|
||||||
|
}
|
||||||
|
if (from >= 34 && from < 35) {
|
||||||
|
await m.addColumn(
|
||||||
|
userPreferences,
|
||||||
|
userPreferences.mailViewButtonPosition,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (from >= 34 && from < 36) {
|
||||||
|
await m.addColumn(
|
||||||
|
userPreferences,
|
||||||
|
userPreferences.afterMailViewAction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (from < 37) {
|
||||||
|
await m.createTable(imageTrustedSenders);
|
||||||
|
}
|
||||||
|
if (from >= 34 && from < 38) {
|
||||||
|
await m.addColumn(userPreferences, userPreferences.prefetchMode);
|
||||||
|
await m.addColumn(
|
||||||
|
userPreferences,
|
||||||
|
userPreferences.bodyCacheLimitMb,
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ class LocalSieveRepository {
|
|||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
|
|
||||||
Future<List<SieveScript>> listScripts(String accountId) async {
|
Future<List<SieveScript>> listScripts(String accountId) async {
|
||||||
final rows = await (_db.select(_db.localSieveScripts)
|
final rows = await (_db.select(
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
_db.localSieveScripts,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
.get();
|
.get();
|
||||||
return rows
|
return rows
|
||||||
.map(
|
.map(
|
||||||
@@ -26,10 +27,9 @@ class LocalSieveRepository {
|
|||||||
|
|
||||||
Future<String> getScriptContent(String accountId, String blobId) async {
|
Future<String> getScriptContent(String accountId, String blobId) async {
|
||||||
final rowId = int.parse(blobId);
|
final rowId = int.parse(blobId);
|
||||||
final row = await (_db.select(_db.localSieveScripts)
|
final row = await (_db.select(
|
||||||
..where(
|
_db.localSieveScripts,
|
||||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
||||||
))
|
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
if (row == null) throw Exception('Local script not found: $blobId');
|
if (row == null) throw Exception('Local script not found: $blobId');
|
||||||
return row.content;
|
return row.content;
|
||||||
@@ -44,9 +44,7 @@ class LocalSieveRepository {
|
|||||||
if (id != null) {
|
if (id != null) {
|
||||||
final rowId = int.parse(id);
|
final rowId = int.parse(id);
|
||||||
await (_db.update(_db.localSieveScripts)
|
await (_db.update(_db.localSieveScripts)
|
||||||
..where(
|
..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
||||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
|
||||||
))
|
|
||||||
.write(
|
.write(
|
||||||
LocalSieveScriptsCompanion(
|
LocalSieveScriptsCompanion(
|
||||||
name: Value(name),
|
name: Value(name),
|
||||||
@@ -78,10 +76,9 @@ class LocalSieveRepository {
|
|||||||
|
|
||||||
Future<void> deleteScript(String accountId, String scriptId) async {
|
Future<void> deleteScript(String accountId, String scriptId) async {
|
||||||
final rowId = int.parse(scriptId);
|
final rowId = int.parse(scriptId);
|
||||||
await (_db.delete(_db.localSieveScripts)
|
await (_db.delete(
|
||||||
..where(
|
_db.localSieveScripts,
|
||||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
||||||
))
|
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +89,7 @@ class LocalSieveRepository {
|
|||||||
.write(const LocalSieveScriptsCompanion(isActive: Value(false)));
|
.write(const LocalSieveScriptsCompanion(isActive: Value(false)));
|
||||||
final rowId = int.parse(scriptId);
|
final rowId = int.parse(scriptId);
|
||||||
await (_db.update(_db.localSieveScripts)
|
await (_db.update(_db.localSieveScripts)
|
||||||
..where(
|
..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
||||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
|
||||||
))
|
|
||||||
.write(const LocalSieveScriptsCompanion(isActive: Value(true)));
|
.write(const LocalSieveScriptsCompanion(isActive: Value(true)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,8 @@ import 'package:sharedinbox/data/db/database.dart';
|
|||||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
|
|
||||||
class DraftRepositoryImpl implements DraftRepository {
|
class DraftRepositoryImpl implements DraftRepository {
|
||||||
DraftRepositoryImpl(
|
DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect})
|
||||||
this._db,
|
: _imapConnect = imapConnect;
|
||||||
this._accounts, {
|
|
||||||
ImapConnectFn? imapConnect,
|
|
||||||
}) : _imapConnect = imapConnect;
|
|
||||||
|
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
final AccountRepository _accounts;
|
final AccountRepository _accounts;
|
||||||
@@ -124,10 +121,7 @@ class DraftRepositoryImpl implements DraftRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _syncWithServer(
|
Future<void> _syncWithServer(imap.ImapClient client, String accountId) async {
|
||||||
imap.ImapClient client,
|
|
||||||
String accountId,
|
|
||||||
) async {
|
|
||||||
// Create/select the Drafts folder.
|
// Create/select the Drafts folder.
|
||||||
try {
|
try {
|
||||||
await client.createMailbox('Drafts');
|
await client.createMailbox('Drafts');
|
||||||
@@ -162,8 +156,9 @@ class DraftRepositoryImpl implements DraftRepository {
|
|||||||
? uidList.first.toString()
|
? uidList.first.toString()
|
||||||
: null;
|
: null;
|
||||||
if (uid != null) {
|
if (uid != null) {
|
||||||
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id)))
|
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))).write(
|
||||||
.write(DraftsCompanion(imapServerId: Value(uid)));
|
DraftsCompanion(imapServerId: Value(uid)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,26 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
.map((rows) => rows.map(_threadRowToModel).toList());
|
.map((rows) => rows.map(_threadRowToModel).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<model.EmailThread>> observeAllInboxThreads({int limit = 50}) {
|
||||||
|
final query = _db.select(_db.threads).join([
|
||||||
|
innerJoin(
|
||||||
|
_db.mailboxes,
|
||||||
|
_db.mailboxes.accountId.equalsExp(_db.threads.accountId) &
|
||||||
|
_db.mailboxes.path.equalsExp(_db.threads.mailboxPath),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
query
|
||||||
|
..where(_db.mailboxes.role.equals('inbox'))
|
||||||
|
..orderBy([OrderingTerm.desc(_db.threads.latestDate)])
|
||||||
|
..limit(limit);
|
||||||
|
return query.watch().map(
|
||||||
|
(rows) => rows
|
||||||
|
.map((row) => _threadRowToModel(row.readTable(_db.threads)))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
model.EmailThread _threadRowToModel(ThreadRow row) {
|
model.EmailThread _threadRowToModel(ThreadRow row) {
|
||||||
List<model.EmailAddress> parseAddresses(String json) {
|
List<model.EmailAddress> parseAddresses(String json) {
|
||||||
final list = jsonDecode(json) as List<dynamic>;
|
final list = jsonDecode(json) as List<dynamic>;
|
||||||
@@ -156,6 +176,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (threadEmails.isEmpty) return;
|
||||||
final latest = threadEmails.last;
|
final latest = threadEmails.last;
|
||||||
|
|
||||||
// Collect unique participants across the whole thread.
|
// Collect unique participants across the whole thread.
|
||||||
@@ -237,7 +258,12 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
try {
|
try {
|
||||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||||
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
|
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
|
||||||
final msg = fetch.messages.first;
|
final msg = fetch.messages.firstOrNull;
|
||||||
|
if (msg == null) {
|
||||||
|
throw StateError(
|
||||||
|
'IMAP server returned no message for UID ${emailRow.uid}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
final textBody = msg.decodeTextPlainPart();
|
final textBody = msg.decodeTextPlainPart();
|
||||||
final rawHtml = msg.decodeTextHtmlPart();
|
final rawHtml = msg.decodeTextHtmlPart();
|
||||||
final htmlBody =
|
final htmlBody =
|
||||||
@@ -325,13 +351,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
],
|
],
|
||||||
'fetchHTMLBodyValues': true,
|
'fetchHTMLBodyValues': true,
|
||||||
'fetchTextBodyValues': true,
|
'fetchTextBodyValues': true,
|
||||||
'bodyProperties': [
|
'bodyProperties': ['partId', 'type', 'name', 'size', 'subParts'],
|
||||||
'partId',
|
|
||||||
'type',
|
|
||||||
'name',
|
|
||||||
'size',
|
|
||||||
'subParts',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
'0',
|
'0',
|
||||||
],
|
],
|
||||||
@@ -1949,8 +1969,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
final inboxPath = inboxMailbox?.path ?? 'INBOX';
|
final inboxPath = inboxMailbox?.path ?? 'INBOX';
|
||||||
|
|
||||||
final alreadyApplied = await (_db.select(_db.localSieveApplied)
|
final alreadyApplied = await (_db.select(
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
_db.localSieveApplied,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
.get();
|
.get();
|
||||||
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
|
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
|
||||||
|
|
||||||
@@ -2050,7 +2071,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
..limit(1))
|
..limit(1))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
if (destMailbox == null) {
|
if (destMailbox == null) {
|
||||||
log('Sieve: JMAP mailbox "$folder" not found for account ${account.id}');
|
log(
|
||||||
|
'Sieve: JMAP mailbox "$folder" not found for account ${account.id}',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
destPath = destMailbox.path;
|
destPath = destMailbox.path;
|
||||||
@@ -2808,11 +2831,13 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
// Content-Transfer-Encoding) and getPart() can decode the part correctly.
|
// Content-Transfer-Encoding) and getPart() can decode the part correctly.
|
||||||
// A partial BODY.PEEK[n] fetch omits those headers, causing
|
// A partial BODY.PEEK[n] fetch omits those headers, causing
|
||||||
// decodeContentBinary() to return raw base64 instead of decoded bytes.
|
// decodeContentBinary() to return raw base64 instead of decoded bytes.
|
||||||
final fetch = await client.uidFetchMessage(
|
final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
|
||||||
emailRow.uid,
|
final msg = fetch.messages.firstOrNull;
|
||||||
'BODY.PEEK[]',
|
if (msg == null) {
|
||||||
);
|
throw StateError(
|
||||||
final msg = fetch.messages.first;
|
'IMAP server returned no message for UID ${emailRow.uid}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
final part = msg.getPart(attachment.fetchPartId) ?? msg;
|
final part = msg.getPart(attachment.fetchPartId) ?? msg;
|
||||||
final bytes = part.decodeContentBinary();
|
final bytes = part.decodeContentBinary();
|
||||||
if (bytes == null) {
|
if (bytes == null) {
|
||||||
@@ -2874,11 +2899,14 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||||
final fetch = await client.uidFetchMessage(
|
final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
|
||||||
emailRow.uid,
|
final msg = fetch.messages.firstOrNull;
|
||||||
'BODY.PEEK[]',
|
if (msg == null) {
|
||||||
);
|
throw StateError(
|
||||||
return fetch.messages.first.renderMessage();
|
'IMAP server returned no message for UID ${emailRow.uid}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return msg.renderMessage();
|
||||||
} finally {
|
} finally {
|
||||||
await client.logout();
|
await client.logout();
|
||||||
}
|
}
|
||||||
@@ -2955,6 +2983,20 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}) async {
|
}) async {
|
||||||
if (query.length < 2) return [];
|
if (query.length < 2) return [];
|
||||||
final pattern = '%${query.toLowerCase()}%';
|
final pattern = '%${query.toLowerCase()}%';
|
||||||
|
|
||||||
|
// Addresses we deliberately wrote to (sent folder) should appear before
|
||||||
|
// addresses that happened to email us (inbox/other folders).
|
||||||
|
final sentMailboxes = await (_db.select(_db.mailboxes)
|
||||||
|
..where((t) {
|
||||||
|
Expression<bool> cond = t.role.equals('sent');
|
||||||
|
if (accountId != null) {
|
||||||
|
cond = t.accountId.equals(accountId) & cond;
|
||||||
|
}
|
||||||
|
return cond;
|
||||||
|
}))
|
||||||
|
.get();
|
||||||
|
final sentPaths = {for (final m in sentMailboxes) m.path};
|
||||||
|
|
||||||
final rows = await (_db.select(_db.emails)
|
final rows = await (_db.select(_db.emails)
|
||||||
..where((t) {
|
..where((t) {
|
||||||
Expression<bool> cond = const Constant(true);
|
Expression<bool> cond = const Constant(true);
|
||||||
@@ -2969,11 +3011,22 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
..limit(100))
|
..limit(100))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
|
// Two passes: sent-folder rows first (prioritise recipients we chose),
|
||||||
|
// then other rows (senders who contacted us).
|
||||||
|
final sortedRows = [
|
||||||
|
...rows.where((r) => sentPaths.contains(r.mailboxPath)),
|
||||||
|
...rows.where((r) => !sentPaths.contains(r.mailboxPath)),
|
||||||
|
];
|
||||||
|
|
||||||
final seen = <String>{};
|
final seen = <String>{};
|
||||||
final results = <model.EmailAddress>[];
|
final results = <model.EmailAddress>[];
|
||||||
final lowerQuery = query.toLowerCase();
|
final lowerQuery = query.toLowerCase();
|
||||||
for (final row in rows) {
|
for (final row in sortedRows) {
|
||||||
for (final jsonStr in [row.fromJson, row.toAddresses, row.ccJson]) {
|
final isSent = sentPaths.contains(row.mailboxPath);
|
||||||
|
final fields = isSent
|
||||||
|
? [row.toAddresses, row.ccJson, row.fromJson]
|
||||||
|
: [row.fromJson, row.toAddresses, row.ccJson];
|
||||||
|
for (final jsonStr in fields) {
|
||||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||||
for (final e in list) {
|
for (final e in list) {
|
||||||
final map = e as Map<String, dynamic>;
|
final map = e as Map<String, dynamic>;
|
||||||
@@ -3252,14 +3305,17 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
await _db.customStatement('PRAGMA foreign_keys = OFF');
|
await _db.customStatement('PRAGMA foreign_keys = OFF');
|
||||||
try {
|
try {
|
||||||
await _db.transaction(() async {
|
await _db.transaction(() async {
|
||||||
await (_db.delete(_db.emails)
|
await (_db.delete(
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
_db.emails,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
.go();
|
.go();
|
||||||
await (_db.delete(_db.pendingChanges)
|
await (_db.delete(
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
_db.pendingChanges,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
.go();
|
.go();
|
||||||
await (_db.delete(_db.syncStates)
|
await (_db.delete(
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
_db.syncStates,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
.go();
|
.go();
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
final mailboxes = await client.listMailboxes(recursive: true);
|
final mailboxes = await client.listMailboxes(recursive: true);
|
||||||
|
|
||||||
|
// Pre-load existing DB roles so we can preserve manually-set roles for
|
||||||
|
// folders the server doesn't tag with a special-use attribute.
|
||||||
|
final existingRows = await (_db.select(
|
||||||
|
_db.mailboxes,
|
||||||
|
)..where((t) => t.accountId.equals(account.id)))
|
||||||
|
.get();
|
||||||
|
final existingRoles = {for (final r in existingRows) r.id: r.role};
|
||||||
|
|
||||||
for (final mb in mailboxes) {
|
for (final mb in mailboxes) {
|
||||||
final path = mb.path;
|
final path = mb.path;
|
||||||
final id = '${account.id}:$path';
|
final id = '${account.id}:$path';
|
||||||
@@ -96,6 +105,12 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
log('STATUS skipped for $path: $e');
|
log('STATUS skipped for $path: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the server-assigned role when available; fall back to the
|
||||||
|
// existing DB role so that manually-created folders (e.g. a user
|
||||||
|
// who just created their Archive folder) keep their role across syncs
|
||||||
|
// when the IMAP server does not expose a special-use attribute.
|
||||||
|
final role = _imapRole(mb) ?? existingRoles[id];
|
||||||
|
|
||||||
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
||||||
MailboxesCompanion.insert(
|
MailboxesCompanion.insert(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -104,7 +119,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
name: mb.name,
|
name: mb.name,
|
||||||
unreadCount: Value(unread),
|
unreadCount: Value(unread),
|
||||||
totalCount: Value(total),
|
totalCount: Value(total),
|
||||||
role: Value(_imapRole(mb)),
|
role: Value(role),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -306,8 +321,127 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> clearForResync(String accountId) async {
|
Future<void> clearForResync(String accountId) async {
|
||||||
await (_db.delete(_db.mailboxes)
|
await (_db.delete(
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
_db.mailboxes,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<model.Mailbox> createMailboxWithRole(
|
||||||
|
String accountId,
|
||||||
|
String name,
|
||||||
|
String role,
|
||||||
|
) async {
|
||||||
|
final account = (await _accounts.getAccount(accountId))!;
|
||||||
|
final password = await _accounts.getPassword(accountId);
|
||||||
|
switch (account.type) {
|
||||||
|
case account_model.AccountType.imap:
|
||||||
|
return _createMailboxWithRoleImap(account, password, name, role);
|
||||||
|
case account_model.AccountType.jmap:
|
||||||
|
return _createMailboxWithRoleJmap(account, password, name, role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<model.Mailbox> createMailbox(String accountId, String name) async {
|
||||||
|
final account = (await _accounts.getAccount(accountId))!;
|
||||||
|
final password = await _accounts.getPassword(accountId);
|
||||||
|
switch (account.type) {
|
||||||
|
case account_model.AccountType.imap:
|
||||||
|
return _createMailboxWithRoleImap(account, password, name, null);
|
||||||
|
case account_model.AccountType.jmap:
|
||||||
|
return _createMailboxWithRoleJmap(account, password, name, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<model.Mailbox> _createMailboxWithRoleImap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String name,
|
||||||
|
String? role,
|
||||||
|
) async {
|
||||||
|
final client = await _imapConnect(
|
||||||
|
account,
|
||||||
|
_effectiveUsername(account),
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await client.createMailbox(name);
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
final id = '${account.id}:$name';
|
||||||
|
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
||||||
|
MailboxesCompanion.insert(
|
||||||
|
id: id,
|
||||||
|
accountId: account.id,
|
||||||
|
path: name,
|
||||||
|
name: name,
|
||||||
|
role: Value(role),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final row = await (_db.select(
|
||||||
|
_db.mailboxes,
|
||||||
|
)..where((t) => t.id.equals(id)))
|
||||||
|
.getSingle();
|
||||||
|
return _toModel(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<model.Mailbox> _createMailboxWithRoleJmap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String name,
|
||||||
|
String? role,
|
||||||
|
) async {
|
||||||
|
final jmapUrl = account.jmapUrl;
|
||||||
|
if (jmapUrl == null || jmapUrl.isEmpty) {
|
||||||
|
throw Exception('JMAP account ${account.id} has no jmapUrl');
|
||||||
|
}
|
||||||
|
final jmap = await JmapClient.connect(
|
||||||
|
httpClient: _httpClient,
|
||||||
|
jmapUrl: Uri.parse(jmapUrl),
|
||||||
|
username: _effectiveUsername(account),
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
final responses = await jmap.call([
|
||||||
|
[
|
||||||
|
'Mailbox/set',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'create': {
|
||||||
|
'new-mailbox': {
|
||||||
|
'name': name,
|
||||||
|
if (role != null) 'role': role,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final result = _responseArgs(responses, 0, 'Mailbox/set');
|
||||||
|
final created = result['created'] as Map<String, dynamic>?;
|
||||||
|
final newId =
|
||||||
|
(created?['new-mailbox'] as Map<String, dynamic>?)?['id'] as String?;
|
||||||
|
if (newId == null) {
|
||||||
|
throw Exception(
|
||||||
|
'Failed to create mailbox "$name": server returned no ID',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final dbId = '${account.id}:$newId';
|
||||||
|
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
||||||
|
MailboxesCompanion.insert(
|
||||||
|
id: dbId,
|
||||||
|
accountId: account.id,
|
||||||
|
path: newId,
|
||||||
|
name: name,
|
||||||
|
role: Value(role),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final row = await (_db.select(
|
||||||
|
_db.mailboxes,
|
||||||
|
)..where((t) => t.id.equals(dbId)))
|
||||||
|
.getSingle();
|
||||||
|
return _toModel(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
|||||||
|
|
||||||
await _db.transaction(() async {
|
await _db.transaction(() async {
|
||||||
// Remove existing entry for same query (deduplication).
|
// Remove existing entry for same query (deduplication).
|
||||||
await (_db.delete(_db.searchHistoryEntries)
|
await (_db.delete(
|
||||||
..where((t) => t.query.equals(trimmed)))
|
_db.searchHistoryEntries,
|
||||||
|
)..where((t) => t.query.equals(trimmed)))
|
||||||
.go();
|
.go();
|
||||||
|
|
||||||
await _db.into(_db.searchHistoryEntries).insert(
|
await _db.into(_db.searchHistoryEntries).insert(
|
||||||
@@ -43,8 +44,9 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (keepIds.isNotEmpty) {
|
if (keepIds.isNotEmpty) {
|
||||||
await (_db.delete(_db.searchHistoryEntries)
|
await (_db.delete(
|
||||||
..where((t) => t.id.isNotIn(keepIds)))
|
_db.searchHistoryEntries,
|
||||||
|
)..where((t) => t.id.isNotIn(keepIds)))
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
|||||||
await _pruneExpired();
|
await _pruneExpired();
|
||||||
|
|
||||||
final keyIdHex = _hex(keyId);
|
final keyIdHex = _hex(keyId);
|
||||||
final row = await (_db.select(_db.shareKeys)
|
final row = await (_db.select(
|
||||||
..where((t) => t.id.equals(keyIdHex)))
|
_db.shareKeys,
|
||||||
|
)..where((t) => t.id.equals(keyIdHex)))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
|
|
||||||
if (row == null) return null;
|
if (row == null) return null;
|
||||||
@@ -55,10 +56,9 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pruneExpired() async {
|
Future<void> _pruneExpired() async {
|
||||||
await (_db.delete(_db.shareKeys)
|
await (_db.delete(
|
||||||
..where(
|
_db.shareKeys,
|
||||||
(t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()),
|
)..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc())))
|
||||||
))
|
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
|||||||
required String accountId,
|
required String accountId,
|
||||||
required bool success,
|
required bool success,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
String? stackTrace,
|
||||||
|
bool isPermanent = false,
|
||||||
required String protocol,
|
required String protocol,
|
||||||
required int emailsFetched,
|
required int emailsFetched,
|
||||||
required int emailsSkipped,
|
required int emailsSkipped,
|
||||||
@@ -30,6 +32,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
|||||||
accountId: accountId,
|
accountId: accountId,
|
||||||
result: success ? 'ok' : 'error',
|
result: success ? 'ok' : 'error',
|
||||||
errorMessage: Value(errorMessage),
|
errorMessage: Value(errorMessage),
|
||||||
|
errorStackTrace: Value(stackTrace),
|
||||||
|
isPermanent: Value(isPermanent),
|
||||||
protocol: Value(protocol),
|
protocol: Value(protocol),
|
||||||
itemsSynced: Value(emailsFetched),
|
itemsSynced: Value(emailsFetched),
|
||||||
emailsSkipped: Value(emailsSkipped),
|
emailsSkipped: Value(emailsSkipped),
|
||||||
@@ -75,6 +79,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
result: r.result,
|
result: r.result,
|
||||||
errorMessage: r.errorMessage,
|
errorMessage: r.errorMessage,
|
||||||
|
stackTrace: r.errorStackTrace,
|
||||||
|
isPermanent: r.isPermanent,
|
||||||
protocol: r.protocol,
|
protocol: r.protocol,
|
||||||
emailsFetched: r.itemsSynced,
|
emailsFetched: r.itemsSynced,
|
||||||
emailsSkipped: r.emailsSkipped,
|
emailsSkipped: r.emailsSkipped,
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart' as pref;
|
||||||
|
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
|
||||||
|
class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
||||||
|
UserPreferencesRepositoryImpl(this._db);
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
static const _rowId = 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<pref.UserPreferences> observePreferences() {
|
||||||
|
return (_db.select(
|
||||||
|
_db.userPreferences,
|
||||||
|
)..where((t) => t.id.equals(_rowId)))
|
||||||
|
.watchSingleOrNull()
|
||||||
|
.map(_rowToModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateMenuPosition(pref.MenuPosition position) async {
|
||||||
|
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||||
|
UserPreferencesCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
menuPosition: Value(position.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateMailViewButtonPosition(pref.MenuPosition position) async {
|
||||||
|
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||||
|
UserPreferencesCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
mailViewButtonPosition: Value(position.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateAfterMailViewAction(
|
||||||
|
pref.AfterMailViewAction action,
|
||||||
|
) async {
|
||||||
|
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||||
|
UserPreferencesCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
afterMailViewAction: Value(action.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updatePrefetchMode(pref.PrefetchMode mode) async {
|
||||||
|
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||||
|
UserPreferencesCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
prefetchMode: Value(mode.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateBodyCacheLimitMb(int mb) async {
|
||||||
|
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||||
|
UserPreferencesCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
bodyCacheLimitMb: Value(mb),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<String>> observeTrustedImageSenders() {
|
||||||
|
return (_db.select(_db.imageTrustedSenders)
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.addedAt)]))
|
||||||
|
.watch()
|
||||||
|
.map((rows) => rows.map((r) => r.senderEmail).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> addTrustedImageSender(String senderEmail) async {
|
||||||
|
await _db.into(_db.imageTrustedSenders).insertOnConflictUpdate(
|
||||||
|
ImageTrustedSendersCompanion(
|
||||||
|
senderEmail: Value(senderEmail.toLowerCase()),
|
||||||
|
addedAt: Value(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> removeTrustedImageSender(String senderEmail) async {
|
||||||
|
await (_db.delete(_db.imageTrustedSenders)
|
||||||
|
..where((t) => t.senderEmail.equals(senderEmail.toLowerCase())))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
|
static pref.UserPreferences _rowToModel(UserPreferencesRow? row) {
|
||||||
|
if (row == null) return const pref.UserPreferences();
|
||||||
|
return pref.UserPreferences(
|
||||||
|
menuPosition: pref.MenuPosition.values.firstWhere(
|
||||||
|
(e) => e.name == row.menuPosition,
|
||||||
|
orElse: () => pref.MenuPosition.bottom,
|
||||||
|
),
|
||||||
|
mailViewButtonPosition: pref.MenuPosition.values.firstWhere(
|
||||||
|
(e) => e.name == row.mailViewButtonPosition,
|
||||||
|
orElse: () => pref.MenuPosition.bottom,
|
||||||
|
),
|
||||||
|
afterMailViewAction: pref.AfterMailViewAction.values.firstWhere(
|
||||||
|
(e) => e.name == row.afterMailViewAction,
|
||||||
|
orElse: () => pref.AfterMailViewAction.nextMessage,
|
||||||
|
),
|
||||||
|
prefetchMode: pref.PrefetchMode.fromString(row.prefetchMode),
|
||||||
|
bodyCacheLimitMb: row.bodyCacheLimitMb,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
-7
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'package:sharedinbox/core/models/account.dart' as model;
|
import 'package:sharedinbox/core/models/account.dart' as model;
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
@@ -13,6 +14,7 @@ import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
|||||||
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/undo_repository.dart';
|
import 'package:sharedinbox/core/repositories/undo_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
|
||||||
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
||||||
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
||||||
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
||||||
@@ -21,7 +23,8 @@ 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'
|
||||||
|
hide Email, EmailBody, UserPreferences;
|
||||||
import 'package:sharedinbox/data/db/local_sieve_repository.dart';
|
import 'package:sharedinbox/data/db/local_sieve_repository.dart';
|
||||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
|
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
|
||||||
@@ -33,6 +36,7 @@ import 'package:sharedinbox/data/repositories/search_history_repository_impl.dar
|
|||||||
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/user_preferences_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
||||||
|
|
||||||
/// Swappable IMAP connection factory — override in tests to use plaintext.
|
/// Swappable IMAP connection factory — override in tests to use plaintext.
|
||||||
@@ -97,8 +101,9 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
|
|||||||
return UndoRepositoryImpl(ref.watch(dbProvider));
|
return UndoRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final searchHistoryRepositoryProvider =
|
final searchHistoryRepositoryProvider = Provider<SearchHistoryRepository>((
|
||||||
Provider<SearchHistoryRepository>((ref) {
|
ref,
|
||||||
|
) {
|
||||||
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,8 +136,10 @@ final syncHealthProvider =
|
|||||||
.watchSingleOrNull();
|
.watchSingleOrNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
final isSyncingProvider =
|
final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>((
|
||||||
StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
|
ref,
|
||||||
|
accountId,
|
||||||
|
) {
|
||||||
return ref.watch(syncManagerProvider).watchSyncing(accountId);
|
return ref.watch(syncManagerProvider).watchSyncing(accountId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,8 +188,9 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
|
|||||||
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
|
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final undoServiceProvider =
|
final undoServiceProvider = NotifierProvider<UndoService, List<UndoAction>>(
|
||||||
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
|
UndoService.new,
|
||||||
|
);
|
||||||
|
|
||||||
/// Loads email header + body and marks the email as seen.
|
/// Loads email header + body and marks the email as seen.
|
||||||
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
|
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
|
||||||
@@ -203,10 +211,38 @@ class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
|
|||||||
repo.getEmailBody(_emailId),
|
repo.getEmailBody(_emailId),
|
||||||
]);
|
]);
|
||||||
unawaited(repo.setFlag(_emailId, seen: true));
|
unawaited(repo.setFlag(_emailId, seen: true));
|
||||||
|
final header = results[0] as Email?;
|
||||||
|
if (header != null) {
|
||||||
|
unawaited(_prefetchNextEmailBody(repo, header));
|
||||||
|
}
|
||||||
return (results[0] as Email?, results[1] as EmailBody);
|
return (results[0] as Email?, results[1] as EmailBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _prefetchNextEmailBody(
|
||||||
|
EmailRepository repo,
|
||||||
|
Email header,
|
||||||
|
) async {
|
||||||
|
final prefs = ref.read(userPreferencesProvider).value;
|
||||||
|
final action =
|
||||||
|
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
|
||||||
|
if (action != AfterMailViewAction.nextMessage) return;
|
||||||
|
|
||||||
|
final threads =
|
||||||
|
await repo.observeThreads(header.accountId, header.mailboxPath).first;
|
||||||
|
final currentIndex = threads.indexWhere(
|
||||||
|
(t) => t.emailIds.contains(_emailId),
|
||||||
|
);
|
||||||
|
if (currentIndex < 0 || currentIndex + 1 >= threads.length) return;
|
||||||
|
|
||||||
|
final nextId = threads[currentIndex + 1].latestEmailId;
|
||||||
|
await repo.getEmailBody(nextId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final allAccountsProvider = StreamProvider<List<model.Account>>((ref) {
|
||||||
|
return ref.watch(accountRepositoryProvider).observeAccounts();
|
||||||
|
});
|
||||||
|
|
||||||
final accountByIdProvider =
|
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(
|
||||||
@@ -227,3 +263,22 @@ final accountConnectionStatusProvider =
|
|||||||
.read(connectionTestServiceProvider)
|
.read(connectionTestServiceProvider)
|
||||||
.testConnection(account, password);
|
.testConnection(account, password);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final userPreferencesRepositoryProvider = Provider<UserPreferencesRepository>((
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>((
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
|
||||||
|
});
|
||||||
|
|
||||||
|
final trustedImageSendersProvider =
|
||||||
|
StreamProvider.autoDispose<List<String>>((ref) {
|
||||||
|
return ref
|
||||||
|
.watch(userPreferencesRepositoryProvider)
|
||||||
|
.observeTrustedImageSenders();
|
||||||
|
});
|
||||||
|
|||||||
+32
-5
@@ -5,19 +5,30 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_riverpod/misc.dart' show Override;
|
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/services/notification_service.dart';
|
import 'package:sharedinbox/core/services/notification_service.dart';
|
||||||
import 'package:sharedinbox/core/sync/background_sync.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';
|
||||||
import 'package:sharedinbox/ui/screens/crash_screen.dart';
|
import 'package:sharedinbox/ui/screens/crash_screen.dart';
|
||||||
|
import 'package:stack_trace/stack_trace.dart' as stack_trace;
|
||||||
|
|
||||||
void main({List<Override> overrides = const []}) async {
|
void main({List<Override> overrides = const []}) {
|
||||||
unawaited(
|
unawaited(
|
||||||
runZonedGuarded(
|
runZonedGuarded(
|
||||||
() async {
|
() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Dart's async machinery propagates stack traces in chain format
|
||||||
|
// (with '===== asynchronous gap =====' separators). Flutter's
|
||||||
|
// StackFrame parser asserts on those lines, so strip them first.
|
||||||
|
FlutterError.demangleStackTrace = (StackTrace s) {
|
||||||
|
if (s is stack_trace.Chain) return s.toTrace().vmTrace;
|
||||||
|
if (s is stack_trace.Trace) return s.vmTrace;
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
// Catch errors during build (e.g. layout exceptions) and show CrashScreen.
|
// Catch errors during build (e.g. layout exceptions) and show CrashScreen.
|
||||||
ErrorWidget.builder = (details) => CrashScreen(
|
ErrorWidget.builder = (details) => CrashScreen(
|
||||||
exception: details.exception,
|
exception: details.exception,
|
||||||
@@ -39,19 +50,35 @@ void main({List<Override> overrides = const []}) async {
|
|||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
await initNotifications();
|
await initNotifications();
|
||||||
await registerBackgroundSync();
|
await registerBackgroundSync();
|
||||||
|
await _registerPrefetchTaskFromStoredPrefs();
|
||||||
}
|
}
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
|
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
(error, stack) {
|
// This handler runs in the parent zone — runApp cannot be called here.
|
||||||
// Catch unhandled async errors.
|
// Framework errors are already handled by FlutterError.onError above.
|
||||||
runApp(CrashScreen(exception: error, stackTrace: stack));
|
(error, stack) => FlutterError.reportError(
|
||||||
},
|
FlutterErrorDetails(exception: error, stack: stack),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads the stored prefetch preference and registers the WorkManager task
|
||||||
|
/// with the correct network constraint for it. Opens and immediately closes
|
||||||
|
/// a temporary DB connection; safe because initDatabasePath() has already run.
|
||||||
|
Future<void> _registerPrefetchTaskFromStoredPrefs() async {
|
||||||
|
final db = AppDatabase();
|
||||||
|
try {
|
||||||
|
final row = await db.select(db.userPreferences).getSingleOrNull();
|
||||||
|
final mode = PrefetchMode.fromString(row?.prefetchMode);
|
||||||
|
await registerBodyPrefetchTask(mode);
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class SharedInboxApp extends ConsumerStatefulWidget {
|
class SharedInboxApp extends ConsumerStatefulWidget {
|
||||||
const SharedInboxApp({super.key});
|
const SharedInboxApp({super.key});
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -8,7 +8,9 @@ import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
|
|||||||
import 'package:sharedinbox/ui/screens/account_send_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/bug_report_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/combined_inbox_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/compose_screen.dart';
|
import 'package:sharedinbox/ui/screens/compose_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/edit_account_screen.dart';
|
import 'package:sharedinbox/ui/screens/edit_account_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
|
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
|
||||||
@@ -19,15 +21,21 @@ import 'package:sharedinbox/ui/screens/sieve_script_edit_screen.dart';
|
|||||||
import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
|
import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
|
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/trusted_image_senders_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
|
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
||||||
|
|
||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
initialLocation: '/accounts',
|
initialLocation: '/inbox',
|
||||||
routes: [
|
routes: [
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
builder: (ctx, state, child) => UndoShell(child: child),
|
builder: (ctx, state, child) => UndoShell(child: child),
|
||||||
routes: [
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/inbox',
|
||||||
|
builder: (ctx, state) => const CombinedInboxScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/accounts',
|
path: '/accounts',
|
||||||
builder: (ctx, state) => const AccountListScreen(),
|
builder: (ctx, state) => const AccountListScreen(),
|
||||||
@@ -56,6 +64,16 @@ final router = GoRouter(
|
|||||||
path: 'about',
|
path: 'about',
|
||||||
builder: (ctx, state) => const AboutScreen(),
|
builder: (ctx, state) => const AboutScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'preferences',
|
||||||
|
builder: (ctx, state) => const UserPreferencesScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'trusted-senders',
|
||||||
|
builder: (ctx, state) => TrustedImageSendersScreen(
|
||||||
|
highlightedSender: state.extra as String?,
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':accountId/edit',
|
path: ':accountId/edit',
|
||||||
builder: (ctx, state) => EditAccountScreen(
|
builder: (ctx, state) => EditAccountScreen(
|
||||||
@@ -159,6 +177,12 @@ final router = GoRouter(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/bug-report',
|
||||||
|
builder: (ctx, state) => BugReportScreen(
|
||||||
|
emailId: state.uri.queryParameters['emailId'],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/utils/about_markdown.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class AboutScreen extends ConsumerStatefulWidget {
|
class AboutScreen extends ConsumerStatefulWidget {
|
||||||
@@ -19,57 +20,22 @@ class AboutScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||||
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
||||||
|
late final Future<String?> _deviceModelFuture;
|
||||||
late final Stream<List<Account>> _accountsStream;
|
late final Stream<List<Account>> _accountsStream;
|
||||||
|
String? _deviceModel;
|
||||||
static const _gitHash = String.fromEnvironment('GIT_HASH');
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
||||||
|
_deviceModelFuture = getDeviceModel();
|
||||||
|
unawaited(
|
||||||
|
_deviceModelFuture.then((model) {
|
||||||
|
if (mounted) setState(() => _deviceModel = model);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _buildMarkdown(
|
|
||||||
BuildContext context,
|
|
||||||
PackageInfo? pkg,
|
|
||||||
int imapCount,
|
|
||||||
int jmapCount,
|
|
||||||
) {
|
|
||||||
final size = MediaQuery.of(context).size;
|
|
||||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
|
||||||
final physW = (size.width * pixelRatio).toInt();
|
|
||||||
final physH = (size.height * pixelRatio).toInt();
|
|
||||||
final version =
|
|
||||||
pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
|
|
||||||
final versionDisplay = _gitHash.isNotEmpty
|
|
||||||
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
|
|
||||||
: version;
|
|
||||||
final osName = _capitalize(Platform.operatingSystem);
|
|
||||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
|
||||||
|
|
||||||
final gitCommitLine = _gitHash.isNotEmpty
|
|
||||||
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
|
||||||
: '';
|
|
||||||
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
|
||||||
'| Property | Value |\n'
|
|
||||||
'|----------|-------|\n'
|
|
||||||
'| App Version | $versionDisplay |\n'
|
|
||||||
'$gitCommitLine'
|
|
||||||
'| Platform | ${Platform.operatingSystem} |\n'
|
|
||||||
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
|
||||||
'| Resolution | ${physW}x$physH px'
|
|
||||||
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
|
||||||
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
|
||||||
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
|
||||||
'| Processors | ${Platform.numberOfProcessors} |\n'
|
|
||||||
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
|
||||||
'| IMAP Accounts | $imapCount |\n'
|
|
||||||
'| JMAP Accounts | $jmapCount |\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _capitalize(String s) =>
|
|
||||||
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
|
|
||||||
|
|
||||||
Future<void> _copyToClipboard(
|
Future<void> _copyToClipboard(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
int imapCount,
|
int imapCount,
|
||||||
@@ -79,10 +45,20 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
try {
|
try {
|
||||||
pkg = await _packageInfoFuture;
|
pkg = await _packageInfoFuture;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
String? deviceModel;
|
||||||
|
try {
|
||||||
|
deviceModel = await _deviceModelFuture;
|
||||||
|
} catch (_) {}
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
await Clipboard.setData(
|
await Clipboard.setData(
|
||||||
ClipboardData(
|
ClipboardData(
|
||||||
text: _buildMarkdown(context, pkg, imapCount, jmapCount),
|
text: buildAboutMarkdown(
|
||||||
|
context: context,
|
||||||
|
pkg: pkg,
|
||||||
|
imapCount: imapCount,
|
||||||
|
jmapCount: jmapCount,
|
||||||
|
deviceModel: deviceModel,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -95,6 +71,32 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _launchUrl(BuildContext context, Uri url) async {
|
||||||
|
try {
|
||||||
|
final launched = await launchUrl(
|
||||||
|
url,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
|
if (!launched && context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text('Could not open browser.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
content: Text('Error: $e'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _createIssue(
|
Future<void> _createIssue(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
int imapCount,
|
int imapCount,
|
||||||
@@ -104,16 +106,28 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
try {
|
try {
|
||||||
pkg = await _packageInfoFuture;
|
pkg = await _packageInfoFuture;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
String? deviceModel;
|
||||||
|
try {
|
||||||
|
deviceModel = await _deviceModelFuture;
|
||||||
|
} catch (_) {}
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
final body = Uri.encodeComponent(
|
final body = Uri.encodeComponent(
|
||||||
_buildMarkdown(context, pkg, imapCount, jmapCount),
|
buildAboutMarkdown(
|
||||||
|
context: context,
|
||||||
|
pkg: pkg,
|
||||||
|
imapCount: imapCount,
|
||||||
|
jmapCount: jmapCount,
|
||||||
|
deviceModel: deviceModel,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
final url = Uri.parse(
|
final url = Uri.parse(
|
||||||
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
final launched =
|
final launched = await launchUrl(
|
||||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
url,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
if (!launched && context.mounted) {
|
if (!launched && context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -157,21 +171,17 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
return Markdown(
|
return Markdown(
|
||||||
data: _buildMarkdown(
|
data: buildAboutMarkdown(
|
||||||
context,
|
context: context,
|
||||||
snapshot.data,
|
pkg: snapshot.data,
|
||||||
imapCount,
|
imapCount: imapCount,
|
||||||
jmapCount,
|
jmapCount: jmapCount,
|
||||||
|
deviceModel: _deviceModel,
|
||||||
),
|
),
|
||||||
selectable: true,
|
selectable: true,
|
||||||
onTapLink: (text, href, title) {
|
onTapLink: (text, href, title) {
|
||||||
if (href != null) {
|
if (href != null) {
|
||||||
unawaited(
|
unawaited(_launchUrl(context, Uri.parse(href)));
|
||||||
launchUrl(
|
|
||||||
Uri.parse(href),
|
|
||||||
mode: LaunchMode.externalApplication,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -188,22 +198,30 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.copy),
|
icon: const Icon(Icons.copy),
|
||||||
label: const Text('Copy to clipboard'),
|
label: const Text('Copy info'),
|
||||||
onPressed: () => unawaited(
|
onPressed: () => unawaited(
|
||||||
_copyToClipboard(context, imapCount, jmapCount),
|
_copyToClipboard(context, imapCount, jmapCount),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton.icon(
|
child: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.bug_report),
|
icon: const Icon(Icons.bug_report_outlined),
|
||||||
label: const Text('Create issue'),
|
label: const Text('Public issue'),
|
||||||
onPressed: () => unawaited(
|
onPressed: () => unawaited(
|
||||||
_createIssue(context, imapCount, jmapCount),
|
_createIssue(context, imapCount, jmapCount),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
icon: const Icon(Icons.feedback_outlined),
|
||||||
|
label: const Text('Report bug'),
|
||||||
|
onPressed: () => context.push('/bug-report'),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -66,6 +67,14 @@ class AccountListScreen extends ConsumerWidget {
|
|||||||
unawaited(context.push('/accounts/about'));
|
unawaited(context.push('/accounts/about'));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.settings),
|
||||||
|
title: const Text('Preferences'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context); // Close drawer
|
||||||
|
unawaited(context.push('/accounts/preferences'));
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -111,20 +120,80 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
final health = ref.watch(syncHealthProvider(account.id));
|
final health = ref.watch(syncHealthProvider(account.id));
|
||||||
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
|
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
|
||||||
|
|
||||||
return ListTile(
|
return Column(
|
||||||
leading: const Icon(Icons.account_circle),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
title: Text(account.displayName),
|
children: [
|
||||||
subtitle: Column(
|
ListTile(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
leading: const Icon(Icons.account_circle),
|
||||||
children: [
|
title: Text(account.displayName),
|
||||||
Text('${account.email}\n$typeLabel'),
|
subtitle: Text('${account.email}\n$typeLabel'),
|
||||||
const SizedBox(height: 4),
|
isThreeLine: true,
|
||||||
health.when(
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
status.when(
|
||||||
|
loading: () => const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
data: (_) =>
|
||||||
|
const Icon(Icons.check_circle, color: Colors.green),
|
||||||
|
error: (e, _) => Tooltip(
|
||||||
|
message: e.toString(),
|
||||||
|
child: const Icon(Icons.error_outline, color: Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuButton<_AccountAction>(
|
||||||
|
onSelected: (action) => _onAction(context, action),
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.syncLog,
|
||||||
|
child: Text('Sync log'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.verifySync,
|
||||||
|
child: Text('Verify sync health'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.forceSync,
|
||||||
|
child: Text('Force full sync'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.edit,
|
||||||
|
child: Text('Edit'),
|
||||||
|
),
|
||||||
|
if (_sieveSupported(account))
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.emailFiltersRemote,
|
||||||
|
child: Text('Server email filters'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.emailFiltersLocal,
|
||||||
|
child: Text('Local email filters'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.send,
|
||||||
|
child: Text('Send accounts'),
|
||||||
|
),
|
||||||
|
const PopupMenuDivider(),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.delete,
|
||||||
|
child: Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(72, 0, 16, 8),
|
||||||
|
child: health.when(
|
||||||
data: (h) {
|
data: (h) {
|
||||||
if (h == null) return const Text('Sync health: Not verified yet');
|
if (h == null) return const Text('Sync health: Not verified yet');
|
||||||
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
const Text('Sync health: '),
|
const Text('Sync health: '),
|
||||||
Icon(
|
Icon(
|
||||||
@@ -133,7 +202,13 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
color: h.isHealthy ? Colors.green : Colors.orange,
|
color: h.isHealthy ? Colors.green : Colors.orange,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'),
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
h.isHealthy
|
||||||
|
? 'Healthy'
|
||||||
|
: _formatDiscrepancies(h.discrepancySummary),
|
||||||
|
),
|
||||||
|
),
|
||||||
Text(' ($date)', style: const TextStyle(fontSize: 10)),
|
Text(' ($date)', style: const TextStyle(fontSize: 10)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -141,66 +216,8 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
loading: () => const Text('Sync health: checking...'),
|
loading: () => const Text('Sync health: checking...'),
|
||||||
error: (e, _) => Text('Sync health error: $e'),
|
error: (e, _) => Text('Sync health error: $e'),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
isThreeLine: true,
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
status.when(
|
|
||||||
loading: () => const SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
|
||||||
data: (_) => const Icon(Icons.check_circle, color: Colors.green),
|
|
||||||
error: (e, _) => Tooltip(
|
|
||||||
message: e.toString(),
|
|
||||||
child: const Icon(Icons.error_outline, color: Colors.red),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
PopupMenuButton<_AccountAction>(
|
|
||||||
onSelected: (action) => _onAction(context, action),
|
|
||||||
itemBuilder: (_) => [
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.syncLog,
|
|
||||||
child: Text('Sync log'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.verifySync,
|
|
||||||
child: Text('Verify sync health'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.forceSync,
|
|
||||||
child: Text('Force full sync'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.edit,
|
|
||||||
child: Text('Edit'),
|
|
||||||
),
|
|
||||||
if (_sieveSupported(account))
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.emailFiltersRemote,
|
|
||||||
child: Text('Server email filters'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.emailFiltersLocal,
|
|
||||||
child: Text('Local email filters'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.send,
|
|
||||||
child: Text('Send accounts'),
|
|
||||||
),
|
|
||||||
const PopupMenuDivider(),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.delete,
|
|
||||||
child: Text('Delete'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +310,30 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _formatDiscrepancies(String? summary) {
|
||||||
|
if (summary == null) return 'Discrepancies found';
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(summary) as Map<String, dynamic>;
|
||||||
|
var missingLocally = 0;
|
||||||
|
var missingOnServer = 0;
|
||||||
|
var flagMismatches = 0;
|
||||||
|
for (final v in decoded.values) {
|
||||||
|
final m = v as Map<String, dynamic>;
|
||||||
|
missingLocally += (m['missingLocally'] as int? ?? 0);
|
||||||
|
missingOnServer += (m['missingOnServer'] as int? ?? 0);
|
||||||
|
flagMismatches += (m['flagMismatches'] as int? ?? 0);
|
||||||
|
}
|
||||||
|
final parts = <String>[];
|
||||||
|
if (missingLocally > 0) parts.add('missing locally: $missingLocally');
|
||||||
|
if (missingOnServer > 0) parts.add('missing on server: $missingOnServer');
|
||||||
|
if (flagMismatches > 0) parts.add('flag mismatches: $flagMismatches');
|
||||||
|
if (parts.isEmpty) return 'Discrepancies found';
|
||||||
|
return 'Discrepancies found (${parts.join(', ')})';
|
||||||
|
} catch (_) {
|
||||||
|
return 'Discrepancies found';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _OnboardingView extends StatelessWidget {
|
class _OnboardingView extends StatelessWidget {
|
||||||
const _OnboardingView();
|
const _OnboardingView();
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,15 @@ enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
|
|||||||
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||||
_Step _step = _Step.generatingKey;
|
_Step _step = _Step.generatingKey;
|
||||||
ShareKeyMaterial? _keyMaterial;
|
ShareKeyMaterial? _keyMaterial;
|
||||||
|
DateTime? _keyExpiresAt;
|
||||||
String? _pubKeyQr;
|
String? _pubKeyQr;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
bool _scannerActive = false;
|
bool _scannerActive = false;
|
||||||
|
|
||||||
MobileScannerController? _scannerController;
|
MobileScannerController? _scannerController;
|
||||||
|
// True when the scanner plugin fails to initialise at runtime (e.g.
|
||||||
|
// MissingPluginException on some Android builds).
|
||||||
|
bool _scannerFailed = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -61,6 +65,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_keyMaterial = material;
|
_keyMaterial = material;
|
||||||
|
_keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
|
||||||
_pubKeyQr = qr;
|
_pubKeyQr = qr;
|
||||||
_step = _Step.showingPubKey;
|
_step = _Step.showingPubKey;
|
||||||
});
|
});
|
||||||
@@ -76,8 +81,37 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_step = _Step.scanning;
|
_step = _Step.scanning;
|
||||||
_scannerActive = true;
|
_scannerActive = true;
|
||||||
_scannerController = MobileScannerController();
|
|
||||||
});
|
});
|
||||||
|
if (_cameraScanSupported()) {
|
||||||
|
unawaited(_initScanner());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-flight: probe the scanner's permission-state method to verify the
|
||||||
|
// plugin is registered. MissingPluginException is thrown on Android builds
|
||||||
|
// where the plugin is not linked (issue #204). All other exceptions mean
|
||||||
|
// the plugin exists but something else failed — the MobileScanner widget
|
||||||
|
// will surface those via its own error builder.
|
||||||
|
Future<void> _initScanner() async {
|
||||||
|
bool available = false;
|
||||||
|
try {
|
||||||
|
await const MethodChannel(
|
||||||
|
'dev.steenbakker.mobile_scanner/scanner/method',
|
||||||
|
).invokeMethod<int>('state');
|
||||||
|
available = true;
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Plugin not registered on this device; text fallback will be shown.
|
||||||
|
} catch (_) {
|
||||||
|
// Plugin registered but state check failed; let the scanner widget
|
||||||
|
// handle it via its errorBuilder.
|
||||||
|
available = true;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
if (available) {
|
||||||
|
setState(() => _scannerController = MobileScannerController());
|
||||||
|
} else {
|
||||||
|
setState(() => _scannerFailed = true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onScanned(String rawValue) async {
|
Future<void> _onScanned(String rawValue) async {
|
||||||
@@ -185,11 +219,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
_Step.done => const Center(
|
_Step.done => const Center(
|
||||||
child: Icon(
|
child: Icon(Icons.check_circle, size: 64, color: Colors.green),
|
||||||
Icons.check_circle,
|
|
||||||
size: 64,
|
|
||||||
color: Colors.green,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
_Step.error => Center(
|
_Step.error => Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -244,7 +274,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const _ExpiryHint(),
|
_ExpiryHint(expiresAt: _keyExpiresAt!),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
if (_errorMessage != null) ...[
|
if (_errorMessage != null) ...[
|
||||||
Text(
|
Text(
|
||||||
@@ -266,11 +296,14 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildScannerView(BuildContext context) {
|
Widget _buildScannerView(BuildContext context) {
|
||||||
// On platforms where the camera scanner is not available (Linux desktop),
|
// Fall back to text input when the platform has no camera support or when
|
||||||
// fall back to a text-input field.
|
// the scanner plugin fails to initialise at runtime (MissingPluginException).
|
||||||
if (!_cameraScanSupported()) {
|
if (!_cameraScanSupported() || _scannerFailed) {
|
||||||
return _buildTextFallbackView(context);
|
return _buildTextFallbackView(context);
|
||||||
}
|
}
|
||||||
|
if (_scannerController == null) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -371,8 +404,37 @@ bool _cameraScanSupported() =>
|
|||||||
Platform.isMacOS ||
|
Platform.isMacOS ||
|
||||||
Platform.isWindows;
|
Platform.isWindows;
|
||||||
|
|
||||||
class _ExpiryHint extends StatelessWidget {
|
class _ExpiryHint extends StatefulWidget {
|
||||||
const _ExpiryHint();
|
const _ExpiryHint({required this.expiresAt});
|
||||||
|
|
||||||
|
final DateTime expiresAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ExpiryHint> createState() => _ExpiryHintState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExpiryHintState extends State<_ExpiryHint> {
|
||||||
|
late Timer _timer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatRemaining() {
|
||||||
|
final remaining = widget.expiresAt.difference(DateTime.now().toUtc());
|
||||||
|
if (remaining.isNegative) return 'expired';
|
||||||
|
final minutes = remaining.inMinutes;
|
||||||
|
final seconds = remaining.inSeconds % 60;
|
||||||
|
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -382,7 +444,7 @@ class _ExpiryHint extends StatelessWidget {
|
|||||||
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
|
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'This key expires in 20 minutes',
|
'This key expires in ${_formatRemaining()}',
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -45,12 +45,42 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
|||||||
bool _scannerActive = true;
|
bool _scannerActive = true;
|
||||||
|
|
||||||
MobileScannerController? _scannerController;
|
MobileScannerController? _scannerController;
|
||||||
|
// True when the scanner plugin fails to initialise at runtime (e.g.
|
||||||
|
// MissingPluginException on some Android builds).
|
||||||
|
bool _scannerFailed = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (_cameraScanSupported()) {
|
if (_cameraScanSupported()) {
|
||||||
_scannerController = MobileScannerController();
|
unawaited(_initScanner());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-flight: probe the scanner's permission-state method to verify the
|
||||||
|
// plugin is registered. MissingPluginException is thrown on Android builds
|
||||||
|
// where the plugin is not linked (issue #204). All other exceptions mean
|
||||||
|
// the plugin exists but something else failed — the MobileScanner widget
|
||||||
|
// will surface those via its own error builder.
|
||||||
|
Future<void> _initScanner() async {
|
||||||
|
bool available = false;
|
||||||
|
try {
|
||||||
|
await const MethodChannel(
|
||||||
|
'dev.steenbakker.mobile_scanner/scanner/method',
|
||||||
|
).invokeMethod<int>('state');
|
||||||
|
available = true;
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Plugin not registered on this device; text fallback will be shown.
|
||||||
|
} catch (_) {
|
||||||
|
// Plugin registered but state check failed; let the scanner widget
|
||||||
|
// handle it via its errorBuilder.
|
||||||
|
available = true;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
if (available) {
|
||||||
|
setState(() => _scannerController = MobileScannerController());
|
||||||
|
} else {
|
||||||
|
setState(() => _scannerFailed = true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,10 +158,7 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
|||||||
for (final account in selected) {
|
for (final account in selected) {
|
||||||
final password = await repo.getPassword(account.id);
|
final password = await repo.getPassword(account.id);
|
||||||
payloads.add(
|
payloads.add(
|
||||||
AccountPayload(
|
AccountPayload(accountJson: account.toJson(), password: password),
|
||||||
accountJson: account.toJson(),
|
|
||||||
password: password,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,9 +205,12 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildScanStep(BuildContext context) {
|
Widget _buildScanStep(BuildContext context) {
|
||||||
if (!_cameraScanSupported()) {
|
if (!_cameraScanSupported() || _scannerFailed) {
|
||||||
return _buildTextFallbackView(context);
|
return _buildTextFallbackView(context);
|
||||||
}
|
}
|
||||||
|
if (_scannerController == null) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -328,9 +358,7 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
|||||||
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
|
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text(
|
content: Text('Encrypted code copied to clipboard'),
|
||||||
'Encrypted code copied to clipboard',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,635 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/utils/about_markdown.dart';
|
||||||
|
|
||||||
|
const _bugReportApiUrl = String.fromEnvironment(
|
||||||
|
'BUG_REPORT_API_URL',
|
||||||
|
defaultValue: 'https://sharedinbox.de/api/v1/bug-reports',
|
||||||
|
);
|
||||||
|
|
||||||
|
class BugReportScreen extends ConsumerStatefulWidget {
|
||||||
|
const BugReportScreen({super.key, this.emailId});
|
||||||
|
|
||||||
|
final String? emailId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<BugReportScreen> createState() => _BugReportScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BugReportScreenState extends ConsumerState<BugReportScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _descriptionController = TextEditingController();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
|
||||||
|
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
||||||
|
late final Future<String?> _deviceModelFuture = getDeviceModel();
|
||||||
|
|
||||||
|
final List<PlatformFile> _attachments = [];
|
||||||
|
bool _includeEmail = false;
|
||||||
|
bool _includeSyncLog = false;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
Email? _attachedEmail;
|
||||||
|
List<Account> _accounts = [];
|
||||||
|
String? _selectedAccountId;
|
||||||
|
String? _deviceModel;
|
||||||
|
bool _loadingEmail = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
unawaited(_loadInitialData());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_descriptionController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadInitialData() async {
|
||||||
|
setState(() => _loadingEmail = true);
|
||||||
|
try {
|
||||||
|
_deviceModel = await _deviceModelFuture;
|
||||||
|
_accounts =
|
||||||
|
await ref.read(accountRepositoryProvider).observeAccounts().first;
|
||||||
|
|
||||||
|
if (widget.emailId != null) {
|
||||||
|
final email =
|
||||||
|
await ref.read(emailRepositoryProvider).getEmail(widget.emailId!);
|
||||||
|
if (mounted && email != null) {
|
||||||
|
_attachedEmail = email;
|
||||||
|
_selectedAccountId = email.accountId;
|
||||||
|
final fromStr =
|
||||||
|
email.from.isNotEmpty ? email.from.first.toString() : 'unknown';
|
||||||
|
final subjectStr = email.subject ?? '(no subject)';
|
||||||
|
_descriptionController.text =
|
||||||
|
'Problem with email from $fromStr: "$subjectStr"\n\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedAccountId == null && _accounts.isNotEmpty) {
|
||||||
|
_selectedAccountId = _accounts.first.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedAccountId != null) {
|
||||||
|
final matching =
|
||||||
|
_accounts.where((a) => a.id == _selectedAccountId).firstOrNull;
|
||||||
|
if (matching != null) {
|
||||||
|
_emailController.text = matching.email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _loadingEmail = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int get _totalAttachmentSize {
|
||||||
|
return _attachments.fold(0, (sum, f) => sum + f.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatSize(int bytes) {
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickAttachments() async {
|
||||||
|
try {
|
||||||
|
final result = await FilePicker.pickFiles();
|
||||||
|
if (result == null) return;
|
||||||
|
final newFiles =
|
||||||
|
result.files.where((PlatformFile f) => f.path != null).toList();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_attachments.addAll(newFiles);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to pick files: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeAttachment(int index) {
|
||||||
|
setState(() {
|
||||||
|
_attachments.removeAt(index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _serializeSyncLogs(List<SyncLogEntry> entries) {
|
||||||
|
final sb = StringBuffer();
|
||||||
|
for (final entry in entries.take(50)) {
|
||||||
|
sb.writeln('ID: ${entry.id}');
|
||||||
|
sb.writeln('Started: ${entry.startedAt.toIso8601String()}');
|
||||||
|
sb.writeln('Finished: ${entry.finishedAt.toIso8601String()}');
|
||||||
|
sb.writeln('Result: ${entry.result}');
|
||||||
|
if (entry.errorMessage != null) {
|
||||||
|
sb.writeln('Error: ${entry.errorMessage}');
|
||||||
|
}
|
||||||
|
if (entry.stackTrace != null) {
|
||||||
|
sb.writeln('StackTrace:\n${entry.stackTrace}');
|
||||||
|
}
|
||||||
|
sb.writeln('Protocol: ${entry.protocol}');
|
||||||
|
sb.writeln(
|
||||||
|
'Fetched: ${entry.emailsFetched}, Skipped: ${entry.emailsSkipped}',
|
||||||
|
);
|
||||||
|
if (entry.protocolLog != null) {
|
||||||
|
sb.writeln('Protocol Log:\n${entry.protocolLog}');
|
||||||
|
}
|
||||||
|
sb.writeln('---');
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitReport() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
final totalSize = _totalAttachmentSize;
|
||||||
|
if (totalSize > 20 * 1024 * 1024) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Total attachments size exceeds the 20 MB limit. Please remove some files.',
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final client = ref.read(httpClientProvider);
|
||||||
|
final uri = Uri.parse(_bugReportApiUrl);
|
||||||
|
final request = http.MultipartRequest('POST', uri);
|
||||||
|
|
||||||
|
// Description
|
||||||
|
request.fields['description'] = _descriptionController.text;
|
||||||
|
|
||||||
|
// Email Data if from email view
|
||||||
|
if (_attachedEmail != null) {
|
||||||
|
final emailMap = {
|
||||||
|
'id': _attachedEmail!.id,
|
||||||
|
'subject': _attachedEmail!.subject,
|
||||||
|
'from': _attachedEmail!.from.map((e) => e.toString()).toList(),
|
||||||
|
'date': _attachedEmail!.sentAt?.toIso8601String() ??
|
||||||
|
_attachedEmail!.receivedAt.toIso8601String(),
|
||||||
|
'preview': _attachedEmail!.preview,
|
||||||
|
};
|
||||||
|
request.fields['email_data'] = jsonEncode(emailMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contact Email
|
||||||
|
if (_includeEmail) {
|
||||||
|
request.fields['email'] = _emailController.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// About Info
|
||||||
|
PackageInfo? pkg;
|
||||||
|
try {
|
||||||
|
pkg = await _packageInfoFuture;
|
||||||
|
} catch (_) {}
|
||||||
|
final imapCount =
|
||||||
|
_accounts.where((a) => a.type == AccountType.imap).length;
|
||||||
|
final jmapCount =
|
||||||
|
_accounts.where((a) => a.type == AccountType.jmap).length;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
final aboutInfo = buildAboutMarkdown(
|
||||||
|
context: context,
|
||||||
|
pkg: pkg,
|
||||||
|
imapCount: imapCount,
|
||||||
|
jmapCount: jmapCount,
|
||||||
|
deviceModel: _deviceModel,
|
||||||
|
);
|
||||||
|
request.fields['about_info'] = aboutInfo;
|
||||||
|
|
||||||
|
// Sync Log
|
||||||
|
if (_includeSyncLog && _selectedAccountId != null) {
|
||||||
|
final syncLogs = await ref
|
||||||
|
.read(syncLogRepositoryProvider)
|
||||||
|
.observeSyncLogs(_selectedAccountId!)
|
||||||
|
.first;
|
||||||
|
request.fields['sync_log'] = _serializeSyncLogs(syncLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
for (final file in _attachments) {
|
||||||
|
final multipartFile = await http.MultipartFile.fromPath(
|
||||||
|
'attachments[]',
|
||||||
|
file.path!,
|
||||||
|
filename: file.name,
|
||||||
|
);
|
||||||
|
request.files.add(multipartFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
final streamedResponse = await client.send(request);
|
||||||
|
final response = await http.Response.fromStream(streamedResponse);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (response.statusCode == 201) {
|
||||||
|
final resData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final reportId = resData['id'] as String;
|
||||||
|
_showSuccessDialog(reportId);
|
||||||
|
} else if (response.statusCode == 429) {
|
||||||
|
final retryAfter = response.headers['retry-after'] ?? '6';
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Rate limited. Please retry in $retryAfter seconds.'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
String errorMsg =
|
||||||
|
'Failed to submit report. Server returned status: ${response.statusCode}';
|
||||||
|
try {
|
||||||
|
final resData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
if (resData['error'] != null) {
|
||||||
|
errorMsg = resData['error'] as String;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(errorMsg),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('An error occurred: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSuccessDialog(String reportId) {
|
||||||
|
unawaited(
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Bug Report Submitted'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: ListBody(
|
||||||
|
children: [
|
||||||
|
const Text('Thank you for helping us improve SharedInbox!'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Your Report ID is:\n$reportId',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Your report is handled confidentially and has not been posted to the public issue tracker.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(); // Dismiss dialog
|
||||||
|
context.pop(); // Go back to previous screen
|
||||||
|
},
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final totalSize = _totalAttachmentSize;
|
||||||
|
const sizeLimit = 20 * 1024 * 1024;
|
||||||
|
final approachingLimit = totalSize > 15 * 1024 * 1024;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Report a Bug'),
|
||||||
|
),
|
||||||
|
body: _loadingEmail
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
children: [
|
||||||
|
// Confidentiality info card
|
||||||
|
Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: theme.colorScheme.secondaryContainer
|
||||||
|
.withValues(alpha: 0.4),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: BorderSide(
|
||||||
|
color:
|
||||||
|
theme.colorScheme.secondary.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.lock_outline,
|
||||||
|
color: theme.colorScheme.secondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Your report is handled confidentially and will not be posted to the public issue tracker.',
|
||||||
|
style: TextStyle(height: 1.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Description Text Field
|
||||||
|
TextFormField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
autofocus: true,
|
||||||
|
maxLines: 8,
|
||||||
|
minLines: 4,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'What went wrong?',
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
helperText:
|
||||||
|
'Please describe the problem and how to reproduce it.',
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Please enter a description.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Email info chip if email is attached
|
||||||
|
if (_attachedEmail != null) ...[
|
||||||
|
Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12.0,
|
||||||
|
vertical: 8.0,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.email_outlined,
|
||||||
|
size: 20,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'The current email metadata will be attached automatically.',
|
||||||
|
style: TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Attachments Section
|
||||||
|
Text(
|
||||||
|
'Attachments',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _submitting ? null : _pickAttachments,
|
||||||
|
icon: const Icon(Icons.add_a_photo_outlined),
|
||||||
|
label: const Text('Add screenshots'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Screenshots help us understand the problem faster.',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_attachments.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: _attachments.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final file = _attachments[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8.0),
|
||||||
|
child: InputChip(
|
||||||
|
label: Text(
|
||||||
|
'${file.name} (${_formatSize(file.size)})',
|
||||||
|
),
|
||||||
|
onDeleted: _submitting
|
||||||
|
? null
|
||||||
|
: () => _removeAttachment(index),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Total Attachment Size: ${_formatSize(totalSize)} / ${_formatSize(sizeLimit)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: totalSize > sizeLimit
|
||||||
|
? Colors.red
|
||||||
|
: approachingLimit
|
||||||
|
? Colors.orange
|
||||||
|
: Colors.grey,
|
||||||
|
fontWeight: approachingLimit
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (totalSize > sizeLimit) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Email opt-in
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Include my email for follow-up'),
|
||||||
|
value: _includeEmail,
|
||||||
|
onChanged: _submitting
|
||||||
|
? null
|
||||||
|
: (val) {
|
||||||
|
setState(() => _includeEmail = val ?? false);
|
||||||
|
},
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
if (_includeEmail) ...[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16.0),
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Contact Email Address',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (_includeEmail &&
|
||||||
|
(value == null || value.trim().isEmpty)) {
|
||||||
|
return 'Please enter an email address.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Sync log opt-in
|
||||||
|
if (_selectedAccountId != null) ...[
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Include recent sync log'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Helps diagnose connection and protocol issues.',
|
||||||
|
),
|
||||||
|
value: _includeSyncLog,
|
||||||
|
onChanged: _submitting
|
||||||
|
? null
|
||||||
|
: (val) {
|
||||||
|
setState(() => _includeSyncLog = val ?? false);
|
||||||
|
},
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
|
||||||
|
// System info section
|
||||||
|
FutureBuilder<PackageInfo>(
|
||||||
|
future: _packageInfoFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final imapCount = _accounts
|
||||||
|
.where((a) => a.type == AccountType.imap)
|
||||||
|
.length;
|
||||||
|
final jmapCount = _accounts
|
||||||
|
.where((a) => a.type == AccountType.jmap)
|
||||||
|
.length;
|
||||||
|
final aboutMd = buildAboutMarkdown(
|
||||||
|
context: context,
|
||||||
|
pkg: snapshot.data,
|
||||||
|
imapCount: imapCount,
|
||||||
|
jmapCount: jmapCount,
|
||||||
|
deviceModel: _deviceModel,
|
||||||
|
);
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: BorderSide(
|
||||||
|
color: theme.dividerColor.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: ExpansionTile(
|
||||||
|
title: const Text(
|
||||||
|
'System Info (attached automatically)',
|
||||||
|
style: TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: MarkdownBody(data: aboutMd),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Submit Button
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _submitting ? null : _submitReport,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||||
|
child: _submitting
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text(
|
||||||
|
'Send Bug Report',
|
||||||
|
style: TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart' show rootBundle;
|
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
@@ -13,7 +12,9 @@ class ChangeLogScreen extends StatelessWidget {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('ChangeLog')),
|
appBar: AppBar(title: const Text('ChangeLog')),
|
||||||
body: FutureBuilder<String>(
|
body: FutureBuilder<String>(
|
||||||
future: rootBundle.loadString('assets/changelog.txt'),
|
future: DefaultAssetBundle.of(
|
||||||
|
context,
|
||||||
|
).loadString('assets/changelog.txt'),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/email_thread_tile.dart';
|
||||||
|
|
||||||
|
class CombinedInboxScreen extends ConsumerStatefulWidget {
|
||||||
|
const CombinedInboxScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CombinedInboxScreen> createState() =>
|
||||||
|
_CombinedInboxScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
||||||
|
static const _pageSize = 50;
|
||||||
|
int _limit = _pageSize;
|
||||||
|
|
||||||
|
// Thread-level selection (key = threadId).
|
||||||
|
final Set<String> _selectedThreadIds = {};
|
||||||
|
// Last-emitted thread list, used to resolve emailIds for batch operations.
|
||||||
|
List<EmailThread> _currentThreads = [];
|
||||||
|
|
||||||
|
bool get _selecting => _selectedThreadIds.isNotEmpty;
|
||||||
|
|
||||||
|
void _toggleThreadSelection(EmailThread thread) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedThreadIds.contains(thread.threadId)) {
|
||||||
|
_selectedThreadIds.remove(thread.threadId);
|
||||||
|
} else {
|
||||||
|
_selectedThreadIds.add(thread.threadId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSelection() => setState(() => _selectedThreadIds.clear());
|
||||||
|
|
||||||
|
void _selectAll() {
|
||||||
|
setState(
|
||||||
|
() => _selectedThreadIds.addAll(_currentThreads.map((t) => t.threadId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final accountsAsync = ref.watch(allAccountsProvider);
|
||||||
|
|
||||||
|
return accountsAsync.when(
|
||||||
|
loading: () => const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
error: (e, _) => Scaffold(
|
||||||
|
body: Center(child: Text('Error: $e')),
|
||||||
|
),
|
||||||
|
data: (accounts) {
|
||||||
|
if (accounts.isEmpty) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (context.mounted) context.go('/accounts');
|
||||||
|
});
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final accountNames = {
|
||||||
|
for (final a in accounts) a.id: a.displayName,
|
||||||
|
};
|
||||||
|
final showAccount = accounts.length > 1;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: _buildAppBar(accounts),
|
||||||
|
drawer: _selecting ? null : _buildDrawer(context, accounts),
|
||||||
|
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
|
||||||
|
body: _buildBody(accountNames, showAccount),
|
||||||
|
floatingActionButton: _selecting
|
||||||
|
? null
|
||||||
|
: FloatingActionButton(
|
||||||
|
onPressed: () => context.push('/compose'),
|
||||||
|
child: const Icon(Icons.edit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PreferredSizeWidget _buildAppBar(List<Account> accounts) {
|
||||||
|
if (_selecting) {
|
||||||
|
return AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: _clearSelection,
|
||||||
|
),
|
||||||
|
title: Text('${_selectedThreadIds.length} selected'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.select_all),
|
||||||
|
tooltip: 'Select all',
|
||||||
|
onPressed: _selectAll,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppBar(
|
||||||
|
title: const Text('Combined Inbox'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
|
tooltip: 'Search',
|
||||||
|
onPressed: () => context.push('/search'),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.sync),
|
||||||
|
tooltip: 'Sync all',
|
||||||
|
onPressed: () {
|
||||||
|
for (final a in accounts) {
|
||||||
|
ref.read(syncManagerProvider).syncNow(a.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _selectionBottomBar() {
|
||||||
|
return BottomAppBar(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.archive),
|
||||||
|
tooltip: 'Archive',
|
||||||
|
onPressed: _batchArchive,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
tooltip: 'Delete',
|
||||||
|
onPressed: _batchDelete,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDrawer(BuildContext context, List<Account> accounts) {
|
||||||
|
return Drawer(
|
||||||
|
child: ListView(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
children: [
|
||||||
|
const DrawerHeader(
|
||||||
|
decoration: BoxDecoration(color: Colors.blueGrey),
|
||||||
|
child: Text(
|
||||||
|
'sharedinbox.de',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.manage_accounts),
|
||||||
|
title: const Text('Accounts'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.go('/accounts');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.person_add),
|
||||||
|
title: const Text('Add account'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
unawaited(context.push('/accounts/add'));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
for (final account in accounts)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.inbox),
|
||||||
|
title: Text(account.displayName),
|
||||||
|
subtitle: Text(account.email),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
unawaited(context.push('/accounts/${account.id}/mailboxes'));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.settings),
|
||||||
|
title: const Text('Preferences'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
unawaited(context.push('/accounts/preferences'));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.history),
|
||||||
|
title: const Text('Undo Log'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
unawaited(context.push('/accounts/undo-log'));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.info_outline),
|
||||||
|
title: const Text('About'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
unawaited(context.push('/accounts/about'));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(Map<String, String> accountNames, bool showAccount) {
|
||||||
|
final emailRepo = ref.watch(emailRepositoryProvider);
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
final accounts = ref.read(allAccountsProvider).value ?? [];
|
||||||
|
for (final a in accounts) {
|
||||||
|
ref.read(syncManagerProvider).syncNow(a.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: StreamBuilder<List<EmailThread>>(
|
||||||
|
stream: emailRepo.observeAllInboxThreads(limit: _limit),
|
||||||
|
builder: (ctx, snap) {
|
||||||
|
if (!snap.hasData) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
final threads = snap.data!;
|
||||||
|
_currentThreads = threads;
|
||||||
|
if (threads.isEmpty) {
|
||||||
|
return ListView(
|
||||||
|
children: const [
|
||||||
|
SizedBox(
|
||||||
|
height: 300,
|
||||||
|
child: Center(child: Text('No emails')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _buildThreadList(threads, accountNames, showAccount);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildThreadList(
|
||||||
|
List<EmailThread> threads,
|
||||||
|
Map<String, String> accountNames,
|
||||||
|
bool showAccount,
|
||||||
|
) {
|
||||||
|
final hasMore = threads.length == _limit;
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: threads.length + (hasMore ? 1 : 0),
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
if (i == threads.length) {
|
||||||
|
return TextButton(
|
||||||
|
onPressed: () => setState(() => _limit += _pageSize),
|
||||||
|
child: const Text('Load more'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final t = threads[i];
|
||||||
|
return EmailThreadTile(
|
||||||
|
thread: t,
|
||||||
|
isSelected: _selectedThreadIds.contains(t.threadId),
|
||||||
|
isSelecting: _selecting,
|
||||||
|
showAccount: showAccount,
|
||||||
|
accountName: accountNames[t.accountId],
|
||||||
|
onTap: _selecting
|
||||||
|
? () => _toggleThreadSelection(t)
|
||||||
|
: t.messageCount > 1
|
||||||
|
? () => context.push(
|
||||||
|
'/accounts/${t.accountId}/mailboxes'
|
||||||
|
'/${Uri.encodeComponent(t.mailboxPath)}'
|
||||||
|
'/threads/${Uri.encodeComponent(t.threadId)}',
|
||||||
|
)
|
||||||
|
: () => context.push(
|
||||||
|
'/accounts/${t.accountId}/mailboxes'
|
||||||
|
'/${Uri.encodeComponent(t.mailboxPath)}'
|
||||||
|
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
||||||
|
),
|
||||||
|
onLongPress: () => _toggleThreadSelection(t),
|
||||||
|
onDismissed: (direction) => _onSwipeDismissed(t, direction),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSwipeDismissed(
|
||||||
|
EmailThread t,
|
||||||
|
DismissDirection direction,
|
||||||
|
) async {
|
||||||
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
|
||||||
|
final originalEmails = (await Future.wait(
|
||||||
|
t.emailIds.map((id) => repo.getEmail(id)),
|
||||||
|
))
|
||||||
|
.whereType<Email>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (direction == DismissDirection.startToEnd) {
|
||||||
|
final archive = await ref
|
||||||
|
.read(mailboxRepositoryProvider)
|
||||||
|
.findMailboxByRole(t.accountId, 'archive');
|
||||||
|
if (!mounted || archive == null) return;
|
||||||
|
|
||||||
|
for (final id in t.emailIds) {
|
||||||
|
await repo.moveEmail(id, archive.path);
|
||||||
|
}
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: t.accountId,
|
||||||
|
type: UndoType.move,
|
||||||
|
emailIds: t.emailIds,
|
||||||
|
sourceMailboxPath: t.mailboxPath,
|
||||||
|
destinationMailboxPath: archive.path,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? lastDestPath;
|
||||||
|
for (final id in t.emailIds) {
|
||||||
|
lastDestPath = await repo.deleteEmail(id);
|
||||||
|
}
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: t.accountId,
|
||||||
|
type: UndoType.delete,
|
||||||
|
emailIds: t.emailIds,
|
||||||
|
sourceMailboxPath: t.mailboxPath,
|
||||||
|
destinationMailboxPath: lastDestPath,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _batchArchive() async {
|
||||||
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||||
|
|
||||||
|
// Group selected threads by accountId so we look up each account's archive once.
|
||||||
|
final byAccount = <String, List<EmailThread>>{};
|
||||||
|
for (final t in _currentThreads) {
|
||||||
|
if (!_selectedThreadIds.contains(t.threadId)) continue;
|
||||||
|
(byAccount[t.accountId] ??= []).add(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearSelection();
|
||||||
|
|
||||||
|
for (final entry in byAccount.entries) {
|
||||||
|
final accountId = entry.key;
|
||||||
|
final threads = entry.value;
|
||||||
|
final archive = await mailboxRepo.findMailboxByRole(accountId, 'archive');
|
||||||
|
if (!mounted || archive == null) continue;
|
||||||
|
|
||||||
|
for (final t in threads) {
|
||||||
|
final originalEmails = (await Future.wait(
|
||||||
|
t.emailIds.map((id) => repo.getEmail(id)),
|
||||||
|
))
|
||||||
|
.whereType<Email>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
for (final id in t.emailIds) {
|
||||||
|
await repo.moveEmail(id, archive.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: accountId,
|
||||||
|
type: UndoType.move,
|
||||||
|
emailIds: t.emailIds,
|
||||||
|
sourceMailboxPath: t.mailboxPath,
|
||||||
|
destinationMailboxPath: archive.path,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _batchDelete() async {
|
||||||
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
|
||||||
|
final selectedThreads = _currentThreads
|
||||||
|
.where((t) => _selectedThreadIds.contains(t.threadId))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
_clearSelection();
|
||||||
|
|
||||||
|
for (final t in selectedThreads) {
|
||||||
|
final originalEmails = (await Future.wait(
|
||||||
|
t.emailIds.map((id) => repo.getEmail(id)),
|
||||||
|
))
|
||||||
|
.whereType<Email>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
String? lastDestPath;
|
||||||
|
for (final id in t.emailIds) {
|
||||||
|
lastDestPath = await repo.deleteEmail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: t.accountId,
|
||||||
|
type: UndoType.delete,
|
||||||
|
emailIds: t.emailIds,
|
||||||
|
sourceMailboxPath: t.mailboxPath,
|
||||||
|
destinationMailboxPath: lastDestPath,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -194,9 +194,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
await OpenFilex.open(path);
|
await OpenFilex.open(path);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
|
||||||
).showSnackBar(
|
|
||||||
SnackBar(
|
SnackBar(
|
||||||
duration: const Duration(seconds: 5),
|
duration: const Duration(seconds: 5),
|
||||||
content: Text('Failed to open file: $e'),
|
content: Text('Failed to open file: $e'),
|
||||||
@@ -213,9 +211,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
|
|
||||||
Future<void> _send() async {
|
Future<void> _send() async {
|
||||||
if (_accountId == null) {
|
if (_accountId == null) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
|
||||||
).showSnackBar(
|
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
duration: Duration(seconds: 5),
|
duration: Duration(seconds: 5),
|
||||||
content: Text('Select an account first'),
|
content: Text('Select an account first'),
|
||||||
@@ -255,9 +251,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
if (mounted) context.pop();
|
if (mounted) context.pop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
|
||||||
).showSnackBar(
|
|
||||||
SnackBar(
|
SnackBar(
|
||||||
duration: const Duration(seconds: 5),
|
duration: const Duration(seconds: 5),
|
||||||
content: Text('Send failed: $e'),
|
content: Text('Send failed: $e'),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
@@ -10,27 +11,45 @@ class CrashScreen extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.exception,
|
required this.exception,
|
||||||
required this.stackTrace,
|
required this.stackTrace,
|
||||||
|
this.gitHash = const String.fromEnvironment('GIT_HASH'),
|
||||||
});
|
});
|
||||||
|
|
||||||
final Object exception;
|
final Object exception;
|
||||||
final StackTrace? stackTrace;
|
final StackTrace? stackTrace;
|
||||||
|
final String gitHash;
|
||||||
|
|
||||||
static const _gitHash = String.fromEnvironment('GIT_HASH');
|
String get _buildMode {
|
||||||
|
if (kDebugMode) return 'debug';
|
||||||
|
if (kProfileMode) return 'profile';
|
||||||
|
return 'release';
|
||||||
|
}
|
||||||
|
|
||||||
Future<String> _buildReport() async {
|
Future<String> _fetchVersion() async {
|
||||||
String version = 'unknown';
|
|
||||||
try {
|
try {
|
||||||
final info = await PackageInfo.fromPlatform();
|
final info = await PackageInfo.fromPlatform();
|
||||||
version = '${info.version}+${info.buildNumber}';
|
return '${info.version}+${info.buildNumber}';
|
||||||
} catch (_) {}
|
} catch (_) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _buildReport() async {
|
||||||
|
final version = await _fetchVersion();
|
||||||
final platform =
|
final platform =
|
||||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||||
final gitLine = _gitHash.isNotEmpty
|
final versionDisplay = gitHash.isNotEmpty
|
||||||
? 'Git Commit: [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)\n'
|
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)'
|
||||||
|
: version;
|
||||||
|
final gitLine = gitHash.isNotEmpty
|
||||||
|
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
|
||||||
: '';
|
: '';
|
||||||
return 'App Version: $version\n'
|
final timestamp = DateTime.now().toUtc().toIso8601String();
|
||||||
|
return 'App Version: $versionDisplay\n'
|
||||||
|
'Build Mode: $_buildMode\n'
|
||||||
'$gitLine'
|
'$gitLine'
|
||||||
'Platform: $platform\n\n'
|
'Platform: $platform\n'
|
||||||
|
'Dart: ${Platform.version}\n'
|
||||||
|
'Timestamp: $timestamp\n\n'
|
||||||
'Error:\n```\n$exception\n```\n\n'
|
'Error:\n```\n$exception\n```\n\n'
|
||||||
'Stack Trace:\n```\n$stackTrace\n```';
|
'Stack Trace:\n```\n$stackTrace\n```';
|
||||||
}
|
}
|
||||||
@@ -56,13 +75,69 @@ class CrashScreen extends StatelessWidget {
|
|||||||
style: Theme.of(ctx).textTheme.titleMedium,
|
style: Theme.of(ctx).textTheme.titleMedium,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
if (_gitHash.isNotEmpty) ...[
|
const SizedBox(height: 4),
|
||||||
const SizedBox(height: 8),
|
FutureBuilder<String>(
|
||||||
const Text(
|
future: _fetchVersion(),
|
||||||
'Git Commit: $_gitHash',
|
builder: (context, snapshot) => Text(
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
'v${snapshot.data ?? '…'} • $_buildMode • '
|
||||||
|
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (gitHash.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
FutureBuilder<PackageInfo>(
|
||||||
|
future: PackageInfo.fromPlatform(),
|
||||||
|
builder: (_, snapshot) {
|
||||||
|
if (!snapshot.hasData) return const SizedBox.shrink();
|
||||||
|
final version =
|
||||||
|
'${snapshot.data!.version}+${snapshot.data!.buildNumber}';
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
final url = Uri.parse(
|
||||||
|
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
|
||||||
|
);
|
||||||
|
await launchUrl(
|
||||||
|
url,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'App Version: $version',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.blue,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
final url = Uri.parse(
|
||||||
|
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
|
||||||
|
);
|
||||||
|
await launchUrl(
|
||||||
|
url,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'Git Commit: $gitHash',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.blue,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Text(
|
const Text(
|
||||||
@@ -106,32 +181,6 @@ class CrashScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (_gitHash.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text(
|
|
||||||
'Git Commit:',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () async {
|
|
||||||
final url = Uri.parse(
|
|
||||||
'https://codeberg.org/guettli/sharedinbox/commit/$_gitHash',
|
|
||||||
);
|
|
||||||
await launchUrl(
|
|
||||||
url,
|
|
||||||
mode: LaunchMode.externalApplication,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Text(
|
|
||||||
_gitHash,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.blue,
|
|
||||||
decoration: TextDecoration.underline,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
var _sieveSsl = true;
|
var _sieveSsl = true;
|
||||||
var _verbose = false;
|
var _verbose = false;
|
||||||
final _jmapUrlCtrl = TextEditingController();
|
final _jmapUrlCtrl = TextEditingController();
|
||||||
|
bool _hasStoredPassword = false;
|
||||||
|
|
||||||
// -- "Try connection" state ------------------------------------------------
|
// -- "Try connection" state ------------------------------------------------
|
||||||
bool _tryTesting = false;
|
bool _tryTesting = false;
|
||||||
@@ -50,6 +51,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
_smtpHostCtrl.addListener(_rebuild);
|
_smtpHostCtrl.addListener(_rebuild);
|
||||||
_sieveHostCtrl.addListener(_rebuild);
|
_sieveHostCtrl.addListener(_rebuild);
|
||||||
_imapHostCtrl.addListener(_rebuild);
|
_imapHostCtrl.addListener(_rebuild);
|
||||||
|
_passwordCtrl.addListener(_rebuild);
|
||||||
unawaited(_load());
|
unawaited(_load());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +65,11 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
context.pop();
|
context.pop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await repo.getPassword(account.id);
|
||||||
|
_hasStoredPassword = true;
|
||||||
|
} catch (_) {}
|
||||||
|
if (!mounted) return;
|
||||||
_account = account;
|
_account = account;
|
||||||
_displayNameCtrl.text = account.displayName;
|
_displayNameCtrl.text = account.displayName;
|
||||||
_usernameCtrl.text = account.username;
|
_usernameCtrl.text = account.username;
|
||||||
@@ -84,6 +91,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
_smtpHostCtrl.removeListener(_rebuild);
|
_smtpHostCtrl.removeListener(_rebuild);
|
||||||
_sieveHostCtrl.removeListener(_rebuild);
|
_sieveHostCtrl.removeListener(_rebuild);
|
||||||
_imapHostCtrl.removeListener(_rebuild);
|
_imapHostCtrl.removeListener(_rebuild);
|
||||||
|
_passwordCtrl.removeListener(_rebuild);
|
||||||
for (final c in [
|
for (final c in [
|
||||||
_displayNameCtrl,
|
_displayNameCtrl,
|
||||||
_usernameCtrl,
|
_usernameCtrl,
|
||||||
@@ -267,10 +275,12 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
),
|
),
|
||||||
_field(
|
_field(
|
||||||
_passwordCtrl,
|
_passwordCtrl,
|
||||||
'New password (leave blank to keep)',
|
_hasStoredPassword
|
||||||
|
? 'New password (leave blank to keep)'
|
||||||
|
: 'Password',
|
||||||
key: const Key('editPasswordField'),
|
key: const Key('editPasswordField'),
|
||||||
obscure: true,
|
obscure: true,
|
||||||
required: false,
|
required: !_hasStoredPassword,
|
||||||
),
|
),
|
||||||
if (account.type == AccountType.jmap) ...[
|
if (account.type == AccountType.jmap) ...[
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
@@ -345,10 +355,17 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
testing: _tryTesting,
|
testing: _tryTesting,
|
||||||
okMessage: _tryOk,
|
okMessage: _tryOk,
|
||||||
errorMessage: _tryErr,
|
errorMessage: _tryErr,
|
||||||
onPressed: _tryConnection,
|
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
|
||||||
|
? _tryConnection
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
FilledButton(onPressed: _save, child: const Text('Save')),
|
FilledButton(
|
||||||
|
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
|
||||||
|
? _save
|
||||||
|
: null,
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||||
|
|
||||||
|
enum _MissingFolderChoice { chooseExisting, createNew }
|
||||||
|
|
||||||
|
/// Resolves a mailbox by role, prompting the user to choose or create one when
|
||||||
|
/// the role is not found. Returns the target [Mailbox], or null if cancelled.
|
||||||
|
Future<Mailbox?> resolveMailboxByRole(
|
||||||
|
BuildContext context,
|
||||||
|
MailboxRepository mailboxRepo,
|
||||||
|
String accountId,
|
||||||
|
String currentMailboxPath,
|
||||||
|
String role, {
|
||||||
|
required String dialogTitle,
|
||||||
|
required String createFolderName,
|
||||||
|
}) async {
|
||||||
|
Mailbox? mailbox = await mailboxRepo.findMailboxByRole(accountId, role);
|
||||||
|
if (!context.mounted) return null;
|
||||||
|
if (mailbox != null) return mailbox;
|
||||||
|
|
||||||
|
final choice = await showDialog<_MissingFolderChoice>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(dialogTitle),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(ctx, _MissingFolderChoice.chooseExisting),
|
||||||
|
child: const Text('Choose existing folder'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, _MissingFolderChoice.createNew),
|
||||||
|
child: Text('Create "$createFolderName"'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!context.mounted || choice == null) return null;
|
||||||
|
|
||||||
|
switch (choice) {
|
||||||
|
case _MissingFolderChoice.chooseExisting:
|
||||||
|
final mailboxes = await mailboxRepo.observeMailboxes(accountId).first;
|
||||||
|
if (!context.mounted) return null;
|
||||||
|
final chosen = await showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: [
|
||||||
|
const ListTile(
|
||||||
|
title: Text(
|
||||||
|
'Move to…',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final m in mailboxes.where(
|
||||||
|
(m) => m.path != currentMailboxPath,
|
||||||
|
))
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.folder_outlined),
|
||||||
|
title: Text(m.name),
|
||||||
|
onTap: () => Navigator.pop(ctx, m.path),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (chosen == null || !context.mounted) return null;
|
||||||
|
mailbox = mailboxes.firstWhere((m) => m.path == chosen);
|
||||||
|
case _MissingFolderChoice.createNew:
|
||||||
|
mailbox = await mailboxRepo.createMailboxWithRole(
|
||||||
|
accountId,
|
||||||
|
createFolderName,
|
||||||
|
role,
|
||||||
|
);
|
||||||
|
if (!context.mounted) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mailbox;
|
||||||
|
}
|
||||||
@@ -13,9 +13,12 @@ import 'package:share_plus/share_plus.dart';
|
|||||||
|
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/utils/format_utils.dart';
|
import 'package:sharedinbox/core/utils/format_utils.dart';
|
||||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/email_headers_dialog.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -70,33 +73,40 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
onPressed: header == null
|
onPressed: header == null
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
unawaited(_reply(context, header, body, replyAll: false));
|
unawaited(_replyWithRecipientDialog(context, header, body));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.reply_all),
|
icon: const Icon(Icons.archive),
|
||||||
tooltip: 'Reply all',
|
tooltip: 'Archive',
|
||||||
onPressed: header == null
|
onPressed: header == null
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
unawaited(_reply(context, header, body, replyAll: true));
|
unawaited(_archive(context, header));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.forward),
|
icon: const Icon(Icons.delete),
|
||||||
tooltip: 'Forward',
|
tooltip: 'Delete',
|
||||||
onPressed: header == null
|
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
unawaited(_forward(context, header, body));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.mark_email_unread_outlined),
|
|
||||||
tooltip: 'Mark as unread',
|
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await repo.setFlag(widget.emailId, seen: false);
|
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||||
if (context.mounted) context.pop();
|
final destPath = await repo.deleteEmail(widget.emailId);
|
||||||
|
|
||||||
|
if (header != null) {
|
||||||
|
await 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) _navigateTo(context, header, nextEmailId);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -111,43 +121,17 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
if (mounted) setState(() => _isFlagged = 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>(
|
PopupMenuButton<String>(
|
||||||
itemBuilder: (ctx) => [
|
itemBuilder: (ctx) => [
|
||||||
|
const PopupMenuItem(value: 'forward', child: Text('Forward')),
|
||||||
|
const PopupMenuItem(value: 'move', child: Text('Move to folder')),
|
||||||
|
const PopupMenuItem(value: 'snooze', child: Text('Snooze')),
|
||||||
|
const PopupMenuItem(value: 'spam', child: Text('Mark as spam')),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'mark_unread',
|
||||||
|
child: Text('Mark as unread'),
|
||||||
|
),
|
||||||
|
const PopupMenuDivider(),
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: 'headers',
|
value: 'headers',
|
||||||
child: Text('Show Mail Headers'),
|
child: Text('Show Mail Headers'),
|
||||||
@@ -156,18 +140,36 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
value: 'structure',
|
value: 'structure',
|
||||||
child: Text('Show Mail Structure'),
|
child: Text('Show Mail Structure'),
|
||||||
),
|
),
|
||||||
|
const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')),
|
||||||
|
const PopupMenuDivider(),
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: 'rfc',
|
value: 'bug_report',
|
||||||
child: Text('Show Raw Email'),
|
child: Text('Report a Bug'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onSelected: (value) {
|
onSelected: (value) async {
|
||||||
if (value == 'headers' && body != null) {
|
if (value == 'forward' && header != null) {
|
||||||
|
unawaited(_forward(context, header, body));
|
||||||
|
} else if (value == 'move' && header != null) {
|
||||||
|
unawaited(_moveTo(context, header));
|
||||||
|
} else if (value == 'snooze' && header != null) {
|
||||||
|
unawaited(_snooze(context, header));
|
||||||
|
} else if (value == 'spam' && header != null) {
|
||||||
|
unawaited(_markAsSpam(context, header));
|
||||||
|
} else if (value == 'mark_unread') {
|
||||||
|
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||||
|
await repo.setFlag(widget.emailId, seen: false);
|
||||||
|
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||||
|
} else if (value == 'headers' && body != null) {
|
||||||
_showHeaders(context, body);
|
_showHeaders(context, body);
|
||||||
} else if (value == 'structure' && body != null) {
|
} else if (value == 'structure' && body != null) {
|
||||||
_showStructure(context, body);
|
_showStructure(context, body);
|
||||||
} else if (value == 'rfc') {
|
} else if (value == 'rfc') {
|
||||||
unawaited(_showRaw(context, header));
|
unawaited(_showRaw(context, header));
|
||||||
|
} else if (value == 'bug_report') {
|
||||||
|
unawaited(
|
||||||
|
context.push('/bug-report?emailId=${widget.emailId}'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -176,19 +178,35 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
body: detail.when(
|
body: detail.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('Error: $e')),
|
error: (e, _) => Center(child: Text('Error: $e')),
|
||||||
data: (d) => _buildBody(context, d.$1, d.$2),
|
data: (d) {
|
||||||
|
final trusted =
|
||||||
|
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
|
||||||
|
return _buildBody(context, d.$1, d.$2, trusted);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody(BuildContext ctx, Email? header, EmailBody body) {
|
Widget _buildBody(
|
||||||
|
BuildContext ctx,
|
||||||
|
Email? header,
|
||||||
|
EmailBody body,
|
||||||
|
List<String> trustedSenders,
|
||||||
|
) {
|
||||||
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
|
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
|
||||||
|
final senderEmail = header?.from.isNotEmpty == true
|
||||||
|
? header!.from.first.email.toLowerCase()
|
||||||
|
: null;
|
||||||
|
final isTrusted =
|
||||||
|
senderEmail != null && trustedSenders.contains(senderEmail);
|
||||||
|
final effectiveLoadImages = _loadRemoteImages || isTrusted;
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
if (header != null) ...[_buildHeader(ctx, header), const Divider()],
|
if (header != null) ...[_buildHeader(ctx, header), const Divider()],
|
||||||
if (hasHtml) ...[
|
if (hasHtml) ...[
|
||||||
if (!_loadRemoteImages)
|
if (!effectiveLoadImages)
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -196,13 +214,43 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.image_outlined, size: 18),
|
icon: const Icon(Icons.image_outlined, size: 18),
|
||||||
label: const Text('Load remote images'),
|
label: const Text('Load remote images'),
|
||||||
onPressed: () => setState(() => _loadRemoteImages = true),
|
onPressed: () {
|
||||||
|
setState(() => _loadRemoteImages = true);
|
||||||
|
if (senderEmail != null) {
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.addTrustedImageSender(senderEmail),
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
content: const Text(
|
||||||
|
'Images will be loaded automatically for this sender.',
|
||||||
|
),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'View',
|
||||||
|
onPressed: () {
|
||||||
|
if (mounted) {
|
||||||
|
unawaited(
|
||||||
|
context.push(
|
||||||
|
'/accounts/trusted-senders',
|
||||||
|
extra: senderEmail,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SecureEmailWebView(
|
SecureEmailWebView(
|
||||||
htmlBody: body.htmlBody!,
|
htmlBody: body.htmlBody!,
|
||||||
loadRemoteImages: _loadRemoteImages,
|
loadRemoteImages: effectiveLoadImages,
|
||||||
),
|
),
|
||||||
] else
|
] else
|
||||||
SelectableText(
|
SelectableText(
|
||||||
@@ -241,6 +289,40 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String?> _getNextEmailIdIfNeeded(Email? header) async {
|
||||||
|
if (header == null) return null;
|
||||||
|
final prefs = ref.read(userPreferencesProvider).value;
|
||||||
|
final action =
|
||||||
|
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
|
||||||
|
if (action != AfterMailViewAction.nextMessage) return null;
|
||||||
|
|
||||||
|
final threads = await ref
|
||||||
|
.read(emailRepositoryProvider)
|
||||||
|
.observeThreads(header.accountId, header.mailboxPath)
|
||||||
|
.first;
|
||||||
|
|
||||||
|
final currentIndex = threads.indexWhere(
|
||||||
|
(t) => t.emailIds.contains(widget.emailId),
|
||||||
|
);
|
||||||
|
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
|
||||||
|
return threads[currentIndex + 1].latestEmailId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateTo(BuildContext context, Email? header, String? nextEmailId) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (nextEmailId != null && header != null) {
|
||||||
|
context.go(
|
||||||
|
'/accounts/${header.accountId}'
|
||||||
|
'/mailboxes/${Uri.encodeComponent(header.mailboxPath)}'
|
||||||
|
'/emails/${Uri.encodeComponent(nextEmailId)}',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _downloadAndOpen(EmailAttachment att) async {
|
Future<void> _downloadAndOpen(EmailAttachment att) async {
|
||||||
setState(() => _downloading.add(att.filename));
|
setState(() => _downloading.add(att.filename));
|
||||||
try {
|
try {
|
||||||
@@ -303,17 +385,78 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
return '\n\n— On $date, $from wrote:\n$quoted';
|
return '\n\n— On $date, $from wrote:\n$quoted';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _reply(
|
Future<void> _replyWithRecipientDialog(
|
||||||
|
BuildContext context,
|
||||||
|
Email header,
|
||||||
|
EmailBody? body,
|
||||||
|
) async {
|
||||||
|
final account =
|
||||||
|
await ref.read(accountRepositoryProvider).getAccount(header.accountId);
|
||||||
|
final ownEmail = account?.email.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
final seen = <String>{};
|
||||||
|
final candidates = <_Candidate>[];
|
||||||
|
|
||||||
|
void addIfNew(EmailAddress addr, _Placement defaultPlacement) {
|
||||||
|
final key = addr.email.toLowerCase();
|
||||||
|
if (key == ownEmail || seen.contains(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
candidates.add(_Candidate(addr, defaultPlacement));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final addr in header.from) {
|
||||||
|
addIfNew(addr, _Placement.to);
|
||||||
|
}
|
||||||
|
for (final addr in header.to) {
|
||||||
|
addIfNew(addr, _Placement.to);
|
||||||
|
}
|
||||||
|
for (final addr in header.cc) {
|
||||||
|
addIfNew(addr, _Placement.cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
if (candidates.length <= 1) {
|
||||||
|
final to = candidates
|
||||||
|
.where((c) => c.placement == _Placement.to)
|
||||||
|
.map((c) => c.address.email)
|
||||||
|
.join(', ');
|
||||||
|
final cc = candidates
|
||||||
|
.where((c) => c.placement == _Placement.cc)
|
||||||
|
.map((c) => c.address.email)
|
||||||
|
.join(', ');
|
||||||
|
await _composeReply(context, header, body, to: to, cc: cc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final confirmed = await showDialog<List<_Candidate>>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => _ReplyAllDialog(candidates: candidates),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == null || !context.mounted) return;
|
||||||
|
|
||||||
|
final to = confirmed
|
||||||
|
.where((c) => c.placement == _Placement.to)
|
||||||
|
.map((c) => c.address.email)
|
||||||
|
.join(', ');
|
||||||
|
final cc = confirmed
|
||||||
|
.where((c) => c.placement == _Placement.cc)
|
||||||
|
.map((c) => c.address.email)
|
||||||
|
.join(', ');
|
||||||
|
await _composeReply(context, header, body, to: to, cc: cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _composeReply(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Email header,
|
Email header,
|
||||||
EmailBody? body, {
|
EmailBody? body, {
|
||||||
required bool replyAll,
|
required String to,
|
||||||
|
required String cc,
|
||||||
}) async {
|
}) async {
|
||||||
final to = header.from.isNotEmpty ? header.from.first.email : '';
|
|
||||||
final subject = (header.subject?.startsWith('Re:') ?? false)
|
final subject = (header.subject?.startsWith('Re:') ?? false)
|
||||||
? header.subject!
|
? header.subject!
|
||||||
: 'Re: ${header.subject ?? ''}';
|
: 'Re: ${header.subject ?? ''}';
|
||||||
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
|
|
||||||
final quoted = await _quotedBody(header, body);
|
final quoted = await _quotedBody(header, body);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
unawaited(
|
unawaited(
|
||||||
@@ -330,6 +473,78 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _archive(BuildContext context, Email header) async {
|
||||||
|
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
final mailbox = await resolveMailboxByRole(
|
||||||
|
context,
|
||||||
|
ref.read(mailboxRepositoryProvider),
|
||||||
|
header.accountId,
|
||||||
|
header.mailboxPath,
|
||||||
|
'archive',
|
||||||
|
dialogTitle: 'No archive folder found',
|
||||||
|
createFolderName: 'Archive',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mailbox == null || !context.mounted) return;
|
||||||
|
|
||||||
|
await ref
|
||||||
|
.read(emailRepositoryProvider)
|
||||||
|
.moveEmail(widget.emailId, mailbox.path);
|
||||||
|
|
||||||
|
unawaited(
|
||||||
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
|
UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: header.accountId,
|
||||||
|
type: UndoType.move,
|
||||||
|
emailIds: [widget.emailId],
|
||||||
|
sourceMailboxPath: header.mailboxPath,
|
||||||
|
destinationMailboxPath: mailbox.path,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markAsSpam(BuildContext context, Email header) async {
|
||||||
|
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
final mailbox = await resolveMailboxByRole(
|
||||||
|
context,
|
||||||
|
ref.read(mailboxRepositoryProvider),
|
||||||
|
header.accountId,
|
||||||
|
header.mailboxPath,
|
||||||
|
'junk',
|
||||||
|
dialogTitle: 'No spam folder found',
|
||||||
|
createFolderName: 'Junk',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mailbox == null || !context.mounted) return;
|
||||||
|
|
||||||
|
await ref
|
||||||
|
.read(emailRepositoryProvider)
|
||||||
|
.moveEmail(widget.emailId, mailbox.path);
|
||||||
|
|
||||||
|
unawaited(
|
||||||
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
|
UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: header.accountId,
|
||||||
|
type: UndoType.move,
|
||||||
|
emailIds: [widget.emailId],
|
||||||
|
sourceMailboxPath: header.mailboxPath,
|
||||||
|
destinationMailboxPath: mailbox.path,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _forward(
|
Future<void> _forward(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Email header,
|
Email header,
|
||||||
@@ -343,15 +558,50 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
unawaited(
|
unawaited(
|
||||||
context.push(
|
context.push(
|
||||||
'/compose',
|
'/compose',
|
||||||
extra: {
|
extra: {'prefillSubject': subject, 'prefillBody': quoted},
|
||||||
'prefillSubject': subject,
|
|
||||||
'prefillBody': quoted,
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String?> _promptNewFolderName(BuildContext context) async {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
try {
|
||||||
|
return await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Create new folder'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(hintText: 'Folder name'),
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
onSubmitted: (value) {
|
||||||
|
if (value.trim().isNotEmpty) Navigator.pop(ctx, value.trim());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final name = controller.text.trim();
|
||||||
|
if (name.isNotEmpty) Navigator.pop(ctx, name);
|
||||||
|
},
|
||||||
|
child: const Text('Create'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _moveTo(BuildContext context, Email header) async {
|
Future<void> _moveTo(BuildContext context, Email header) async {
|
||||||
|
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||||
|
|
||||||
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||||
final mailboxes =
|
final mailboxes =
|
||||||
await mailboxRepo.observeMailboxes(header.accountId).first;
|
await mailboxRepo.observeMailboxes(header.accountId).first;
|
||||||
@@ -362,6 +612,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
const createNewSentinel = '__create_new__';
|
||||||
|
|
||||||
final chosen = await showModalBottomSheet<String>(
|
final chosen = await showModalBottomSheet<String>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => ListView(
|
builder: (ctx) => ListView(
|
||||||
@@ -379,13 +631,28 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
title: Text(m.name),
|
title: Text(m.name),
|
||||||
onTap: () => Navigator.pop(ctx, m.path),
|
onTap: () => Navigator.pop(ctx, m.path),
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.create_new_folder_outlined),
|
||||||
|
title: const Text('Create new folder…'),
|
||||||
|
onTap: () => Navigator.pop(ctx, createNewSentinel),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (chosen == null || !context.mounted) return;
|
if (chosen == null || !context.mounted) return;
|
||||||
|
|
||||||
await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen);
|
String destination = chosen;
|
||||||
|
if (chosen == createNewSentinel) {
|
||||||
|
final name = await _promptNewFolderName(context);
|
||||||
|
if (name == null || !context.mounted) return;
|
||||||
|
final mailbox = await mailboxRepo.createMailbox(header.accountId, name);
|
||||||
|
destination = mailbox.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ref
|
||||||
|
.read(emailRepositoryProvider)
|
||||||
|
.moveEmail(widget.emailId, destination);
|
||||||
|
|
||||||
unawaited(
|
unawaited(
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
@@ -395,15 +662,18 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
type: UndoType.move,
|
type: UndoType.move,
|
||||||
emailIds: [widget.emailId],
|
emailIds: [widget.emailId],
|
||||||
sourceMailboxPath: header.mailboxPath,
|
sourceMailboxPath: header.mailboxPath,
|
||||||
destinationMailboxPath: chosen,
|
destinationMailboxPath: destination,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (context.mounted) context.pop();
|
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _snooze(BuildContext context, Email header) async {
|
Future<void> _snooze(BuildContext context, Email header) async {
|
||||||
|
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
final until = await showModalBottomSheet<DateTime>(
|
final until = await showModalBottomSheet<DateTime>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => const SnoozePicker(),
|
builder: (ctx) => const SnoozePicker(),
|
||||||
@@ -431,7 +701,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
context.pop();
|
_navigateTo(context, header, nextEmailId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,9 +713,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
.fetchRawRfc822(widget.emailId);
|
.fetchRawRfc822(widget.emailId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Failed to fetch raw email: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Failed to fetch raw email: $e')));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,47 +829,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
unawaited(
|
unawaited(
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => EmailHeadersDialog(headers: body.headers),
|
||||||
title: const Text('Mail Headers'),
|
|
||||||
content: SizedBox(
|
|
||||||
width: double.maxFinite,
|
|
||||||
child: ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: body.headers.length,
|
|
||||||
itemBuilder: (ctx, i) {
|
|
||||||
final header = body.headers[i];
|
|
||||||
return Container(
|
|
||||||
color: i.isEven
|
|
||||||
? Theme.of(ctx).colorScheme.surfaceContainerHighest
|
|
||||||
: Theme.of(ctx).colorScheme.surface,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 4,
|
|
||||||
horizontal: 8,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: SelectableText(
|
|
||||||
header.name,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(flex: 2, child: SelectableText(header.value)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('Close'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -610,9 +840,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
duration: Duration(seconds: 5),
|
duration: Duration(seconds: 5),
|
||||||
content: Text(
|
content: Text('Structure not available. Try re-syncing the email.'),
|
||||||
'Structure not available. Try re-syncing the email.',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -624,12 +852,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
unawaited(
|
unawaited(
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => Dialog.fullscreen(
|
||||||
title: const Text('Mail Structure'),
|
child: Scaffold(
|
||||||
content: SizedBox(
|
appBar: AppBar(
|
||||||
width: double.maxFinite,
|
title: const Text('Mail Structure'),
|
||||||
child: ListView.builder(
|
leading: const CloseButton(),
|
||||||
shrinkWrap: true,
|
),
|
||||||
|
body: ListView.builder(
|
||||||
itemCount: rows.length,
|
itemCount: rows.length,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
final row = rows[i];
|
final row = rows[i];
|
||||||
@@ -658,14 +887,90 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
),
|
||||||
TextButton(
|
),
|
||||||
onPressed: () => Navigator.pop(ctx),
|
);
|
||||||
child: const Text('Close'),
|
}
|
||||||
),
|
}
|
||||||
|
|
||||||
|
enum _Placement { to, cc, skip }
|
||||||
|
|
||||||
|
class _Candidate {
|
||||||
|
_Candidate(this.address, this.placement);
|
||||||
|
final EmailAddress address;
|
||||||
|
_Placement placement;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReplyAllDialog extends StatefulWidget {
|
||||||
|
const _ReplyAllDialog({required this.candidates});
|
||||||
|
final List<_Candidate> candidates;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ReplyAllDialog> createState() => _ReplyAllDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReplyAllDialogState extends State<_ReplyAllDialog> {
|
||||||
|
late final List<_Candidate> _candidates;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_candidates = [
|
||||||
|
for (final c in widget.candidates) _Candidate(c.address, c.placement),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Reply All'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: [
|
||||||
|
for (final c in _candidates)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
c.address.toString(),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SegmentedButton<_Placement>(
|
||||||
|
showSelectedIcon: false,
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: _Placement.to, label: Text('To')),
|
||||||
|
ButtonSegment(value: _Placement.cc, label: Text('Cc')),
|
||||||
|
ButtonSegment(
|
||||||
|
value: _Placement.skip,
|
||||||
|
label: Text('Skip'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {c.placement},
|
||||||
|
onSelectionChanged: (s) =>
|
||||||
|
setState(() => c.placement = s.first),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, _candidates),
|
||||||
|
child: const Text('Reply'),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -712,10 +1017,13 @@ class _UnsubscribeChip extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final uri = _parseUnsubscribeUri(header);
|
final uri = _parseUnsubscribeUri(header);
|
||||||
if (uri == null) return const SizedBox.shrink();
|
if (uri == null) return const SizedBox.shrink();
|
||||||
return ActionChip(
|
return Tooltip(
|
||||||
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
|
message: uri.toString(),
|
||||||
label: const Text('Unsubscribe'),
|
child: ActionChip(
|
||||||
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
|
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
|
||||||
|
label: const Text('Unsubscribe'),
|
||||||
|
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,21 +8,14 @@ import 'package:intl/intl.dart';
|
|||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/email_thread_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';
|
||||||
|
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
|
||||||
final _dateFmt = DateFormat('MMM d');
|
|
||||||
// Cache formatted dates by local calendar day so DateFormat.format is called
|
|
||||||
// at most once per unique date rather than once per list item per rebuild.
|
|
||||||
final _formattedDates = <int, String>{};
|
|
||||||
|
|
||||||
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
|
|
||||||
|
|
||||||
String _fmtDate(DateTime dt) =>
|
|
||||||
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
|
|
||||||
|
|
||||||
class EmailListScreen extends ConsumerStatefulWidget {
|
class EmailListScreen extends ConsumerStatefulWidget {
|
||||||
const EmailListScreen({
|
const EmailListScreen({
|
||||||
@@ -147,16 +140,21 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final repo = ref.watch(emailRepositoryProvider);
|
final repo = ref.watch(emailRepositoryProvider);
|
||||||
final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
|
final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
|
||||||
|
final prefs =
|
||||||
|
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
||||||
|
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: _buildAppBar(repo, accountAsync),
|
appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom),
|
||||||
drawer: _selecting
|
drawer: _selecting
|
||||||
? null
|
? null
|
||||||
: FolderDrawer(
|
: FolderDrawer(
|
||||||
accountId: widget.accountId,
|
accountId: widget.accountId,
|
||||||
currentMailboxPath: widget.mailboxPath,
|
currentMailboxPath: widget.mailboxPath,
|
||||||
),
|
),
|
||||||
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
|
bottomNavigationBar: _selecting
|
||||||
|
? _selectionBottomBar()
|
||||||
|
: (menuAtBottom ? _folderNavBottomBar() : null),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildSyncErrorBanner(),
|
_buildSyncErrorBanner(),
|
||||||
@@ -172,12 +170,14 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
|
|
||||||
PreferredSizeWidget _buildAppBar(
|
PreferredSizeWidget _buildAppBar(
|
||||||
EmailRepository emailRepo,
|
EmailRepository emailRepo,
|
||||||
AsyncValue<Account?> accountAsync,
|
AsyncValue<Account?> accountAsync, {
|
||||||
) {
|
required bool menuAtBottom,
|
||||||
|
}) {
|
||||||
final selectionCount =
|
final selectionCount =
|
||||||
_searching ? _selectedSearchIds.length : _selectedThreadIds.length;
|
_searching ? _selectedSearchIds.length : _selectedThreadIds.length;
|
||||||
|
|
||||||
return AppBar(
|
return AppBar(
|
||||||
|
automaticallyImplyLeading: !menuAtBottom,
|
||||||
leading: _selecting
|
leading: _selecting
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
@@ -300,6 +300,22 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _folderNavBottomBar() {
|
||||||
|
return BottomAppBar(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Builder(
|
||||||
|
builder: (context) => IconButton(
|
||||||
|
icon: const Icon(Icons.menu),
|
||||||
|
tooltip: 'Open folders',
|
||||||
|
onPressed: () => Scaffold.of(context).openDrawer(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _selectionBottomBar() {
|
Widget _selectionBottomBar() {
|
||||||
return BottomAppBar(
|
return BottomAppBar(
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -356,11 +372,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
}
|
}
|
||||||
return MaterialBanner(
|
return MaterialBanner(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||||
content: Text(
|
content: Text(error, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
error,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
Icons.sync_problem,
|
Icons.sync_problem,
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: Theme.of(context).colorScheme.error,
|
||||||
@@ -374,9 +386,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
child: const Text('Retry'),
|
child: const Text('Retry'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context.push(
|
onPressed: () =>
|
||||||
'/accounts/${widget.accountId}/sync-log',
|
context.push('/accounts/${widget.accountId}/sync-log'),
|
||||||
),
|
|
||||||
child: const Text('View log'),
|
child: const Text('View log'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -420,24 +431,26 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _batchMoveToRole(String role, String notFoundMessage) async {
|
Future<void> _batchMoveToRole(
|
||||||
|
String role, {
|
||||||
|
required String dialogTitle,
|
||||||
|
required String createFolderName,
|
||||||
|
}) async {
|
||||||
final ids = _selectedEmailIds;
|
final ids = _selectedEmailIds;
|
||||||
_clearSelection();
|
_clearSelection();
|
||||||
final mailbox = await ref
|
|
||||||
.read(mailboxRepositoryProvider)
|
final mailbox = await resolveMailboxByRole(
|
||||||
.findMailboxByRole(widget.accountId, role);
|
context,
|
||||||
if (!mounted) return;
|
ref.read(mailboxRepositoryProvider),
|
||||||
if (mailbox == null) {
|
widget.accountId,
|
||||||
ScaffoldMessenger.of(
|
widget.mailboxPath,
|
||||||
context,
|
role,
|
||||||
).showSnackBar(
|
dialogTitle: dialogTitle,
|
||||||
SnackBar(
|
createFolderName: createFolderName,
|
||||||
duration: const Duration(seconds: 5),
|
);
|
||||||
content: Text(notFoundMessage),
|
|
||||||
),
|
if (!mounted || mailbox == null) return;
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
|
||||||
// Fetch full email data before moving so we can restore them if user clicks Undo.
|
// Fetch full email data before moving so we can restore them if user clicks Undo.
|
||||||
@@ -463,8 +476,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _batchArchive() =>
|
Future<void> _batchArchive() => _batchMoveToRole(
|
||||||
_batchMoveToRole('archive', 'No archive folder found');
|
'archive',
|
||||||
|
dialogTitle: 'No archive folder found',
|
||||||
|
createFolderName: 'Archive',
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> _refreshSearchAndPopIfEmpty() async {
|
Future<void> _refreshSearchAndPopIfEmpty() async {
|
||||||
if (!mounted || !_searching) return;
|
if (!mounted || !_searching) return;
|
||||||
@@ -543,8 +559,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _batchMarkSpam() =>
|
Future<void> _batchMarkSpam() => _batchMoveToRole(
|
||||||
_batchMoveToRole('junk', 'No spam folder found');
|
'junk',
|
||||||
|
dialogTitle: 'No spam folder found',
|
||||||
|
createFolderName: 'Junk',
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> _batchMove() async {
|
Future<void> _batchMove() async {
|
||||||
final ids = _selectedEmailIds;
|
final ids = _selectedEmailIds;
|
||||||
@@ -660,177 +679,93 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final t = threads[i];
|
final t = threads[i];
|
||||||
final isSelected = _selectedThreadIds.contains(t.threadId);
|
return EmailThreadTile(
|
||||||
final senderNames =
|
thread: t,
|
||||||
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
isSelected: _selectedThreadIds.contains(t.threadId),
|
||||||
|
isSelecting: _selecting,
|
||||||
final tile = ListTile(
|
|
||||||
leading: SizedBox(
|
|
||||||
width: 40,
|
|
||||||
child: _selecting
|
|
||||||
? Checkbox(
|
|
||||||
value: isSelected,
|
|
||||||
onChanged: (_) => _toggleThreadSelection(t),
|
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
t.hasUnread ? Icons.mail : Icons.mail_outline,
|
|
||||||
color:
|
|
||||||
t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
senderNames.isEmpty ? '(unknown)' : senderNames,
|
|
||||||
style: t.hasUnread
|
|
||||||
? const TextStyle(fontWeight: FontWeight.bold)
|
|
||||||
: null,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (t.messageCount > 1)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 4),
|
|
||||||
child: Text(
|
|
||||||
'[${t.messageCount}]',
|
|
||||||
style: Theme.of(ctx).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
t.subject ?? '(no subject)',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: t.hasUnread
|
|
||||||
? const TextStyle(fontWeight: FontWeight.bold)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
if (t.preview != null && t.preview!.isNotEmpty)
|
|
||||||
Text(
|
|
||||||
t.preview!,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(ctx).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
selected: isSelected,
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (t.isFlagged)
|
|
||||||
const Icon(Icons.star, color: Colors.amber, size: 16),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
_fmtDate(t.latestDate),
|
|
||||||
style: Theme.of(ctx).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: _selecting
|
onTap: _selecting
|
||||||
? () => _toggleThreadSelection(t)
|
? () => _toggleThreadSelection(t)
|
||||||
: t.messageCount > 1
|
: t.messageCount > 1
|
||||||
? () => context.push(
|
? () => context.push(
|
||||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/threads/${Uri.encodeComponent(t.threadId)}',
|
'/accounts/${widget.accountId}/mailboxes'
|
||||||
|
'/${Uri.encodeComponent(widget.mailboxPath)}'
|
||||||
|
'/threads/${Uri.encodeComponent(t.threadId)}',
|
||||||
)
|
)
|
||||||
: () => context.push(
|
: () => context.push(
|
||||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
'/accounts/${widget.accountId}/mailboxes'
|
||||||
|
'/${Uri.encodeComponent(widget.mailboxPath)}'
|
||||||
|
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
||||||
),
|
),
|
||||||
onLongPress: () => _toggleThreadSelection(t),
|
onLongPress: () => _toggleThreadSelection(t),
|
||||||
);
|
onDismissed: (direction) => _onSwipeDismissed(t, direction),
|
||||||
|
|
||||||
// For swipe actions on threads, operate on the latest email only
|
|
||||||
// (single-email threads) or the whole thread.
|
|
||||||
return Dismissible(
|
|
||||||
key: ValueKey(t.threadId),
|
|
||||||
direction:
|
|
||||||
_selecting ? DismissDirection.none : DismissDirection.horizontal,
|
|
||||||
background: _swipeBackground(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
color: Colors.green,
|
|
||||||
icon: Icons.archive,
|
|
||||||
label: 'Archive',
|
|
||||||
),
|
|
||||||
secondaryBackground: _swipeBackground(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
color: Colors.red,
|
|
||||||
icon: Icons.delete,
|
|
||||||
label: 'Delete',
|
|
||||||
),
|
|
||||||
onDismissed: (direction) async {
|
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
|
||||||
final type = direction == DismissDirection.startToEnd
|
|
||||||
? UndoType.move
|
|
||||||
: UndoType.delete;
|
|
||||||
|
|
||||||
// Fetch full email data before moving/deleting.
|
|
||||||
final originalEmails = (await Future.wait(
|
|
||||||
t.emailIds.map((id) => repo.getEmail(id)),
|
|
||||||
))
|
|
||||||
.whereType<Email>()
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (direction == DismissDirection.startToEnd) {
|
|
||||||
final archive = await ref
|
|
||||||
.read(mailboxRepositoryProvider)
|
|
||||||
.findMailboxByRole(widget.accountId, 'archive');
|
|
||||||
if (!mounted || archive == null) return;
|
|
||||||
for (final id in t.emailIds) {
|
|
||||||
await repo.moveEmail(id, archive.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
final action = UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: widget.accountId,
|
|
||||||
type: type,
|
|
||||||
emailIds: t.emailIds,
|
|
||||||
sourceMailboxPath: widget.mailboxPath,
|
|
||||||
destinationMailboxPath: archive.path,
|
|
||||||
originalEmails: originalEmails,
|
|
||||||
);
|
|
||||||
unawaited(
|
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(action),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
String? lastDestPath;
|
|
||||||
for (final id in t.emailIds) {
|
|
||||||
lastDestPath = await repo.deleteEmail(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
final action = UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: widget.accountId,
|
|
||||||
type: type,
|
|
||||||
emailIds: t.emailIds,
|
|
||||||
sourceMailboxPath: widget.mailboxPath,
|
|
||||||
destinationMailboxPath: lastDestPath,
|
|
||||||
originalEmails: originalEmails,
|
|
||||||
);
|
|
||||||
unawaited(
|
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(action),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: tile,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onSwipeDismissed(
|
||||||
|
EmailThread t,
|
||||||
|
DismissDirection direction,
|
||||||
|
) async {
|
||||||
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
final type = direction == DismissDirection.startToEnd
|
||||||
|
? UndoType.move
|
||||||
|
: UndoType.delete;
|
||||||
|
|
||||||
|
// Fetch full email data before moving/deleting.
|
||||||
|
final originalEmails = (await Future.wait(
|
||||||
|
t.emailIds.map((id) => repo.getEmail(id)),
|
||||||
|
))
|
||||||
|
.whereType<Email>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (direction == DismissDirection.startToEnd) {
|
||||||
|
final archive = await ref
|
||||||
|
.read(mailboxRepositoryProvider)
|
||||||
|
.findMailboxByRole(widget.accountId, 'archive');
|
||||||
|
if (!mounted || archive == null) return;
|
||||||
|
for (final id in t.emailIds) {
|
||||||
|
await repo.moveEmail(id, archive.path);
|
||||||
|
}
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: widget.accountId,
|
||||||
|
type: type,
|
||||||
|
emailIds: t.emailIds,
|
||||||
|
sourceMailboxPath: widget.mailboxPath,
|
||||||
|
destinationMailboxPath: archive.path,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? lastDestPath;
|
||||||
|
for (final id in t.emailIds) {
|
||||||
|
lastDestPath = await repo.deleteEmail(id);
|
||||||
|
}
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: widget.accountId,
|
||||||
|
type: type,
|
||||||
|
emailIds: t.emailIds,
|
||||||
|
sourceMailboxPath: widget.mailboxPath,
|
||||||
|
destinationMailboxPath: lastDestPath,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
}
|
||||||
|
|
||||||
// Used for search results, which are individual emails.
|
// Used for search results, which are individual emails.
|
||||||
Widget _buildEmailList(List<Email> emails) {
|
Widget _buildEmailList(List<Email> emails) {
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: emails.length,
|
itemCount: emails.length,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
final e = emails[i];
|
final e = emails[i];
|
||||||
|
final t = EmailThread.fromEmail(e);
|
||||||
final isSelected = _selectedSearchIds.contains(e.id);
|
final isSelected = _selectedSearchIds.contains(e.id);
|
||||||
return EmailTile(
|
return ThreadTile(
|
||||||
email: e,
|
thread: t,
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
leading: SizedBox(
|
leading: SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
@@ -849,25 +784,4 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _swipeBackground({
|
|
||||||
required AlignmentGeometry alignment,
|
|
||||||
required Color color,
|
|
||||||
required IconData icon,
|
|
||||||
required String label,
|
|
||||||
}) {
|
|
||||||
return Container(
|
|
||||||
color: color,
|
|
||||||
alignment: alignment,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: Colors.white),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(label, style: const TextStyle(color: Colors.white)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||||
@@ -17,8 +18,12 @@ class MailboxListScreen extends ConsumerWidget {
|
|||||||
final mailboxRepo = ref.watch(mailboxRepositoryProvider);
|
final mailboxRepo = ref.watch(mailboxRepositoryProvider);
|
||||||
final emailRepo = ref.watch(emailRepositoryProvider);
|
final emailRepo = ref.watch(emailRepositoryProvider);
|
||||||
final accountAsync = ref.watch(accountByIdProvider(accountId));
|
final accountAsync = ref.watch(accountByIdProvider(accountId));
|
||||||
|
final prefs =
|
||||||
|
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
||||||
|
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
automaticallyImplyLeading: !menuAtBottom,
|
||||||
title: const Text('Folders'),
|
title: const Text('Folders'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -42,6 +47,21 @@ class MailboxListScreen extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
drawer: FolderDrawer(accountId: accountId),
|
drawer: FolderDrawer(accountId: accountId),
|
||||||
|
bottomNavigationBar: menuAtBottom
|
||||||
|
? BottomAppBar(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Builder(
|
||||||
|
builder: (ctx) => IconButton(
|
||||||
|
icon: const Icon(Icons.menu),
|
||||||
|
tooltip: 'Open folders',
|
||||||
|
onPressed: () => Scaffold.of(ctx).openDrawer(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
// ── Failed-mutation banner ───────────────────────────────────────
|
// ── Failed-mutation banner ───────────────────────────────────────
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ 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';
|
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
|
||||||
|
|
||||||
final _searchHistoryProvider =
|
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
|
||||||
FutureProvider.autoDispose<List<String>>((ref) async {
|
ref,
|
||||||
|
) async {
|
||||||
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
|
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,9 +189,9 @@ 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(
|
ThreadTile(
|
||||||
email: e,
|
thread: EmailThread.fromEmail(e),
|
||||||
showLocation: true,
|
locationLabel: '${e.accountId} • ${e.mailboxPath}',
|
||||||
onTap: () => context.push(
|
onTap: () => context.push(
|
||||||
'/accounts/${e.accountId}/mailboxes'
|
'/accounts/${e.accountId}/mailboxes'
|
||||||
'/${Uri.encodeComponent(e.mailboxPath)}'
|
'/${Uri.encodeComponent(e.mailboxPath)}'
|
||||||
|
|||||||
@@ -137,9 +137,7 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(widget.isLocal ? 'Local Filters' : 'Remote Filters'),
|
||||||
widget.isLocal ? 'Local Filters' : 'Remote Filters',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
body: _buildBody(),
|
body: _buildBody(),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/utils/about_markdown.dart';
|
||||||
|
|
||||||
final _timeFmt = DateFormat('MMM d, HH:mm:ss');
|
final _timeFmt = DateFormat('MMM d, HH:mm:ss');
|
||||||
|
|
||||||
@@ -21,6 +25,57 @@ String _fmtBytes(int bytes) {
|
|||||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _buildSyncEntryMarkdown(SyncLogEntry entry) {
|
||||||
|
final buf = StringBuffer();
|
||||||
|
buf.writeln('## Sync Entry');
|
||||||
|
buf.writeln();
|
||||||
|
buf.writeln('| Property | Value |');
|
||||||
|
buf.writeln('|----------|-------|');
|
||||||
|
buf.writeln('| Started | ${_timeFmt.format(entry.startedAt)} |');
|
||||||
|
buf.writeln('| Finished | ${_timeFmt.format(entry.finishedAt)} |');
|
||||||
|
buf.writeln('| Duration | ${_fmtDuration(entry.duration)} |');
|
||||||
|
if (entry.protocol.isNotEmpty) {
|
||||||
|
buf.writeln('| Protocol | ${entry.protocol.toUpperCase()} |');
|
||||||
|
}
|
||||||
|
final statusLabel = entry.isOk
|
||||||
|
? 'OK'
|
||||||
|
: entry.isPermanent
|
||||||
|
? 'Error (permanent)'
|
||||||
|
: 'Error';
|
||||||
|
buf.writeln('| Status | $statusLabel |');
|
||||||
|
buf.writeln('| Emails fetched | ${entry.emailsFetched} |');
|
||||||
|
buf.writeln('| Emails up-to-date | ${entry.emailsSkipped} |');
|
||||||
|
buf.writeln('| Mailboxes synced | ${entry.mailboxesSynced} |');
|
||||||
|
buf.writeln('| Pending changes flushed | ${entry.pendingFlushed} |');
|
||||||
|
buf.writeln('| Data transferred | ${_fmtBytes(entry.bytesTransferred)} |');
|
||||||
|
if (entry.mailboxStats.isNotEmpty) {
|
||||||
|
buf.writeln();
|
||||||
|
buf.writeln('### Per mailbox');
|
||||||
|
buf.writeln();
|
||||||
|
buf.writeln('| Mailbox | Fetched | Up-to-date | Duration |');
|
||||||
|
buf.writeln('|---------|---------|------------|----------|');
|
||||||
|
for (final m in entry.mailboxStats) {
|
||||||
|
final dur = m.duration != null ? _fmtDuration(m.duration!) : '-';
|
||||||
|
buf.writeln('| ${m.mailboxPath} | ${m.fetched} | ${m.skipped} | $dur |');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.errorMessage != null) {
|
||||||
|
buf.writeln();
|
||||||
|
buf.writeln('**Error:**');
|
||||||
|
buf.writeln();
|
||||||
|
buf.writeln(entry.errorMessage);
|
||||||
|
}
|
||||||
|
if (entry.stackTrace != null) {
|
||||||
|
buf.writeln();
|
||||||
|
buf.writeln('**Stack trace:**');
|
||||||
|
buf.writeln();
|
||||||
|
buf.writeln('```');
|
||||||
|
buf.write(entry.stackTrace);
|
||||||
|
buf.writeln('```');
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
class SyncLogScreen extends ConsumerStatefulWidget {
|
class SyncLogScreen extends ConsumerStatefulWidget {
|
||||||
const SyncLogScreen({super.key, required this.accountId});
|
const SyncLogScreen({super.key, required this.accountId});
|
||||||
|
|
||||||
@@ -69,6 +124,41 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
|
|||||||
ref.read(syncManagerProvider).syncNow(widget.accountId);
|
ref.read(syncManagerProvider).syncNow(widget.accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _copyEntry(SyncLogEntry entry, BuildContext context) async {
|
||||||
|
final accounts =
|
||||||
|
await ref.read(accountRepositoryProvider).observeAccounts().first;
|
||||||
|
final imapCount = accounts.where((a) => a.type == AccountType.imap).length;
|
||||||
|
final jmapCount = accounts.where((a) => a.type == AccountType.jmap).length;
|
||||||
|
|
||||||
|
PackageInfo? pkg;
|
||||||
|
try {
|
||||||
|
pkg = await PackageInfo.fromPlatform();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final deviceModel = await getDeviceModel();
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
final syncMd = _buildSyncEntryMarkdown(entry);
|
||||||
|
final aboutMd = buildAboutMarkdown(
|
||||||
|
context: context,
|
||||||
|
pkg: pkg,
|
||||||
|
imapCount: imapCount,
|
||||||
|
jmapCount: jmapCount,
|
||||||
|
deviceModel: deviceModel,
|
||||||
|
);
|
||||||
|
await Clipboard.setData(ClipboardData(text: '$syncMd\n$aboutMd'));
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 3),
|
||||||
|
content: Text('Copied to clipboard'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -96,16 +186,20 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
|
|||||||
? const Center(child: Text('No sync entries yet'))
|
? const Center(child: Text('No sync entries yet'))
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
itemCount: _entries.length,
|
itemCount: _entries.length,
|
||||||
itemBuilder: (ctx, i) => _SyncLogTile(entry: _entries[i]),
|
itemBuilder: (ctx, i) => _SyncLogTile(
|
||||||
|
entry: _entries[i],
|
||||||
|
onCopy: () => _copyEntry(_entries[i], ctx),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SyncLogTile extends StatelessWidget {
|
class _SyncLogTile extends StatelessWidget {
|
||||||
const _SyncLogTile({required this.entry});
|
const _SyncLogTile({required this.entry, required this.onCopy});
|
||||||
|
|
||||||
final SyncLogEntry entry;
|
final SyncLogEntry entry;
|
||||||
|
final VoidCallback onCopy;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -115,6 +209,12 @@ class _SyncLogTile extends StatelessWidget {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final errorColor = theme.colorScheme.error;
|
final errorColor = theme.colorScheme.error;
|
||||||
|
|
||||||
|
final subtitleText = entry.isOk
|
||||||
|
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
|
||||||
|
: entry.isPermanent
|
||||||
|
? 'Error (permanent) · took $durationLabel'
|
||||||
|
: 'Error · took $durationLabel';
|
||||||
|
|
||||||
return ExpansionTile(
|
return ExpansionTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
entry.isOk ? Icons.check_circle : Icons.error_outline,
|
entry.isOk ? Icons.check_circle : Icons.error_outline,
|
||||||
@@ -125,11 +225,20 @@ class _SyncLogTile extends StatelessWidget {
|
|||||||
style: entry.isOk ? null : TextStyle(color: errorColor),
|
style: entry.isOk ? null : TextStyle(color: errorColor),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
entry.isOk
|
subtitleText,
|
||||||
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
|
|
||||||
: 'Error · took $durationLabel',
|
|
||||||
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor),
|
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor),
|
||||||
),
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.copy, size: 18),
|
||||||
|
tooltip: 'Copy as markdown',
|
||||||
|
onPressed: onCopy,
|
||||||
|
),
|
||||||
|
const Icon(Icons.expand_more),
|
||||||
|
],
|
||||||
|
),
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(72, 0, 16, 12),
|
padding: const EdgeInsets.fromLTRB(72, 0, 16, 12),
|
||||||
@@ -171,6 +280,31 @@ class _SyncLogTile extends StatelessWidget {
|
|||||||
style: TextStyle(color: errorColor, fontSize: 12),
|
style: TextStyle(color: errorColor, fontSize: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (entry.stackTrace != null) ...[
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 6, bottom: 2),
|
||||||
|
child: Text(
|
||||||
|
'Stack trace',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black87,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
entry.stackTrace!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: Colors.red[300],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
if (entry.protocolLog != null) ...[
|
if (entry.protocolLog != null) ...[
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.only(top: 6, bottom: 2),
|
padding: EdgeInsets.only(top: 6, bottom: 2),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
|
|||||||
|
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||||
@@ -28,9 +29,16 @@ class ThreadDetailScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final repo = ref.watch(emailRepositoryProvider);
|
final repo = ref.watch(emailRepositoryProvider);
|
||||||
|
final prefs =
|
||||||
|
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
||||||
|
final buttonAtBottom = prefs.mailViewButtonPosition == MenuPosition.bottom;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Thread')),
|
appBar: AppBar(
|
||||||
|
title: const Text('Thread'),
|
||||||
|
automaticallyImplyLeading: !buttonAtBottom,
|
||||||
|
),
|
||||||
|
bottomNavigationBar: buttonAtBottom ? _buildBackButtonBar(context) : null,
|
||||||
body: StreamBuilder<List<Email>>(
|
body: StreamBuilder<List<Email>>(
|
||||||
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
|
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
@@ -60,6 +68,20 @@ class ThreadDetailScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildBackButtonBar(BuildContext context) {
|
||||||
|
return BottomAppBar(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
tooltip: 'Back',
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EmailMessageCard extends ConsumerStatefulWidget {
|
class _EmailMessageCard extends ConsumerStatefulWidget {
|
||||||
@@ -91,6 +113,14 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final trustedSenders =
|
||||||
|
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
|
||||||
|
final senderEmail = widget.email.from.isNotEmpty
|
||||||
|
? widget.email.from.first.email.toLowerCase()
|
||||||
|
: null;
|
||||||
|
final isTrusted =
|
||||||
|
senderEmail != null && trustedSenders.contains(senderEmail);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -125,13 +155,13 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_expanded) _buildExpandedBody(),
|
if (_expanded) _buildExpandedBody(isTrusted, senderEmail),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildExpandedBody() {
|
Widget _buildExpandedBody(bool isTrusted, String? senderEmail) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -141,6 +171,17 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
FutureBuilder<EmailBody>(
|
FutureBuilder<EmailBody>(
|
||||||
future: _bodyFuture,
|
future: _bodyFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'Failed to load email: ${snapshot.error}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!snapshot.hasData) {
|
if (!snapshot.hasData) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -151,21 +192,51 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
}
|
}
|
||||||
final body = snapshot.data!;
|
final body = snapshot.data!;
|
||||||
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
|
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
|
||||||
|
final effectiveLoadImages = _loadRemoteImages || isTrusted;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (hasHtml) ...[
|
if (hasHtml) ...[
|
||||||
if (!_loadRemoteImages)
|
if (!effectiveLoadImages)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
icon: const Icon(Icons.image_outlined, size: 16),
|
icon: const Icon(Icons.image_outlined, size: 16),
|
||||||
label: const Text('Load remote images'),
|
label: const Text('Load remote images'),
|
||||||
onPressed: () =>
|
onPressed: () {
|
||||||
setState(() => _loadRemoteImages = true),
|
setState(() => _loadRemoteImages = true);
|
||||||
|
if (senderEmail != null) {
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.addTrustedImageSender(senderEmail),
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
content: const Text(
|
||||||
|
'Images will be loaded automatically for this sender.',
|
||||||
|
),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'View',
|
||||||
|
onPressed: () {
|
||||||
|
if (mounted) {
|
||||||
|
unawaited(
|
||||||
|
context.push(
|
||||||
|
'/accounts/trusted-senders',
|
||||||
|
extra: senderEmail,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
SecureEmailWebView(
|
SecureEmailWebView(
|
||||||
htmlBody: body.htmlBody!,
|
htmlBody: body.htmlBody!,
|
||||||
loadRemoteImages: _loadRemoteImages,
|
loadRemoteImages: effectiveLoadImages,
|
||||||
),
|
),
|
||||||
] else
|
] else
|
||||||
SelectableText(
|
SelectableText(
|
||||||
@@ -229,47 +300,27 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _delete() async {
|
Future<void> _delete() async {
|
||||||
final confirmed = await showDialog<bool>(
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
context: context,
|
// Fetch data first for IMAP undo support
|
||||||
builder: (ctx) => AlertDialog(
|
final original = await repo.getEmail(widget.email.id);
|
||||||
title: const Text('Delete email'),
|
|
||||||
content: const Text('Move this email to Trash?'),
|
final destPath = await repo.deleteEmail(widget.email.id);
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
|
||||||
child: const Text('Delete'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (confirmed == true) {
|
if (original != null) {
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
unawaited(
|
||||||
// Fetch data first for IMAP undo support
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
final original = await repo.getEmail(widget.email.id);
|
UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
final destPath = await repo.deleteEmail(widget.email.id);
|
accountId: widget.email.accountId,
|
||||||
|
type: UndoType.delete,
|
||||||
if (!mounted) return;
|
emailIds: [widget.email.id],
|
||||||
if (original != null) {
|
sourceMailboxPath: widget.email.mailboxPath,
|
||||||
unawaited(
|
destinationMailboxPath: destPath,
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
originalEmails: [original],
|
||||||
UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: widget.email.accountId,
|
|
||||||
type: UndoType.delete,
|
|
||||||
emailIds: [widget.email.id],
|
|
||||||
sourceMailboxPath: widget.email.mailboxPath,
|
|
||||||
destinationMailboxPath: destPath,
|
|
||||||
originalEmails: [original],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
|
||||||
|
class TrustedImageSendersScreen extends ConsumerWidget {
|
||||||
|
const TrustedImageSendersScreen({super.key, this.highlightedSender});
|
||||||
|
|
||||||
|
final String? highlightedSender;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Allowed addresses for images')),
|
||||||
|
body: trustedSendersAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (_, __) =>
|
||||||
|
const Center(child: Text('Error loading trusted senders')),
|
||||||
|
data: (senders) {
|
||||||
|
if (senders.isEmpty) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'No addresses added yet. '
|
||||||
|
'Tap "Load remote images" in an email to add the sender.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: senders.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final sender = senders[index];
|
||||||
|
final isHighlighted = sender == highlightedSender;
|
||||||
|
return ListTile(
|
||||||
|
title: Text(
|
||||||
|
sender,
|
||||||
|
style: isHighlighted
|
||||||
|
? const TextStyle(fontWeight: FontWeight.bold)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
tooltip: 'Remove',
|
||||||
|
onPressed: () {
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.removeTrustedImageSender(sender),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,9 +84,7 @@ class _UndoActionTile extends ConsumerWidget {
|
|||||||
.read(undoServiceProvider.notifier)
|
.read(undoServiceProvider.notifier)
|
||||||
.undo(actionId: action.id);
|
.undo(actionId: action.id);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
|
||||||
).showSnackBar(
|
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
duration: Duration(seconds: 5),
|
duration: Duration(seconds: 5),
|
||||||
content: Text('Action undone.'),
|
content: Text('Action undone.'),
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
|
import 'package:sharedinbox/core/sync/background_sync.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
|
||||||
|
class UserPreferencesScreen extends ConsumerWidget {
|
||||||
|
const UserPreferencesScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final prefsAsync = ref.watch(userPreferencesProvider);
|
||||||
|
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
|
||||||
|
final trustedCount = trustedSendersAsync.value?.length ?? 0;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Preferences')),
|
||||||
|
body: prefsAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (_, __) =>
|
||||||
|
const Center(child: Text('Error loading preferences')),
|
||||||
|
data: (prefs) => ListView(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
'Menu bar position',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Where the folder navigation menu is shown in the mailbox view.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
RadioGroup<MenuPosition>(
|
||||||
|
groupValue: prefs.menuPosition,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null) return;
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.updateMenuPosition(value),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Column(
|
||||||
|
children: [
|
||||||
|
RadioListTile<MenuPosition>(
|
||||||
|
title: Text('Bottom (default)'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Open folder navigation from a button at the bottom of the screen.',
|
||||||
|
),
|
||||||
|
value: MenuPosition.bottom,
|
||||||
|
),
|
||||||
|
RadioListTile<MenuPosition>(
|
||||||
|
title: Text('Top'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Open folder navigation from the hamburger icon in the top bar.',
|
||||||
|
),
|
||||||
|
value: MenuPosition.top,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
'Single mail view button position',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Where the back button is shown in the single mail view.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
RadioGroup<MenuPosition>(
|
||||||
|
groupValue: prefs.mailViewButtonPosition,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null) return;
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.updateMailViewButtonPosition(value),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Column(
|
||||||
|
children: [
|
||||||
|
RadioListTile<MenuPosition>(
|
||||||
|
title: Text('Bottom (default)'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Show the back button at the bottom of the screen.',
|
||||||
|
),
|
||||||
|
value: MenuPosition.bottom,
|
||||||
|
),
|
||||||
|
RadioListTile<MenuPosition>(
|
||||||
|
title: Text('Top'),
|
||||||
|
subtitle: Text('Show the back button in the top bar.'),
|
||||||
|
value: MenuPosition.top,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
'After mail action',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
subtitle: const Text(
|
||||||
|
'What to show after deleting, archiving, or otherwise handling a message.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
RadioGroup<AfterMailViewAction>(
|
||||||
|
groupValue: prefs.afterMailViewAction,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null) return;
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.updateAfterMailViewAction(value),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Column(
|
||||||
|
children: [
|
||||||
|
RadioListTile<AfterMailViewAction>(
|
||||||
|
title: Text('Next message (default)'),
|
||||||
|
subtitle: Text('Show the next message in the mailbox.'),
|
||||||
|
value: AfterMailViewAction.nextMessage,
|
||||||
|
),
|
||||||
|
RadioListTile<AfterMailViewAction>(
|
||||||
|
title: Text('Return to mailbox'),
|
||||||
|
subtitle: Text('Return to the message list.'),
|
||||||
|
value: AfterMailViewAction.showMailbox,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
'Offline email cache',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Pre-fetch email bodies in the background so they are available offline.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
RadioGroup<PrefetchMode>(
|
||||||
|
groupValue: prefs.prefetchMode,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null) return;
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.updatePrefetchMode(value),
|
||||||
|
);
|
||||||
|
unawaited(registerBodyPrefetchTask(value));
|
||||||
|
},
|
||||||
|
child: const Column(
|
||||||
|
children: [
|
||||||
|
RadioListTile<PrefetchMode>(
|
||||||
|
title: Text('Wi-Fi only (default)'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Pre-fetch bodies in the background when connected to Wi-Fi.',
|
||||||
|
),
|
||||||
|
value: PrefetchMode.wifiOnly,
|
||||||
|
),
|
||||||
|
RadioListTile<PrefetchMode>(
|
||||||
|
title: Text('Any network'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Pre-fetch bodies on Wi-Fi and mobile data.',
|
||||||
|
),
|
||||||
|
value: PrefetchMode.always,
|
||||||
|
),
|
||||||
|
RadioListTile<PrefetchMode>(
|
||||||
|
title: Text('Disabled'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Do not pre-fetch email bodies in the background.',
|
||||||
|
),
|
||||||
|
value: PrefetchMode.disabled,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (prefs.prefetchMode != PrefetchMode.disabled) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text('Cache size limit:'),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
DropdownButton<int>(
|
||||||
|
value: _nearestCacheOption(prefs.bodyCacheLimitMb),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 50, child: Text('50 MB')),
|
||||||
|
DropdownMenuItem(value: 100, child: Text('100 MB')),
|
||||||
|
DropdownMenuItem(value: 200, child: Text('200 MB')),
|
||||||
|
DropdownMenuItem(value: 500, child: Text('500 MB')),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null) return;
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.updateBodyCacheLimitMb(value),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
'Allowed addresses for images',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
trustedCount == 0
|
||||||
|
? 'No addresses added yet.'
|
||||||
|
: '$trustedCount address${trustedCount == 1 ? '' : 'es'}',
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => context.push('/accounts/trusted-senders'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _nearestCacheOption(int mb) {
|
||||||
|
const options = [50, 100, 200, 500];
|
||||||
|
return options.reduce(
|
||||||
|
(a, b) => (a - mb).abs() <= (b - mb).abs() ? a : b,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:sharedinbox/core/db_schema_version.dart';
|
||||||
|
|
||||||
|
const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||||
|
|
||||||
|
/// Builds the About markdown table used in [AboutScreen] and sync log copies.
|
||||||
|
String buildAboutMarkdown({
|
||||||
|
required BuildContext context,
|
||||||
|
PackageInfo? pkg,
|
||||||
|
required int imapCount,
|
||||||
|
required int jmapCount,
|
||||||
|
String? deviceModel,
|
||||||
|
}) {
|
||||||
|
final size = MediaQuery.of(context).size;
|
||||||
|
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||||
|
final physW = (size.width * pixelRatio).toInt();
|
||||||
|
final physH = (size.height * pixelRatio).toInt();
|
||||||
|
final version = pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
|
||||||
|
final versionDisplay = _gitHash.isNotEmpty
|
||||||
|
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
|
||||||
|
: version;
|
||||||
|
final osName = _capitalize(Platform.operatingSystem);
|
||||||
|
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||||
|
final locale = Localizations.localeOf(context).toString();
|
||||||
|
final textScale = MediaQuery.of(
|
||||||
|
context,
|
||||||
|
).textScaler.scale(1.0).toStringAsFixed(1);
|
||||||
|
|
||||||
|
final gitCommitLine = _gitHash.isNotEmpty
|
||||||
|
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
||||||
|
: '';
|
||||||
|
final deviceModelLine =
|
||||||
|
deviceModel != null ? '| Device Model | $deviceModel |\n' : '';
|
||||||
|
|
||||||
|
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
||||||
|
'| Property | Value |\n'
|
||||||
|
'|----------|-------|\n'
|
||||||
|
'| App Version | $versionDisplay |\n'
|
||||||
|
'$gitCommitLine'
|
||||||
|
'| Platform | ${Platform.operatingSystem} |\n'
|
||||||
|
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
||||||
|
'$deviceModelLine'
|
||||||
|
'| Resolution | ${physW}x$physH px'
|
||||||
|
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
||||||
|
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
||||||
|
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
||||||
|
'| Processors | ${Platform.numberOfProcessors} |\n'
|
||||||
|
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
||||||
|
'| Locale | $locale |\n'
|
||||||
|
'| Text Scale | $textScale× |\n'
|
||||||
|
'| DB Schema Version | $dbSchemaVersion |\n'
|
||||||
|
'| IMAP Accounts | $imapCount |\n'
|
||||||
|
'| JMAP Accounts | $jmapCount |\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches device model string, or null when unavailable.
|
||||||
|
Future<String?> getDeviceModel() async {
|
||||||
|
try {
|
||||||
|
final info = DeviceInfoPlugin();
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final android = await info.androidInfo;
|
||||||
|
return '${android.manufacturer} / ${android.model}';
|
||||||
|
} else if (Platform.isIOS) {
|
||||||
|
final ios = await info.iosInfo;
|
||||||
|
return ios.utsname.machine;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _capitalize(String s) =>
|
||||||
|
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
|
||||||
|
/// Full-screen dialog for browsing email headers, organised into groups.
|
||||||
|
class EmailHeadersDialog extends StatelessWidget {
|
||||||
|
const EmailHeadersDialog({super.key, required this.headers});
|
||||||
|
final List<EmailHeader> headers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dialog.fullscreen(
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Mail Headers'),
|
||||||
|
leading: const CloseButton(),
|
||||||
|
),
|
||||||
|
body: _HeadersBody(headers: headers),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HeadersBody extends StatelessWidget {
|
||||||
|
const _HeadersBody({required this.headers});
|
||||||
|
final List<EmailHeader> headers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final receivedHeaders = <EmailHeader>[];
|
||||||
|
final listHeaders = <EmailHeader>[];
|
||||||
|
final arcHeaders = <EmailHeader>[];
|
||||||
|
final otherHeaders = <EmailHeader>[];
|
||||||
|
// Maps X- prefix (e.g. "X-Google") → headers with that prefix.
|
||||||
|
final xByPrefix = <String, List<EmailHeader>>{};
|
||||||
|
|
||||||
|
for (final h in headers) {
|
||||||
|
final lower = h.name.toLowerCase();
|
||||||
|
if (lower == 'received') {
|
||||||
|
receivedHeaders.add(h);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lower.startsWith('list-')) {
|
||||||
|
listHeaders.add(h);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lower.startsWith('arc-')) {
|
||||||
|
arcHeaders.add(h);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lower.startsWith('x-')) {
|
||||||
|
final parts = h.name.split('-');
|
||||||
|
// "X-Foo-Bar-Baz" → prefix "X-Foo"; "X-Single" → prefix "X-Single".
|
||||||
|
final prefix = parts.length >= 3 ? '${parts[0]}-${parts[1]}' : h.name;
|
||||||
|
xByPrefix.putIfAbsent(prefix, () => []).add(h);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
otherHeaders.add(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
final sections = <Widget>[];
|
||||||
|
|
||||||
|
if (otherHeaders.isNotEmpty) {
|
||||||
|
sections.add(_HeadersSection(title: 'Headers', headers: otherHeaders));
|
||||||
|
}
|
||||||
|
if (listHeaders.isNotEmpty) {
|
||||||
|
sections.add(
|
||||||
|
_HeadersSection(title: 'List- Headers', headers: listHeaders),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (receivedHeaders.isNotEmpty) {
|
||||||
|
sections.add(_ReceivedSection(headers: receivedHeaders));
|
||||||
|
}
|
||||||
|
if (arcHeaders.isNotEmpty) {
|
||||||
|
sections.add(
|
||||||
|
_HeadersSection(title: 'ARC- Headers', headers: arcHeaders),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// X- headers at bottom, each prefix in its own collapsible group.
|
||||||
|
final sortedPrefixes = xByPrefix.keys.toList()
|
||||||
|
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
||||||
|
for (final prefix in sortedPrefixes) {
|
||||||
|
sections.add(
|
||||||
|
_HeadersSection(
|
||||||
|
title: '$prefix Headers',
|
||||||
|
headers: xByPrefix[prefix]!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView(children: sections);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HeadersSection extends StatelessWidget {
|
||||||
|
const _HeadersSection({required this.title, required this.headers});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final List<EmailHeader> headers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ExpansionTile(
|
||||||
|
title: Text('$title (${headers.length})'),
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < headers.length; i++)
|
||||||
|
_HeaderRow(header: headers[i], index: i),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Received headers section — collapsed by default; shows inter-hop delays.
|
||||||
|
class _ReceivedSection extends StatelessWidget {
|
||||||
|
const _ReceivedSection({required this.headers});
|
||||||
|
final List<EmailHeader> headers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final entries = _buildEntries(headers);
|
||||||
|
return ExpansionTile(
|
||||||
|
title: Text('Received (${headers.length})'),
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < entries.length; i++) ...[
|
||||||
|
_HeaderRow(header: entries[i].header, index: i),
|
||||||
|
if (entries[i].delay != null) _DelayRow(delay: entries[i].delay!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<_ReceivedEntry> _buildEntries(List<EmailHeader> headers) {
|
||||||
|
final timestamps =
|
||||||
|
headers.map((h) => _parseReceivedTimestamp(h.value)).toList();
|
||||||
|
return [
|
||||||
|
for (var i = 0; i < headers.length; i++)
|
||||||
|
_ReceivedEntry(
|
||||||
|
header: headers[i],
|
||||||
|
delay: _computeDelay(timestamps, i),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static Duration? _computeDelay(List<DateTime?> timestamps, int i) {
|
||||||
|
if (i >= timestamps.length - 1) return null;
|
||||||
|
final current = timestamps[i];
|
||||||
|
final next = timestamps[i + 1];
|
||||||
|
if (current == null || next == null) return null;
|
||||||
|
final d = current.difference(next);
|
||||||
|
return d.isNegative ? Duration.zero : d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReceivedEntry {
|
||||||
|
const _ReceivedEntry({required this.header, this.delay});
|
||||||
|
final EmailHeader header;
|
||||||
|
final Duration? delay;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HeaderRow extends StatelessWidget {
|
||||||
|
const _HeaderRow({required this.header, required this.index});
|
||||||
|
final EmailHeader header;
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bg = index.isEven
|
||||||
|
? Theme.of(context).colorScheme.surfaceContainerHighest
|
||||||
|
: Theme.of(context).colorScheme.surface;
|
||||||
|
return Container(
|
||||||
|
color: bg,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SelectableText(
|
||||||
|
header.name,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(flex: 2, child: SelectableText(header.value)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DelayRow extends StatelessWidget {
|
||||||
|
const _DelayRow({required this.delay});
|
||||||
|
final Duration delay;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = _delayColor(delay);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.arrow_downward, size: 14, color: color),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_formatDuration(delay),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: color,
|
||||||
|
fontWeight:
|
||||||
|
delay.inSeconds >= 30 ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the RFC 2822 timestamp from a Received header value.
|
||||||
|
///
|
||||||
|
/// Received headers end with `; date`, e.g.:
|
||||||
|
/// by mx.example.com; Mon, 1 Jan 2024 12:00:00 +0000 (UTC)
|
||||||
|
DateTime? _parseReceivedTimestamp(String value) {
|
||||||
|
final semiIndex = value.lastIndexOf(';');
|
||||||
|
if (semiIndex < 0) return null;
|
||||||
|
var s = value.substring(semiIndex + 1).trim();
|
||||||
|
// Strip parenthesised comments like (UTC).
|
||||||
|
s = s.replaceAll(RegExp(r'\([^)]*\)'), ' ').trim();
|
||||||
|
// Strip leading day-of-week abbreviation like "Mon, ".
|
||||||
|
s = s.replaceFirst(RegExp(r'^[A-Za-z]{2,4},\s*'), '');
|
||||||
|
// Collapse runs of whitespace.
|
||||||
|
s = s.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||||
|
|
||||||
|
for (final fmt in [
|
||||||
|
DateFormat('dd MMM yyyy HH:mm:ss Z', 'en_US'),
|
||||||
|
DateFormat('d MMM yyyy HH:mm:ss Z', 'en_US'),
|
||||||
|
DateFormat('dd MMM yyyy HH:mm:ss', 'en_US'),
|
||||||
|
DateFormat('d MMM yyyy HH:mm:ss', 'en_US'),
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
return fmt.parse(s);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(Duration d) {
|
||||||
|
if (d.inSeconds < 60) return '${d.inSeconds}s';
|
||||||
|
if (d.inMinutes < 60) return '${d.inMinutes}m ${d.inSeconds.remainder(60)}s';
|
||||||
|
return '${d.inHours}h ${d.inMinutes.remainder(60)}m';
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _delayColor(Duration d) {
|
||||||
|
if (d.inSeconds < 30) return Colors.green;
|
||||||
|
if (d.inSeconds < 300) return Colors.orange;
|
||||||
|
return Colors.red;
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
|
||||||
|
final _dateFmt = DateFormat('MMM d');
|
||||||
|
final _formattedDates = <int, String>{};
|
||||||
|
|
||||||
|
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
|
||||||
|
|
||||||
|
String _fmtDate(DateTime dt) =>
|
||||||
|
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
|
||||||
|
|
||||||
|
/// A swipeable list tile for an [EmailThread].
|
||||||
|
///
|
||||||
|
/// Handles the [Dismissible] wrapper (archive left, delete right) and
|
||||||
|
/// selection-mode checkbox. Pass [showAccount] to display an extra subtitle
|
||||||
|
/// line with the account name — used in the combined-inbox view.
|
||||||
|
class EmailThreadTile extends StatelessWidget {
|
||||||
|
const EmailThreadTile({
|
||||||
|
super.key,
|
||||||
|
required this.thread,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.isSelecting,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onLongPress,
|
||||||
|
required this.onDismissed,
|
||||||
|
this.showAccount = false,
|
||||||
|
this.accountName,
|
||||||
|
});
|
||||||
|
|
||||||
|
final EmailThread thread;
|
||||||
|
final bool isSelected;
|
||||||
|
final bool isSelecting;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onLongPress;
|
||||||
|
final Future<void> Function(DismissDirection) onDismissed;
|
||||||
|
|
||||||
|
/// When true, renders an extra subtitle line with [accountName].
|
||||||
|
final bool showAccount;
|
||||||
|
final String? accountName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = thread;
|
||||||
|
final senderNames =
|
||||||
|
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
||||||
|
|
||||||
|
final tile = ListTile(
|
||||||
|
leading: SizedBox(
|
||||||
|
width: 40,
|
||||||
|
child: isSelecting
|
||||||
|
? Checkbox(
|
||||||
|
value: isSelected,
|
||||||
|
onChanged: (_) => onTap(),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
t.hasUnread ? Icons.mail : Icons.mail_outline,
|
||||||
|
color:
|
||||||
|
t.hasUnread ? Theme.of(context).colorScheme.primary : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
senderNames.isEmpty ? '(unknown)' : senderNames,
|
||||||
|
style: t.hasUnread
|
||||||
|
? const TextStyle(fontWeight: FontWeight.bold)
|
||||||
|
: null,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (t.messageCount > 1)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4),
|
||||||
|
child: Text(
|
||||||
|
'[${t.messageCount}]',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
t.subject ?? '(no subject)',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: t.hasUnread
|
||||||
|
? const TextStyle(fontWeight: FontWeight.bold)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
if (t.preview != null && t.preview!.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
t.preview!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (showAccount && accountName != null)
|
||||||
|
Text(
|
||||||
|
accountName!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
selected: isSelected,
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (t.isFlagged)
|
||||||
|
const Icon(Icons.star, color: Colors.amber, size: 16),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_fmtDate(t.latestDate),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Dismissible(
|
||||||
|
key: ValueKey('${t.accountId}:${t.threadId}'),
|
||||||
|
direction:
|
||||||
|
isSelecting ? DismissDirection.none : DismissDirection.horizontal,
|
||||||
|
background: _swipeBackground(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
color: Colors.green,
|
||||||
|
icon: Icons.archive,
|
||||||
|
label: 'Archive',
|
||||||
|
),
|
||||||
|
secondaryBackground: _swipeBackground(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
color: Colors.red,
|
||||||
|
icon: Icons.delete,
|
||||||
|
label: 'Delete',
|
||||||
|
),
|
||||||
|
onDismissed: onDismissed,
|
||||||
|
child: tile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Widget _swipeBackground({
|
||||||
|
required AlignmentGeometry alignment,
|
||||||
|
required Color color,
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
color: color,
|
||||||
|
alignment: alignment,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: Colors.white),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(label, style: const TextStyle(color: Colors.white)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,10 +31,13 @@ String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
|
|||||||
<meta name="color-scheme" content="light">
|
<meta name="color-scheme" content="light">
|
||||||
<meta http-equiv="Content-Security-Policy" content="$csp">
|
<meta http-equiv="Content-Security-Policy" content="$csp">
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; color-scheme: light; background-color: #ffffff; color: #000000; }
|
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; overflow-x: hidden; color-scheme: light; background-color: #ffffff; color: #000000; }
|
||||||
img { max-width: 100%; height: auto; }
|
img { max-width: 100%; height: auto; }
|
||||||
a { color: #1976D2; }
|
a { color: #1976D2; }
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; max-width: 100%; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
td, th { overflow-wrap: break-word; word-break: break-word; }
|
||||||
|
pre { white-space: pre-wrap; word-break: break-word; overflow-x: auto; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -108,12 +111,16 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Future<void> _measureHeight(String _) async {
|
Future<void> _measureHeight(String _) async {
|
||||||
final result = await _controller!.runJavaScriptReturningResult(
|
try {
|
||||||
'document.documentElement.scrollHeight',
|
final result = await _controller!.runJavaScriptReturningResult(
|
||||||
);
|
'document.documentElement.scrollHeight',
|
||||||
final h = double.tryParse(result.toString());
|
);
|
||||||
if (h != null && h > 0 && mounted) {
|
final h = double.tryParse(result.toString());
|
||||||
setState(() => _height = h);
|
if (h != null && h > 0 && mounted) {
|
||||||
|
setState(() => _height = h);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// WebView not ready yet; height stays at default
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,12 +191,14 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true && mounted) {
|
if (confirmed == true && mounted) {
|
||||||
final launched =
|
final launched = await launchUrl(
|
||||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
uri,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
if (!launched && mounted) {
|
if (!launched && mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Could not open: $url')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Could not open: $url')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
|
||||||
|
final _dateFmt = DateFormat('MMM d');
|
||||||
|
// Cache formatted dates by local calendar day to avoid repeated DateFormat.format calls.
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// A list tile for an [EmailThread].
|
||||||
|
///
|
||||||
|
/// Used in inbox lists, combined inbox, and search result lists.
|
||||||
|
/// Pass a custom [leading] widget to support selection-mode checkboxes.
|
||||||
|
/// Pass [locationLabel] to show an extra subtitle line (e.g. account name or
|
||||||
|
/// "accountId • mailboxPath") — useful in cross-mailbox views.
|
||||||
|
class ThreadTile extends StatelessWidget {
|
||||||
|
const ThreadTile({
|
||||||
|
super.key,
|
||||||
|
required this.thread,
|
||||||
|
required this.onTap,
|
||||||
|
this.leading,
|
||||||
|
this.selected = false,
|
||||||
|
this.onLongPress,
|
||||||
|
this.locationLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
final EmailThread thread;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final Widget? leading;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback? onLongPress;
|
||||||
|
|
||||||
|
/// When non-null, appended as an extra subtitle line in primary colour.
|
||||||
|
final String? locationLabel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final senderNames = thread.participants.isEmpty
|
||||||
|
? '(unknown)'
|
||||||
|
: thread.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: leading ??
|
||||||
|
Icon(
|
||||||
|
thread.hasUnread ? Icons.mail : Icons.mail_outline,
|
||||||
|
color:
|
||||||
|
thread.hasUnread ? Theme.of(context).colorScheme.primary : null,
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
senderNames,
|
||||||
|
style: thread.hasUnread
|
||||||
|
? const TextStyle(fontWeight: FontWeight.bold)
|
||||||
|
: null,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (thread.messageCount > 1)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4),
|
||||||
|
child: Text(
|
||||||
|
'[${thread.messageCount}]',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
thread.subject ?? '(no subject)',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: thread.hasUnread
|
||||||
|
? const TextStyle(fontWeight: FontWeight.bold)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
if (thread.preview != null && thread.preview!.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
thread.preview!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (locationLabel != null)
|
||||||
|
Text(
|
||||||
|
locationLabel!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (thread.isFlagged)
|
||||||
|
const Icon(Icons.star, color: Colors.amber, size: 16),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_fmtDate(thread.latestDate),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
selected: selected,
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# Next
|
|
||||||
|
|
||||||
## Introduction
|
|
||||||
|
|
||||||
Continue the momentum from the safety hardening and infrastructure work.
|
|
||||||
The focus is on making the app ready for real-world use with robust error
|
|
||||||
handling and performance optimizations.
|
|
||||||
|
|
||||||
Create several small commits. Every commit should be self contained.
|
|
||||||
|
|
||||||
while working create/append to plan.log, so that the user sees what you are working on.
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
### 0. deploy-android
|
|
||||||
|
|
||||||
Make `task deploy-android` work.
|
|
||||||
|
|
||||||
### 0.5 Debug duration of deploy-android
|
|
||||||
|
|
||||||
Is there a way to make deploy-android faster?
|
|
||||||
|
|
||||||
Use `task --verbose` to see what gets done.
|
|
||||||
|
|
||||||
Maybe avoid doing things again, when nothing changed.
|
|
||||||
Taskfile has features to avoid calling things again, when the input has not changed.
|
|
||||||
|
|
||||||
### 1. Fix Android E2E Race Condition (aliceTile)
|
|
||||||
|
|
||||||
The Android E2E test `integration_test/app_e2e_test.dart` is flaky. It fails
|
|
||||||
at `tap(aliceTile)` with "0 widgets" even though `pumpUntil` found it.
|
|
||||||
The current "double pumpUntil" fix isn't reliable enough.
|
|
||||||
Investigate if the animation state or the Drift stream propagation is the
|
|
||||||
culprit.
|
|
||||||
|
|
||||||
### 2. Implement Global Crash Screen
|
|
||||||
|
|
||||||
Wrap `main()` in `runZonedGuarded` to catch unhandled async errors.
|
|
||||||
Implement a `CrashScreen` widget that shows the stack trace and a
|
|
||||||
"Copy to Clipboard" button for user reporting.
|
|
||||||
|
|
||||||
### 3. Database-Backed Threading
|
|
||||||
|
|
||||||
Currently, emails are grouped into threads in-memory in the repository.
|
|
||||||
Refactor to store thread relationships in the local SQLite database.
|
|
||||||
This is necessary for performance on mailboxes with thousands of messages.
|
|
||||||
|
|
||||||
### 4. Implement Undo for Bulk Actions
|
|
||||||
|
|
||||||
Add a global "Undo" snackbar after deleting or moving emails.
|
|
||||||
The system needs to handle the three sync states:
|
|
||||||
- Queued (easy to undo)
|
|
||||||
- In-progress (cancel network call)
|
|
||||||
- Finished (requires a reverse move/un-delete)
|
|
||||||
|
|
||||||
### 5. Transition to Real Account Testing
|
|
||||||
|
|
||||||
Prepare the integration tests to run against a real test account
|
|
||||||
(`si3e2e@thomas-guettler.de`) instead of the local Stalwart server.
|
|
||||||
This verifies the app against real-world network latency and RFC edge cases.
|
|
||||||
|
|
||||||
### 6. Coverage Gate Maintenance
|
|
||||||
|
|
||||||
Reduce the `_excluded` list in `scripts/check_coverage.dart`.
|
|
||||||
Add a test to ensure the exclusion list doesn't contain files that no longer
|
|
||||||
exist ("ghost paths").
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
+28
-4
@@ -249,6 +249,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.12"
|
version: "0.7.12"
|
||||||
|
device_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: device_info_plus
|
||||||
|
sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "13.1.0"
|
||||||
|
device_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_platform_interface
|
||||||
|
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.1.0"
|
||||||
drift:
|
drift:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1005,7 +1021,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.44.4"
|
version: "0.44.4"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: stack_trace
|
name: stack_trace
|
||||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||||
@@ -1117,13 +1133,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.2"
|
version: "6.3.2"
|
||||||
url_launcher_android:
|
url_launcher_android:
|
||||||
dependency: transitive
|
dependency: "direct overridden"
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.30"
|
version: "6.3.24"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1284,6 +1300,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.0"
|
version: "6.3.0"
|
||||||
|
win32_registry:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32_registry
|
||||||
|
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.3"
|
||||||
workmanager:
|
workmanager:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
+9
-1
@@ -33,7 +33,7 @@ dependencies:
|
|||||||
flutter_secure_storage: ^10.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
|
|
||||||
# Date formatting
|
# Date formatting
|
||||||
intl: any
|
intl: ^0.20.2
|
||||||
|
|
||||||
# File picking (compose attachments) and opening downloaded attachments
|
# File picking (compose attachments) and opening downloaded attachments
|
||||||
file_picker: ^12.0.0-beta.4
|
file_picker: ^12.0.0-beta.4
|
||||||
@@ -58,9 +58,13 @@ dependencies:
|
|||||||
flutter_local_notifications: ^21.0.0
|
flutter_local_notifications: ^21.0.0
|
||||||
workmanager: ^0.9.0
|
workmanager: ^0.9.0
|
||||||
|
|
||||||
|
# Stack trace chain-to-VM conversion for FlutterError.demangleStackTrace
|
||||||
|
stack_trace: ^1.12.1
|
||||||
|
|
||||||
# App version metadata for crash reports
|
# App version metadata for crash reports
|
||||||
package_info_plus: ^10.1.0
|
package_info_plus: ^10.1.0
|
||||||
share_plus: ^13.1.0
|
share_plus: ^13.1.0
|
||||||
|
device_info_plus: ^13.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -89,3 +93,7 @@ dependency_overrides:
|
|||||||
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
|
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
|
||||||
# stable Pigeon and is known to work reliably.
|
# stable Pigeon and is known to work reliably.
|
||||||
path_provider_android: ">=2.2.0 <2.2.21"
|
path_provider_android: ">=2.2.0 <2.2.21"
|
||||||
|
# url_launcher_android 6.3.25 updated to Pigeon 26, which causes a
|
||||||
|
# channel-error on launchUrl on some Android devices (same root cause as
|
||||||
|
# path_provider_android). Pin to <6.3.25 which uses stable Pigeon.
|
||||||
|
url_launcher_android: ">=6.3.0 <6.3.25"
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended"
|
||||||
|
],
|
||||||
|
"labels": ["dependencies"],
|
||||||
|
"github-actions": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"],
|
||||||
|
"addLabels": ["automerge"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": ["gomod"],
|
||||||
|
"matchFileNames": ["ci/**"],
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"customManagers": [
|
||||||
|
{
|
||||||
|
"customType": "regex",
|
||||||
|
"fileMatch": ["^\\.forgejo/Dockerfile$"],
|
||||||
|
"matchStrings": ["DAGGER_VERSION=(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)"],
|
||||||
|
"depNameTemplate": "dagger/dagger",
|
||||||
|
"datasourceTemplate": "github-releases",
|
||||||
|
"extractVersionTemplate": "^v(?<version>.*)$"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"customType": "regex",
|
||||||
|
"fileMatch": ["^DAGGER\\.md$"],
|
||||||
|
"matchStrings": ["github:dagger/nix/v(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)#dagger"],
|
||||||
|
"depNameTemplate": "dagger/dagger",
|
||||||
|
"datasourceTemplate": "github-releases",
|
||||||
|
"extractVersionTemplate": "^v(?<version>.*)$"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,718 +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.
|
|
||||||
To resume the Claude conversation, look up the session UUID first:
|
|
||||||
|
|
||||||
scripts/agent_loop.py list # shows NAME and UUID columns
|
|
||||||
claude --resume <uuid> # use the UUID, NOT the session name
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shlex
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Cron runs with a minimal PATH; ensure Nix profile binaries (tea, claude) and ~/go/bin (fgj) are found.
|
|
||||||
os.environ["PATH"] = (
|
|
||||||
f"{Path.home()}/.nix-profile/bin"
|
|
||||||
f":{Path.home()}/go/bin"
|
|
||||||
f":{os.environ.get('PATH', '/usr/bin:/bin')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── configuration ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
REPO = "guettli/sharedinbox"
|
|
||||||
REPO_URL = f"https://codeberg.org/{REPO}"
|
|
||||||
STATE_FILE = Path.home() / ".sharedinbox-agent-state.json"
|
|
||||||
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, state: str = "open") -> dict | None:
|
|
||||||
"""Return the first PR in the given state whose head branch matches, or None."""
|
|
||||||
result = subprocess.run(
|
|
||||||
["fgj", "--hostname", "codeberg.org", "pr", "list",
|
|
||||||
"--repo", REPO, "--state", state, "--json"],
|
|
||||||
capture_output=True, text=True,
|
|
||||||
)
|
|
||||||
if result.returncode != 0 or not result.stdout.strip():
|
|
||||||
return None
|
|
||||||
prs = json.loads(result.stdout)
|
|
||||||
for pr in prs:
|
|
||||||
head = pr.get("head", {})
|
|
||||||
ref = head.get("ref") or head.get("label", "").split(":")[-1]
|
|
||||||
if ref == branch:
|
|
||||||
return pr
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _open_issue_prs() -> list[dict]:
|
|
||||||
"""Return all open PRs with issue-{N}-fix branches, oldest-first."""
|
|
||||||
result = subprocess.run(
|
|
||||||
["fgj", "--hostname", "codeberg.org", "pr", "list",
|
|
||||||
"--repo", REPO, "--state", "open", "--json"],
|
|
||||||
capture_output=True, text=True,
|
|
||||||
)
|
|
||||||
if result.returncode != 0 or not result.stdout.strip():
|
|
||||||
return []
|
|
||||||
prs = json.loads(result.stdout)
|
|
||||||
issue_prs = []
|
|
||||||
for pr in prs:
|
|
||||||
head = pr.get("head", {})
|
|
||||||
ref = head.get("ref") or head.get("label", "").split(":")[-1]
|
|
||||||
if re.match(r"^issue-\d+-fix$", ref or ""):
|
|
||||||
issue_prs.append(pr)
|
|
||||||
issue_prs.sort(key=lambda p: p["number"])
|
|
||||||
return issue_prs
|
|
||||||
|
|
||||||
|
|
||||||
def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
|
|
||||||
"""Return the latest CI run triggered by a pull_request event for the given PR number."""
|
|
||||||
data = _tea_get(f"repos/{REPO}/actions/runs?event=pull_request&limit=50")
|
|
||||||
runs = (data or {}).get("workflow_runs", [])
|
|
||||||
for run in runs:
|
|
||||||
try:
|
|
||||||
payload = json.loads(run.get("event_payload", "{}"))
|
|
||||||
if payload.get("pull_request", {}).get("number") == pr_number:
|
|
||||||
return run
|
|
||||||
except (json.JSONDecodeError, AttributeError):
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_pr(pr_number: int) -> None:
|
|
||||||
"""Squash-merge a PR via fgj."""
|
|
||||||
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
|
|
||||||
|
|
||||||
|
|
||||||
# ── state file ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _read_state() -> dict | None:
|
|
||||||
if STATE_FILE.exists():
|
|
||||||
try:
|
|
||||||
return json.loads(STATE_FILE.read_text())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _write_state(pid: int | None, issue: int | None, kind: str, issue_title: str | None = None, session_name: str | None = None, ci_run_id: int | None = None) -> None:
|
|
||||||
data: dict = {
|
|
||||||
"pid": pid,
|
|
||||||
"issue": issue,
|
|
||||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"type": kind,
|
|
||||||
}
|
|
||||||
if issue_title is not None:
|
|
||||||
data["issue_title"] = issue_title
|
|
||||||
if session_name is not None:
|
|
||||||
data["session_name"] = session_name
|
|
||||||
if ci_run_id is not None:
|
|
||||||
data["ci_run_id_at_start"] = ci_run_id
|
|
||||||
STATE_FILE.write_text(json.dumps(data, indent=2))
|
|
||||||
STATE_FILE.chmod(0o600)
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_state() -> None:
|
|
||||||
STATE_FILE.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _find_session_uuid(session_name: str) -> str | None:
|
|
||||||
"""Return the Claude session UUID for *session_name*, or None if not found.
|
|
||||||
|
|
||||||
Claude stores session metadata in JSONL files; the first entry with
|
|
||||||
type=="agent-name" contains both the human-readable name and the UUID
|
|
||||||
needed for ``claude --resume <uuid>``.
|
|
||||||
"""
|
|
||||||
if not CLAUDE_PROJECTS_DIR.exists():
|
|
||||||
return None
|
|
||||||
for jsonl in CLAUDE_PROJECTS_DIR.glob("*.jsonl"):
|
|
||||||
try:
|
|
||||||
with jsonl.open() as fh:
|
|
||||||
for line in fh:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
d = json.loads(line)
|
|
||||||
if d.get("type") == "agent-name" and d.get("agentName") == session_name:
|
|
||||||
return d.get("sessionId")
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ── agent launcher ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _start_agent(prompt: str, session_name: str) -> int:
|
|
||||||
"""Start Claude Code as a detached background process and return its PID."""
|
|
||||||
log_dir = Path.home() / ".sharedinbox-agent-logs"
|
|
||||||
log_dir.mkdir(mode=0o700, exist_ok=True)
|
|
||||||
log_dir.chmod(0o700) # fix permissions if dir already existed with wrong mode
|
|
||||||
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
|
|
||||||
log_file = log_dir / f"{session_name}-{ts}.log"
|
|
||||||
|
|
||||||
log_fh = open(log_file, "w", opener=lambda p, f: os.open(p, f, 0o600))
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
[
|
|
||||||
"claude",
|
|
||||||
"--dangerously-skip-permissions",
|
|
||||||
"--name", session_name,
|
|
||||||
"-p", prompt,
|
|
||||||
],
|
|
||||||
stdin=subprocess.PIPE,
|
|
||||||
stdout=log_fh,
|
|
||||||
stderr=log_fh,
|
|
||||||
start_new_session=True,
|
|
||||||
)
|
|
||||||
log_fh.close() # Parent closes its copy; the child retains the fd.
|
|
||||||
# Answer the workspace-trust dialog; after this the pipe hits EOF.
|
|
||||||
proc.stdin.write(b"\n")
|
|
||||||
proc.stdin.close()
|
|
||||||
|
|
||||||
print(f"Started agent pid={proc.pid}, log={log_file}")
|
|
||||||
print(f" Resume: run 'scripts/agent_loop.py list' to get the UUID-based resume command")
|
|
||||||
return proc.pid
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_alive(state: dict) -> bool:
|
|
||||||
"""Return True if the agent process is still running."""
|
|
||||||
pid = state.get("pid")
|
|
||||||
if pid is None:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
os.kill(pid, 0)
|
|
||||||
return True
|
|
||||||
except ProcessLookupError:
|
|
||||||
return False
|
|
||||||
except PermissionError:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _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")
|
|
||||||
uuid = _find_session_uuid(session_name) if session_name else None
|
|
||||||
if uuid:
|
|
||||||
resume_cmd = f"claude --resume {shlex.quote(uuid)}"
|
|
||||||
elif session_name:
|
|
||||||
resume_cmd = f"claude --resume <uuid> # run: scripts/agent_loop.py list"
|
|
||||||
else:
|
|
||||||
resume_cmd = ""
|
|
||||||
git_info = _git_summary()
|
|
||||||
parts = [
|
|
||||||
f"Agent pid={pid!r} ({kind}, {issue_ref}) still running ({age/60:.0f} min). Waiting.",
|
|
||||||
]
|
|
||||||
if resume_cmd:
|
|
||||||
parts.append(f" Resume: {resume_cmd}")
|
|
||||||
if git_info:
|
|
||||||
parts.append(f" Commit: {git_info}")
|
|
||||||
print("\n".join(parts))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# Agent not running (or no state) — extract any pending issue, then clean up.
|
|
||||||
pending_issue: int | None = None
|
|
||||||
ci_run_id_at_start: int | None = None
|
|
||||||
if state:
|
|
||||||
pending_issue = state.get("issue")
|
|
||||||
ci_run_id_at_start = state.get("ci_run_id_at_start")
|
|
||||||
_clear_state()
|
|
||||||
|
|
||||||
# ── 2. Check for a PR opened by the agent ────────────────────────────────
|
|
||||||
if pending_issue:
|
|
||||||
branch = f"issue-{pending_issue}-fix"
|
|
||||||
pr = _find_pr_for_branch(branch)
|
|
||||||
if pr:
|
|
||||||
pr_number = pr["number"]
|
|
||||||
pr_url = f"{REPO_URL}/pulls/{pr_number}"
|
|
||||||
print(f"Found PR #{pr_number} ({pr_url}) for issue #{pending_issue}.")
|
|
||||||
pr_run = _latest_ci_run_for_branch(branch)
|
|
||||||
|
|
||||||
if pr_run and pr_run.get("status") == "running":
|
|
||||||
print(f"CI run {_ci_run_url(pr_run['id'])} on branch {branch!r} is running. Waiting.")
|
|
||||||
_write_state(None, pending_issue, "pending-ci")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if pr_run and pr_run.get("status") in ("failure", "error"):
|
|
||||||
print(f"CI run {_ci_run_url(pr_run['id'])} on branch {branch!r} failed — starting fix agent.")
|
|
||||||
prompt = (
|
|
||||||
f"The Codeberg CI for guettli/sharedinbox just failed on branch {branch!r} "
|
|
||||||
f"(PR #{pr_number}). "
|
|
||||||
f"CI run: {_ci_run_url(pr_run['id'])}. "
|
|
||||||
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
|
|
||||||
"Identify the failure, fix it, commit, and push to the same branch. "
|
|
||||||
"Do NOT push to main, do NOT close the issue, do NOT merge the PR. "
|
|
||||||
"Verify locally with 'task check' before pushing. "
|
|
||||||
"When done, stop."
|
|
||||||
)
|
|
||||||
session_name = f"ci-fix-pr-{pr_number}"
|
|
||||||
pid = _start_agent(prompt, session_name)
|
|
||||||
_write_state(pid, pending_issue, "ci-fix", session_name=session_name)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if not pr_run:
|
|
||||||
# No CI run yet — might be that CI hasn't triggered yet.
|
|
||||||
# Wait up to 15 min before giving up.
|
|
||||||
pr_created_at = pr.get("created_at", "")
|
|
||||||
try:
|
|
||||||
created = datetime.fromisoformat(pr_created_at.replace("Z", "+00:00"))
|
|
||||||
age_s = (datetime.now(timezone.utc) - created).total_seconds()
|
|
||||||
except Exception:
|
|
||||||
age_s = 999999
|
|
||||||
if age_s < 900:
|
|
||||||
print(
|
|
||||||
f"PR #{pr_number} has no CI run yet (created {age_s/60:.0f} min ago). Waiting."
|
|
||||||
)
|
|
||||||
_write_state(None, pending_issue, "pending-ci")
|
|
||||||
return 0
|
|
||||||
print(
|
|
||||||
f"No CI run for branch {branch!r} after {age_s/60:.0f} min — "
|
|
||||||
"agent may not have pushed. Setting to State/Question."
|
|
||||||
)
|
|
||||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
|
||||||
_comment_issue(
|
|
||||||
pending_issue,
|
|
||||||
f"Agent opened PR #{pr_number} but no CI run appeared on branch `{branch}` "
|
|
||||||
f"after {age_s/60:.0f} min. The agent may not have pushed any commits. "
|
|
||||||
"Please investigate and resume manually.",
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# CI passed on the PR branch — squash-merge and close.
|
|
||||||
print(f"CI passed {_ci_run_url(pr_run['id'])} on branch {branch!r} — merging PR #{pr_number}.")
|
|
||||||
_merge_pr(pr_number)
|
|
||||||
_close_issue(pending_issue)
|
|
||||||
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# No open PR — check if it was already merged.
|
|
||||||
merged_pr = _find_pr_for_branch(branch, state="closed")
|
|
||||||
if merged_pr and merged_pr.get("merged"):
|
|
||||||
print(f"PR for branch {branch!r} was already merged — closing issue #{pending_issue}.")
|
|
||||||
_close_issue(pending_issue)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# No open or merged PR — the agent may not have created one, or it was
|
|
||||||
# closed without merging (the bug this block was added to catch).
|
|
||||||
print(
|
|
||||||
f"No open or merged PR found for branch {branch!r} "
|
|
||||||
f"(issue #{pending_issue}) — setting to State/Question."
|
|
||||||
)
|
|
||||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
|
||||||
_comment_issue(
|
|
||||||
pending_issue,
|
|
||||||
f"Agent finished but no open or merged PR was found for branch `{branch}`. "
|
|
||||||
"Please investigate and resume manually.",
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# ── 2b. Catch-up: scan open issue-N-fix PRs orphaned by a cleared state ─────
|
|
||||||
# This handles PRs whose CI has passed but were never merged because the
|
|
||||||
# state file was cleared (loop restart, killed agent, manual intervention).
|
|
||||||
open_prs = _open_issue_prs()
|
|
||||||
for pr in open_prs:
|
|
||||||
pr_number = pr["number"]
|
|
||||||
pr_url = f"{REPO_URL}/pulls/{pr_number}"
|
|
||||||
head = pr.get("head", {})
|
|
||||||
branch = head.get("ref") or head.get("label", "").split(":")[-1]
|
|
||||||
m = re.match(r"^issue-(\d+)-fix$", branch or "")
|
|
||||||
issue_num = int(m.group(1)) if m else None
|
|
||||||
pr_run = _latest_ci_run_for_pr(pr_number)
|
|
||||||
|
|
||||||
if pr_run and pr_run.get("status") == "running":
|
|
||||||
print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} still running. Waiting.")
|
|
||||||
_write_state(None, issue_num, "pending-ci")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if pr_run and pr_run.get("status") in ("failure", "error"):
|
|
||||||
print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} failed — skipping.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if pr_run and pr_run.get("status") == "success":
|
|
||||||
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
|
|
||||||
_merge_pr(pr_number)
|
|
||||||
if issue_num:
|
|
||||||
_close_issue(issue_num)
|
|
||||||
print(f"Merged PR #{pr_number} and closed issue #{issue_num}.")
|
|
||||||
else:
|
|
||||||
print(f"Merged PR #{pr_number}.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# ── 3. Global CI check (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)
|
|
||||||
ci_run_part = f" {_ci_run_url(run['id'])}" if run else ""
|
|
||||||
print(f"CI passed{ci_run_part} — closed {_issue_url(pending_issue)}.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# Find a Ready issue.
|
|
||||||
issues = _ready_issues()
|
|
||||||
if not issues:
|
|
||||||
print("No issues with State/Ready. Nothing to do.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
issue = issues[0]
|
|
||||||
issue_number = issue["number"]
|
|
||||||
issue_title = issue["title"]
|
|
||||||
issue_body = issue.get("body", "")
|
|
||||||
|
|
||||||
print(f"Starting agent for {_issue_url(issue_number)} {issue_title}")
|
|
||||||
|
|
||||||
# Mark InProgress before starting so the next cron tick sees it even if
|
|
||||||
# the agent hasn't had time to do so yet.
|
|
||||||
_set_labels(
|
|
||||||
issue_number,
|
|
||||||
add=[LABEL_IN_PROGRESS],
|
|
||||||
remove=[LABEL_READY],
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt = f"""Work on Codeberg issue #{issue_number} in the guettli/sharedinbox repository.
|
|
||||||
|
|
||||||
Issue title: {issue_title}
|
|
||||||
|
|
||||||
Issue body:
|
|
||||||
{issue_body}
|
|
||||||
|
|
||||||
Instructions:
|
|
||||||
- Understand the issue thoroughly before writing any code.
|
|
||||||
- Implement the required change, following the existing code style.
|
|
||||||
- Write or update tests as appropriate.
|
|
||||||
- Run 'task check' locally and fix any failures before committing.
|
|
||||||
- Commit with a descriptive message 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())
|
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
tmp=$(mktemp /dev/shm/keystore.XXXXXX.jks)
|
||||||
|
trap "rm -f $tmp" EXIT
|
||||||
|
|
||||||
|
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "$tmp"
|
||||||
|
|
||||||
|
ANDROID_KEYSTORE_PATH="$tmp" \
|
||||||
|
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"
|
||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verify that every container image referenced in ci/main.go is reachable.
|
||||||
|
# Runs skopeo inspect (manifest-only, no layer pull) for each From("...") call.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT=$(git rev-parse --show-toplevel)
|
||||||
|
FILE="$ROOT/ci/main.go"
|
||||||
|
|
||||||
|
# Static images from From("...") literals in ci/main.go
|
||||||
|
static_images=$(grep -oP 'From\("\K[^"]+' "$FILE" | grep -v ':$' | sort -u)
|
||||||
|
|
||||||
|
# Dynamic Flutter image derived from .fvmrc (not a literal in main.go)
|
||||||
|
FVMRC="$ROOT/.fvmrc"
|
||||||
|
flutter_version=$(python3 -c "import json; print(json.load(open('$FVMRC'))['flutter'])" 2>/dev/null || true)
|
||||||
|
flutter_image=""
|
||||||
|
if [ -n "$flutter_version" ]; then
|
||||||
|
flutter_image="ghcr.io/cirruslabs/flutter:$flutter_version"
|
||||||
|
fi
|
||||||
|
|
||||||
|
images=$(printf '%s\n%s\n' "$static_images" "$flutter_image" | grep -v '^$' | sort -u)
|
||||||
|
|
||||||
|
if [ -z "$images" ]; then
|
||||||
|
echo "check-ci-images: no From() image references found in $FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
while IFS= read -r image; do
|
||||||
|
printf "check-ci-images: %-55s" "$image"
|
||||||
|
if skopeo inspect --no-creds "docker://$image" > /dev/null 2>&1; then
|
||||||
|
echo "OK"
|
||||||
|
else
|
||||||
|
echo "NOT FOUND"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done <<< "$images"
|
||||||
|
|
||||||
|
if [ "$fail" -eq 1 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "ERROR: one or more container images in ci/main.go could not be resolved."
|
||||||
|
echo "Fix the image tag before committing."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -11,6 +11,7 @@ const _minCoveragePercent = 80;
|
|||||||
|
|
||||||
// Pure-abstract interfaces: no executable code, Dart VM never instruments them.
|
// Pure-abstract interfaces: no executable code, Dart VM never instruments them.
|
||||||
const _noCode = {
|
const _noCode = {
|
||||||
|
'lib/core/db_schema_version.dart',
|
||||||
'lib/core/repositories/account_repository.dart',
|
'lib/core/repositories/account_repository.dart',
|
||||||
'lib/core/repositories/draft_repository.dart',
|
'lib/core/repositories/draft_repository.dart',
|
||||||
'lib/core/repositories/email_repository.dart',
|
'lib/core/repositories/email_repository.dart',
|
||||||
@@ -19,7 +20,9 @@ const _noCode = {
|
|||||||
'lib/core/repositories/sync_log_repository.dart',
|
'lib/core/repositories/sync_log_repository.dart',
|
||||||
'lib/core/repositories/undo_repository.dart',
|
'lib/core/repositories/undo_repository.dart',
|
||||||
'lib/core/repositories/search_history_repository.dart',
|
'lib/core/repositories/search_history_repository.dart',
|
||||||
|
'lib/core/repositories/user_preferences_repository.dart',
|
||||||
'lib/core/models/undo_action.dart',
|
'lib/core/models/undo_action.dart',
|
||||||
|
'lib/core/models/user_preferences.dart',
|
||||||
'lib/core/storage/secure_storage.dart',
|
'lib/core/storage/secure_storage.dart',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,7 +41,9 @@ const _excluded = {
|
|||||||
'lib/ui/screens/account_send_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/bug_report_screen.dart',
|
||||||
'lib/ui/screens/changelog_screen.dart',
|
'lib/ui/screens/changelog_screen.dart',
|
||||||
|
'lib/ui/screens/combined_inbox_screen.dart',
|
||||||
'lib/ui/screens/compose_screen.dart',
|
'lib/ui/screens/compose_screen.dart',
|
||||||
'lib/ui/screens/crash_screen.dart',
|
'lib/ui/screens/crash_screen.dart',
|
||||||
'lib/ui/screens/edit_account_screen.dart',
|
'lib/ui/screens/edit_account_screen.dart',
|
||||||
@@ -57,6 +62,9 @@ const _excluded = {
|
|||||||
'lib/ui/widgets/try_connection_button.dart',
|
'lib/ui/widgets/try_connection_button.dart',
|
||||||
'lib/ui/widgets/undo_shell.dart',
|
'lib/ui/widgets/undo_shell.dart',
|
||||||
'lib/ui/screens/about_screen.dart',
|
'lib/ui/screens/about_screen.dart',
|
||||||
|
'lib/ui/screens/email_action_helpers.dart',
|
||||||
|
'lib/ui/utils/about_markdown.dart',
|
||||||
|
'lib/ui/widgets/email_headers_dialog.dart',
|
||||||
'lib/ui/widgets/email_tile.dart',
|
'lib/ui/widgets/email_tile.dart',
|
||||||
'lib/core/sync/account_sync_manager.dart',
|
'lib/core/sync/account_sync_manager.dart',
|
||||||
'lib/core/sync/background_sync.dart',
|
'lib/core/sync/background_sync.dart',
|
||||||
@@ -70,7 +78,12 @@ const _excluded = {
|
|||||||
'lib/data/repositories/sync_log_repository_impl.dart',
|
'lib/data/repositories/sync_log_repository_impl.dart',
|
||||||
'lib/data/repositories/undo_repository_impl.dart',
|
'lib/data/repositories/undo_repository_impl.dart',
|
||||||
'lib/data/repositories/search_history_repository_impl.dart',
|
'lib/data/repositories/search_history_repository_impl.dart',
|
||||||
|
'lib/data/repositories/user_preferences_repository_impl.dart',
|
||||||
|
'lib/ui/screens/user_preferences_screen.dart',
|
||||||
'lib/core/services/update_service.dart',
|
'lib/core/services/update_service.dart',
|
||||||
|
'lib/ui/widgets/email_thread_tile.dart',
|
||||||
|
'lib/ui/screens/trusted_image_senders_screen.dart',
|
||||||
|
'lib/ui/widgets/thread_tile.dart',
|
||||||
};
|
};
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|||||||
@@ -33,9 +33,6 @@ def list_remote_files(ssh_user: str, ssh_host: str, pattern: str) -> list[str]:
|
|||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[
|
[
|
||||||
"ssh",
|
"ssh",
|
||||||
"-v",
|
|
||||||
"-o", "StrictHostKeyChecking=no",
|
|
||||||
"-i", "/root/.ssh/id_ed25519",
|
|
||||||
f"{ssh_user}@{ssh_host}",
|
f"{ssh_user}@{ssh_host}",
|
||||||
f"find {REMOTE_BUILDS_DIR} -name '{pattern}' -type f | sort",
|
f"find {REMOTE_BUILDS_DIR} -name '{pattern}' -type f | sort",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ _filter_noise() {
|
|||||||
_run() {
|
_run() {
|
||||||
: > "$OUT" ; : > "$RC_FILE"
|
: > "$OUT" ; : > "$RC_FILE"
|
||||||
{
|
{
|
||||||
dagger call --progress=plain -q -m ci --source=. test-android-firebase \
|
timeout --kill-after=10 2400 dagger call --progress=plain -q -m ci --source=. test-android-firebase \
|
||||||
--service-account-key env:FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY \
|
--service-account-key env:FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY \
|
||||||
--project-id "$FIREBASE_PROJECT_ID"
|
--project-id "$FIREBASE_PROJECT_ID"
|
||||||
echo $? > "$RC_FILE"
|
echo $? > "$RC_FILE"
|
||||||
@@ -44,6 +44,10 @@ _run() {
|
|||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
_run && break
|
_run && break
|
||||||
RC=$(cat "$RC_FILE" 2>/dev/null || echo 1)
|
RC=$(cat "$RC_FILE" 2>/dev/null || echo 1)
|
||||||
|
if [ "$RC" -eq 124 ]; then
|
||||||
|
echo "::warning::[firebase] attempt $attempt/3 timed out after 2400s" >&2
|
||||||
|
exit 124
|
||||||
|
fi
|
||||||
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|No Dagger server responded" "$OUT"; then
|
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
|
echo "[firebase] dagger connectivity error on attempt $attempt/3, retrying..." >&2
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,102 +1,91 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Establishes a secure tunnel to a remote Dagger Engine via stunnel.
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
[ "${CI:-}" = "true" ] || [ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
|
||||||
|
|
||||||
if [ -z "${DAGGER_STUNNEL_URL:-}" ]; then
|
if [ -z "${SOPS_AGE_KEY:-}" ]; then
|
||||||
echo "Error: DAGGER_STUNNEL_URL must be set."
|
echo "Error: SOPS_AGE_KEY must be set."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Parse host and port (e.g., example.com:8774 or just example.com)
|
echo "Decrypting secrets with SOPS..."
|
||||||
host=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f1)
|
export SOPS_AGE_KEY="$SOPS_AGE_KEY"
|
||||||
port=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f2)
|
SECRETS_JSON=$(mktemp)
|
||||||
if [ "$host" == "$port" ]; then
|
trap "rm -f $SECRETS_JSON" EXIT
|
||||||
port="8774"
|
|
||||||
fi
|
|
||||||
|
|
||||||
MAX_PROBE_ATTEMPTS=5
|
sops --decrypt --output-type json secrets.enc.yaml > "$SECRETS_JSON"
|
||||||
PROBE_DELAY=30
|
|
||||||
for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
|
|
||||||
echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..."
|
|
||||||
if nc -zw 5 "$host" "$port" 2>/dev/null; then
|
|
||||||
echo "Found active server on $host:$port"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then
|
|
||||||
echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts"
|
|
||||||
echo "Remote engine unavailable — CI will use the local Dagger engine."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Dagger server not responding, waiting ${PROBE_DELAY}s before retry..."
|
|
||||||
sleep $PROBE_DELAY
|
|
||||||
done
|
|
||||||
|
|
||||||
# 2a. Try plain TCP connection first (works when server is a plain TCP proxy, no TLS)
|
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
|
||||||
echo "Trying plain TCP Dagger connection at tcp://$host:$port..."
|
DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON")
|
||||||
if _DAGGER_RUNNER_HOST="tcp://$host:$port" \
|
|
||||||
_EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" \
|
# Export all CI secrets to the GitHub Actions environment so subsequent steps
|
||||||
timeout 8 dagger version >/dev/null 2>&1; then
|
# can use them without referencing Forgejo secrets directly.
|
||||||
echo "Plain TCP Dagger connection succeeded — no TLS stunnel needed."
|
export_secret() {
|
||||||
|
local name="$1"
|
||||||
|
local value
|
||||||
|
value=$(jq -r --arg k "$name" '.[$k] // empty' "$SECRETS_JSON")
|
||||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||||
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
|
# Use heredoc syntax for multiline-safe export.
|
||||||
echo "_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
|
# Avoid adding a second trailing newline for values that already end with one
|
||||||
else
|
# (e.g. SSH private keys), which can corrupt PEM parsing.
|
||||||
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port"
|
{
|
||||||
export _DAGGER_RUNNER_HOST="tcp://$host:$port"
|
printf '%s<<__EOF__\n' "$name"
|
||||||
echo "Dagger configured at tcp://$host:$port (plain TCP)"
|
printf '%s' "$value"
|
||||||
|
[ "${value%$'\n'}" = "$value" ] && printf '\n'
|
||||||
|
printf '__EOF__\n'
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
fi
|
fi
|
||||||
exit 0
|
printf '[secrets] exported %s (%d chars)\n' "$name" "${#value}"
|
||||||
|
}
|
||||||
|
|
||||||
|
export_secret "SSH_PRIVATE_KEY"
|
||||||
|
export_secret "SSH_KNOWN_HOSTS"
|
||||||
|
export_secret "SSH_USER"
|
||||||
|
export_secret "SSH_HOST"
|
||||||
|
export_secret "WEBSITE_SSH_HOST"
|
||||||
|
export_secret "PLAY_STORE_CONFIG_JSON"
|
||||||
|
export_secret "ANDROID_KEYSTORE_BASE64"
|
||||||
|
export_secret "ANDROID_KEYSTORE_PASSWORD"
|
||||||
|
export_secret "FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY"
|
||||||
|
export_secret "RENOVATE_FORGEJO_TOKEN"
|
||||||
|
|
||||||
|
# Setup SSH directory and keys
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
rm -f ~/.ssh/dagger_key
|
||||||
|
echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key
|
||||||
|
chmod 600 ~/.ssh/dagger_key
|
||||||
|
|
||||||
|
# Add remote host to known_hosts
|
||||||
|
_t0=$SECONDS
|
||||||
|
timeout 30 ssh-keyscan -H "$DAGGER_ENGINE_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
_elapsed=$(( SECONDS - _t0 ))
|
||||||
|
if [ "$_elapsed" -gt 10 ]; then
|
||||||
|
echo "::warning::ssh-keyscan took ${_elapsed}s — Dagger engine host may be slow to respond"
|
||||||
fi
|
fi
|
||||||
echo "Plain TCP connection not available; trying TLS stunnel..."
|
|
||||||
|
|
||||||
# 2b. Setup TLS credentials (passed as env vars from secrets)
|
# Create a background SSH tunnel to the Dagger engine.
|
||||||
mkdir -p /tmp/dagger-tls
|
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
|
||||||
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
|
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
|
||||||
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
|
_t0=$SECONDS
|
||||||
echo "$DAGGER_CLIENT_KEY" > /tmp/dagger-tls/client.key
|
timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
|
||||||
chmod 600 /tmp/dagger-tls/client.key
|
_elapsed=$(( SECONDS - _t0 ))
|
||||||
|
if [ "$_elapsed" -gt 10 ]; then
|
||||||
|
echo "::warning::SSH tunnel setup took ${_elapsed}s"
|
||||||
|
fi
|
||||||
|
|
||||||
# 3. Configure and start stunnel
|
# Export _EXPERIMENTAL_DAGGER_RUNNER_HOST to use the tunnel.
|
||||||
STUNNEL_CONF="/tmp/stunnel-dagger.conf"
|
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://localhost:8080"
|
||||||
cat << EOF > "$STUNNEL_CONF"
|
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||||
client = yes
|
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://localhost:8080" >> "$GITHUB_ENV"
|
||||||
foreground = yes
|
fi
|
||||||
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]
|
# Verify the connection
|
||||||
accept = 127.0.0.1:1774
|
echo "Verifying connection to Dagger engine via SSH tunnel..."
|
||||||
connect = $host:$port
|
# Use a simple command that doesn't require complex GraphQL operations.
|
||||||
CAfile = /tmp/dagger-tls/ca.crt
|
if ! timeout 45 dagger core --help >/dev/null 2>&1 ; then
|
||||||
cert = /tmp/dagger-tls/client.crt
|
echo "Error: Dagger engine unreachable via tunnel at localhost:8080"
|
||||||
key = /tmp/dagger-tls/client.key
|
# Debug
|
||||||
verifyChain = yes
|
ps aux | grep ssh
|
||||||
EOF
|
|
||||||
|
|
||||||
# Start stunnel in the background
|
|
||||||
stunnel "$STUNNEL_CONF" &
|
|
||||||
TUNNEL_PID=$!
|
|
||||||
|
|
||||||
# Give it a moment to establish
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
if ! kill -0 "$TUNNEL_PID" 2>/dev/null; then
|
|
||||||
echo "Error: stunnel failed to start"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
echo "Dagger connection verified successfully."
|
||||||
# 4. Export environment for subsequent CI steps
|
|
||||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
|
||||||
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
|
|
||||||
echo "_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
|
|
||||||
echo "Tunnel established. Dagger is configured to use the remote engine."
|
|
||||||
else
|
|
||||||
export _EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
|
||||||
export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
|
||||||
echo "Tunnel established. Run: export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774"
|
|
||||||
fi
|
|
||||||
|
|||||||
@@ -1,671 +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 _open_pr(self, branch: str = "issue-10-fix") -> dict:
|
|
||||||
return {"number": 5, "head": {"ref": branch}, "created_at": "2026-01-01T00:00:00+00:00"}
|
|
||||||
|
|
||||||
def _find_pr_open(self, branch, state="open"):
|
|
||||||
if state == "open":
|
|
||||||
return self._open_pr(branch)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def test_closes_issue_when_ci_passes_after_agent_finishes(self):
|
|
||||||
"""After issue agent finishes, loop merges the PR and closes the issue once CI is green."""
|
|
||||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
|
||||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
|
||||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
|
||||||
patch("agent_loop._merge_pr") as mock_merge, \
|
|
||||||
patch("agent_loop._close_issue") as mock_close, \
|
|
||||||
patch("agent_loop._clear_state"):
|
|
||||||
result = agent_loop._run_loop()
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
mock_merge.assert_called_once_with(5)
|
|
||||||
mock_close.assert_called_once_with(10)
|
|
||||||
|
|
||||||
def test_ci_passed_output_includes_ci_run_url(self):
|
|
||||||
"""'CI passed' line includes the CI run URL when a run is available."""
|
|
||||||
buf = io.StringIO()
|
|
||||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
|
||||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
|
||||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 4145144, "status": "success"}), \
|
|
||||||
patch("agent_loop._merge_pr"), \
|
|
||||||
patch("agent_loop._close_issue"), \
|
|
||||||
patch("agent_loop._clear_state"), \
|
|
||||||
contextlib.redirect_stdout(buf):
|
|
||||||
agent_loop._run_loop()
|
|
||||||
output = buf.getvalue()
|
|
||||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144", output)
|
|
||||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/10", output)
|
|
||||||
|
|
||||||
def test_already_merged_pr_closes_issue_without_ci_url(self):
|
|
||||||
"""When the PR was already merged, the issue is closed and no CI run URL appears."""
|
|
||||||
def find_pr(branch, state="open"):
|
|
||||||
if state == "closed":
|
|
||||||
return {"number": 5, "merged": True}
|
|
||||||
return None
|
|
||||||
|
|
||||||
buf = io.StringIO()
|
|
||||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
|
||||||
patch("agent_loop._find_pr_for_branch", side_effect=find_pr), \
|
|
||||||
patch("agent_loop._close_issue") as mock_close, \
|
|
||||||
patch("agent_loop._clear_state"), \
|
|
||||||
contextlib.redirect_stdout(buf):
|
|
||||||
result = agent_loop._run_loop()
|
|
||||||
output = buf.getvalue()
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
mock_close.assert_called_once_with(10)
|
|
||||||
self.assertIn("already merged", output)
|
|
||||||
self.assertNotIn("/actions/runs/", output)
|
|
||||||
|
|
||||||
def test_no_pr_found_sets_question_label(self):
|
|
||||||
"""When no open or merged PR exists for the pending branch, set State/Question."""
|
|
||||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
|
||||||
patch("agent_loop._find_pr_for_branch", return_value=None), \
|
|
||||||
patch("agent_loop._set_labels") as mock_labels, \
|
|
||||||
patch("agent_loop._comment_issue") as mock_comment, \
|
|
||||||
patch("agent_loop._close_issue") as mock_close, \
|
|
||||||
patch("agent_loop._clear_state"):
|
|
||||||
result = agent_loop._run_loop()
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
mock_close.assert_not_called()
|
|
||||||
mock_labels.assert_called_once_with(
|
|
||||||
10,
|
|
||||||
add=[agent_loop.LABEL_QUESTION],
|
|
||||||
remove=[agent_loop.LABEL_IN_PROGRESS],
|
|
||||||
)
|
|
||||||
mock_comment.assert_called_once()
|
|
||||||
self.assertIn("issue-10-fix", mock_comment.call_args[0][1])
|
|
||||||
|
|
||||||
def test_does_not_close_issue_when_ci_fails(self):
|
|
||||||
"""After issue agent finishes, loop must NOT close the issue if CI failed on PR branch."""
|
|
||||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
|
||||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
|
||||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "failure"}), \
|
|
||||||
patch("agent_loop._close_issue") as mock_close, \
|
|
||||||
patch("agent_loop._start_agent", return_value=55), \
|
|
||||||
patch("agent_loop._write_state"), \
|
|
||||||
patch("agent_loop._clear_state"):
|
|
||||||
result = agent_loop._run_loop()
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
mock_close.assert_not_called()
|
|
||||||
|
|
||||||
def test_saves_pending_ci_state_while_ci_running(self):
|
|
||||||
"""When CI is still running on PR branch after agent finishes, pending issue is preserved."""
|
|
||||||
written = {}
|
|
||||||
|
|
||||||
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
|
|
||||||
written["pid"] = pid
|
|
||||||
written["issue"] = issue
|
|
||||||
written["kind"] = kind
|
|
||||||
|
|
||||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
|
||||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
|
||||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "running"}), \
|
|
||||||
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
|
||||||
patch("agent_loop._clear_state"):
|
|
||||||
result = agent_loop._run_loop()
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertEqual(written.get("issue"), 10)
|
|
||||||
self.assertEqual(written.get("kind"), "pending-ci")
|
|
||||||
self.assertIsNone(written.get("pid"))
|
|
||||||
|
|
||||||
def test_ci_fix_preserves_pending_issue_in_state(self):
|
|
||||||
"""When CI fails on PR branch after agent finishes, ci-fix state includes the pending issue."""
|
|
||||||
written = {}
|
|
||||||
|
|
||||||
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
|
|
||||||
written["pid"] = pid
|
|
||||||
written["issue"] = issue
|
|
||||||
written["kind"] = kind
|
|
||||||
|
|
||||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
|
||||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
|
||||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "failure"}), \
|
|
||||||
patch("agent_loop._start_agent", return_value=55), \
|
|
||||||
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
|
||||||
patch("agent_loop._clear_state"):
|
|
||||||
result = agent_loop._run_loop()
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertEqual(written.get("issue"), 10)
|
|
||||||
self.assertEqual(written.get("kind"), "ci-fix")
|
|
||||||
|
|
||||||
def test_closes_issue_after_ci_fix_and_ci_passes(self):
|
|
||||||
"""After ci-fix agent finishes and CI passes on PR branch, the pending issue is closed."""
|
|
||||||
with patch("agent_loop._read_state", return_value=self._dead_state(10, "ci-fix")), \
|
|
||||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
|
||||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
|
||||||
patch("agent_loop._merge_pr") as mock_merge, \
|
|
||||||
patch("agent_loop._close_issue") as mock_close, \
|
|
||||||
patch("agent_loop._clear_state"):
|
|
||||||
result = agent_loop._run_loop()
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
mock_merge.assert_called_once_with(5)
|
|
||||||
mock_close.assert_called_once_with(10)
|
|
||||||
|
|
||||||
def test_no_pending_issue_ci_fix_without_issue(self):
|
|
||||||
"""ci-fix for a manual push (no pending issue) does not try to close anything."""
|
|
||||||
with patch("agent_loop._read_state", return_value={
|
|
||||||
"pid": 999999999, "issue": None, "started_at": "2026-01-01T00:00:00+00:00",
|
|
||||||
"type": "ci-fix",
|
|
||||||
}), \
|
|
||||||
patch("agent_loop._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)
|
|
||||||
|
|
||||||
|
|
||||||
class TestFindSessionUuid(unittest.TestCase):
|
|
||||||
"""Tests for _find_session_uuid()."""
|
|
||||||
|
|
||||||
def _write_jsonl(self, directory: Path, filename: str, entries: list) -> Path:
|
|
||||||
path = directory / filename
|
|
||||||
with path.open("w") as fh:
|
|
||||||
for entry in entries:
|
|
||||||
fh.write(json.dumps(entry) + "\n")
|
|
||||||
return path
|
|
||||||
|
|
||||||
def test_returns_uuid_for_matching_session_name(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
projects_dir = Path(tmpdir)
|
|
||||||
self._write_jsonl(projects_dir, "abc123.jsonl", [
|
|
||||||
{"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-abc-123"},
|
|
||||||
])
|
|
||||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
|
||||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
|
||||||
try:
|
|
||||||
result = agent_loop._find_session_uuid("issue-91")
|
|
||||||
finally:
|
|
||||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
|
||||||
self.assertEqual(result, "uuid-abc-123")
|
|
||||||
|
|
||||||
def test_returns_none_when_name_does_not_match(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
projects_dir = Path(tmpdir)
|
|
||||||
self._write_jsonl(projects_dir, "abc123.jsonl", [
|
|
||||||
{"type": "agent-name", "agentName": "issue-99", "sessionId": "uuid-abc-123"},
|
|
||||||
])
|
|
||||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
|
||||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
|
||||||
try:
|
|
||||||
result = agent_loop._find_session_uuid("issue-91")
|
|
||||||
finally:
|
|
||||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
|
||||||
self.assertIsNone(result)
|
|
||||||
|
|
||||||
def test_returns_none_when_directory_missing(self):
|
|
||||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
|
||||||
agent_loop.CLAUDE_PROJECTS_DIR = Path("/nonexistent/path/that/does/not/exist")
|
|
||||||
try:
|
|
||||||
result = agent_loop._find_session_uuid("issue-91")
|
|
||||||
finally:
|
|
||||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
|
||||||
self.assertIsNone(result)
|
|
||||||
|
|
||||||
def test_returns_none_when_no_agent_name_entry(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
projects_dir = Path(tmpdir)
|
|
||||||
self._write_jsonl(projects_dir, "abc123.jsonl", [
|
|
||||||
{"type": "message", "content": "hello"},
|
|
||||||
])
|
|
||||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
|
||||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
|
||||||
try:
|
|
||||||
result = agent_loop._find_session_uuid("issue-91")
|
|
||||||
finally:
|
|
||||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
|
||||||
self.assertIsNone(result)
|
|
||||||
|
|
||||||
def test_scans_multiple_files_to_find_match(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
projects_dir = Path(tmpdir)
|
|
||||||
self._write_jsonl(projects_dir, "aaa.jsonl", [
|
|
||||||
{"type": "agent-name", "agentName": "issue-10", "sessionId": "uuid-10"},
|
|
||||||
])
|
|
||||||
self._write_jsonl(projects_dir, "bbb.jsonl", [
|
|
||||||
{"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-91"},
|
|
||||||
])
|
|
||||||
orig = agent_loop.CLAUDE_PROJECTS_DIR
|
|
||||||
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
|
|
||||||
try:
|
|
||||||
result = agent_loop._find_session_uuid("issue-91")
|
|
||||||
finally:
|
|
||||||
agent_loop.CLAUDE_PROJECTS_DIR = orig
|
|
||||||
self.assertEqual(result, "uuid-91")
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunLoopResumeCommand(unittest.TestCase):
|
|
||||||
"""Tests that _run_loop() shows a UUID-based resume command when agent is running."""
|
|
||||||
|
|
||||||
def _alive_state(self, session_name="issue-91"):
|
|
||||||
return {
|
|
||||||
"pid": os.getpid(), # own PID is always alive
|
|
||||||
"issue": 91,
|
|
||||||
"started_at": "2026-05-23T12:00:00+00:00",
|
|
||||||
"type": "issue",
|
|
||||||
"session_name": session_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_resume_shows_uuid_when_found(self):
|
|
||||||
buf = io.StringIO()
|
|
||||||
fake_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
|
||||||
with patch("agent_loop._read_state", return_value=self._alive_state()), \
|
|
||||||
patch("agent_loop._agent_alive", return_value=True), \
|
|
||||||
patch("agent_loop._agent_age_seconds", return_value=600), \
|
|
||||||
patch("agent_loop._find_session_uuid", return_value=fake_uuid), \
|
|
||||||
patch("agent_loop._git_summary", return_value=""), \
|
|
||||||
contextlib.redirect_stdout(buf):
|
|
||||||
agent_loop._run_loop()
|
|
||||||
output = buf.getvalue()
|
|
||||||
self.assertIn(f"claude --resume {fake_uuid}", output)
|
|
||||||
|
|
||||||
def test_resume_shows_list_hint_when_uuid_not_found(self):
|
|
||||||
buf = io.StringIO()
|
|
||||||
with patch("agent_loop._read_state", return_value=self._alive_state()), \
|
|
||||||
patch("agent_loop._agent_alive", return_value=True), \
|
|
||||||
patch("agent_loop._agent_age_seconds", return_value=600), \
|
|
||||||
patch("agent_loop._find_session_uuid", return_value=None), \
|
|
||||||
patch("agent_loop._git_summary", return_value=""), \
|
|
||||||
contextlib.redirect_stdout(buf):
|
|
||||||
agent_loop._run_loop()
|
|
||||||
output = buf.getvalue()
|
|
||||||
self.assertIn("scripts/agent_loop.py list", output)
|
|
||||||
# Must NOT show the session name as a valid resume argument.
|
|
||||||
self.assertNotIn("claude --resume issue-91", output)
|
|
||||||
|
|
||||||
def test_resume_not_shown_when_no_session_name(self):
|
|
||||||
state = self._alive_state()
|
|
||||||
del state["session_name"]
|
|
||||||
buf = io.StringIO()
|
|
||||||
with patch("agent_loop._read_state", return_value=state), \
|
|
||||||
patch("agent_loop._agent_alive", return_value=True), \
|
|
||||||
patch("agent_loop._agent_age_seconds", return_value=600), \
|
|
||||||
patch("agent_loop._find_session_uuid", return_value=None), \
|
|
||||||
patch("agent_loop._git_summary", return_value=""), \
|
|
||||||
contextlib.redirect_stdout(buf):
|
|
||||||
agent_loop._run_loop()
|
|
||||||
output = buf.getvalue()
|
|
||||||
self.assertNotIn("Resume:", output)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for verify_playstore_deploy.py."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
import verify_playstore_deploy
|
||||||
|
|
||||||
|
|
||||||
|
def _make_session(version_code, track="internal"):
|
||||||
|
"""Return a mock AuthorizedSession with the given version code on the track."""
|
||||||
|
session = MagicMock()
|
||||||
|
|
||||||
|
edit_resp = MagicMock()
|
||||||
|
edit_resp.json.return_value = {"id": "edit-99"}
|
||||||
|
session.post.return_value = edit_resp
|
||||||
|
|
||||||
|
track_resp = MagicMock()
|
||||||
|
track_resp.json.return_value = {
|
||||||
|
"releases": [{"versionCodes": [str(version_code)], "status": "completed"}]
|
||||||
|
}
|
||||||
|
session.get.return_value = track_resp
|
||||||
|
session.delete.return_value = MagicMock()
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
class TestMissingEnv(unittest.TestCase):
|
||||||
|
def test_missing_env_exits(self):
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
with self.assertRaises(SystemExit) as ctx:
|
||||||
|
verify_playstore_deploy.main()
|
||||||
|
self.assertEqual(ctx.exception.code, 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecentDeploy(unittest.TestCase):
|
||||||
|
def _run(self, version_code):
|
||||||
|
session = _make_session(version_code)
|
||||||
|
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
|
||||||
|
with patch("verify_playstore_deploy.service_account.Credentials.from_service_account_info"):
|
||||||
|
with patch("verify_playstore_deploy.AuthorizedSession", return_value=session):
|
||||||
|
verify_playstore_deploy.main()
|
||||||
|
|
||||||
|
def test_recent_version_code_passes(self):
|
||||||
|
# Version code is Unix timestamp — a very recent one should pass.
|
||||||
|
recent_vc = int(time.time()) - 60 # 1 minute ago
|
||||||
|
self._run(recent_vc)
|
||||||
|
|
||||||
|
def test_old_version_code_fails(self):
|
||||||
|
old_vc = int(time.time()) - 7200 # 2 hours ago
|
||||||
|
with self.assertRaises(SystemExit) as ctx:
|
||||||
|
self._run(old_vc)
|
||||||
|
self.assertEqual(ctx.exception.code, 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmptyTrack(unittest.TestCase):
|
||||||
|
def _run_empty(self, releases):
|
||||||
|
session = MagicMock()
|
||||||
|
session.post.return_value = MagicMock(**{"json.return_value": {"id": "edit-1"}})
|
||||||
|
session.get.return_value = MagicMock(**{"json.return_value": {"releases": releases}})
|
||||||
|
session.delete.return_value = MagicMock()
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
|
||||||
|
with patch("verify_playstore_deploy.service_account.Credentials.from_service_account_info"):
|
||||||
|
with patch("verify_playstore_deploy.AuthorizedSession", return_value=session):
|
||||||
|
verify_playstore_deploy.main()
|
||||||
|
|
||||||
|
def test_no_releases_exits(self):
|
||||||
|
with self.assertRaises(SystemExit) as ctx:
|
||||||
|
self._run_empty([])
|
||||||
|
self.assertEqual(ctx.exception.code, 1)
|
||||||
|
|
||||||
|
def test_release_with_no_version_codes_exits(self):
|
||||||
|
with self.assertRaises(SystemExit) as ctx:
|
||||||
|
self._run_empty([{"status": "completed", "versionCodes": []}])
|
||||||
|
self.assertEqual(ctx.exception.code, 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify that the Android app was recently published to the Play Store internal track.
|
||||||
|
|
||||||
|
The publish-android pipeline sets versionCode = int(time.Now().Unix()), so a
|
||||||
|
freshly deployed release always has a version code close to the current Unix
|
||||||
|
timestamp. This script queries the internal track and fails if the latest
|
||||||
|
version code is older than _MAX_DEPLOY_AGE_SECONDS, which would mean the
|
||||||
|
deployment silently did not land.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from google.auth.transport.requests import AuthorizedSession
|
||||||
|
from google.oauth2 import service_account
|
||||||
|
|
||||||
|
PACKAGE_NAME = "de.sharedinbox.mua"
|
||||||
|
TRACK = "internal"
|
||||||
|
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
|
||||||
|
# Allow up to one hour for the build + upload to complete.
|
||||||
|
_MAX_DEPLOY_AGE_SECONDS = 3600
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
config_json = os.environ.get("PLAY_STORE_CONFIG_JSON")
|
||||||
|
if not config_json:
|
||||||
|
print("Error: PLAY_STORE_CONFIG_JSON environment variable not set", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
creds = service_account.Credentials.from_service_account_info(
|
||||||
|
json.loads(config_json),
|
||||||
|
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
||||||
|
)
|
||||||
|
session = AuthorizedSession(creds)
|
||||||
|
|
||||||
|
# Open a read-only edit to query the current track state.
|
||||||
|
edit_resp = session.post(f"{_BASE}/{PACKAGE_NAME}/edits", json={}, timeout=30)
|
||||||
|
edit_resp.raise_for_status()
|
||||||
|
edit_id = edit_resp.json()["id"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
track_resp = session.get(
|
||||||
|
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
track_resp.raise_for_status()
|
||||||
|
track_data = track_resp.json()
|
||||||
|
finally:
|
||||||
|
# Discard the edit — we made no changes.
|
||||||
|
try:
|
||||||
|
session.delete(f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}", timeout=30)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
releases = track_data.get("releases", [])
|
||||||
|
if not releases:
|
||||||
|
print(
|
||||||
|
f"ERROR: No releases found on {TRACK} track — deploy may have failed silently",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
all_version_codes = [
|
||||||
|
int(vc)
|
||||||
|
for release in releases
|
||||||
|
for vc in release.get("versionCodes", [])
|
||||||
|
]
|
||||||
|
if not all_version_codes:
|
||||||
|
print("ERROR: Latest release has no version codes", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
latest_vc = max(all_version_codes)
|
||||||
|
now = int(time.time())
|
||||||
|
# versionCode is set to Unix timestamp by PublishAndroid in ci/main.go.
|
||||||
|
age_seconds = now - latest_vc
|
||||||
|
|
||||||
|
print(f"Latest version code on {TRACK} track: {latest_vc}")
|
||||||
|
print(f"Current time: {now} — version code age: {age_seconds}s")
|
||||||
|
|
||||||
|
if age_seconds > _MAX_DEPLOY_AGE_SECONDS:
|
||||||
|
print(
|
||||||
|
f"::error::Latest version code {latest_vc} is {age_seconds}s old "
|
||||||
|
f"(limit: {_MAX_DEPLOY_AGE_SECONDS}s). The deploy may have failed silently.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"OK: version {latest_vc} verified on {TRACK} track ({age_seconds}s old)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user