Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a2a6c7b49 | ||
|
|
f1f7de7b4d | ||
|
|
de2b9d22b4 | ||
|
|
0297701829 | ||
|
|
ee238b85c7 | ||
|
|
f0eff7dc7c | ||
|
|
517f7a6aa8 | ||
|
|
8ea5237991 | ||
|
|
1e5093b631 | ||
|
|
c1ee8ec1f4 | ||
|
|
7ce9eddabf | ||
|
|
8592bba9e3 | ||
|
|
13a0c99f57 | ||
|
|
41c8196a97 | ||
|
|
38f7ada8b5 | ||
|
|
a227f8607c | ||
|
|
5db5d957ab | ||
|
|
0dd1d7232b | ||
|
|
282a64b4c3 | ||
|
|
8e26715658 |
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "SharedInbox Dev",
|
||||||
|
"build": {
|
||||||
|
"dockerfile": "../Dockerfile.dev",
|
||||||
|
"context": ".."
|
||||||
|
},
|
||||||
|
"workspaceFolder": "/src",
|
||||||
|
"workspaceMount": "source=${localWorkspaceFolder},target=/src,type=bind,consistency=cached",
|
||||||
|
"remoteUser": "ci"
|
||||||
|
}
|
||||||
@@ -19,14 +19,14 @@ jobs:
|
|||||||
RUN_NUMBER: ${{ github.run_number }}
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
run: |
|
run: |
|
||||||
runner_start=$(date +%s)
|
runner_start=$(date +%s)
|
||||||
created_at=$(curl -sf \
|
created=$(curl -sf --max-time 30 \
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
if [ -n "$created_at" ]; then
|
if [ -n "$created" ]; then
|
||||||
queued_epoch=$(date -d "$created_at" +%s)
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
wait_seconds=$((runner_start - queued_epoch))
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
else
|
else
|
||||||
echo "Runner wait time: unknown (API lookup failed)"
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ jobs:
|
|||||||
RUN_NUMBER: ${{ github.run_number }}
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
run: |
|
run: |
|
||||||
runner_start=$(date +%s)
|
runner_start=$(date +%s)
|
||||||
created_at=$(curl -sf \
|
created=$(curl -sf --max-time 30 \
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
if [ -n "$created_at" ]; then
|
if [ -n "$created" ]; then
|
||||||
queued_epoch=$(date -d "$created_at" +%s)
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
wait_seconds=$((runner_start - queued_epoch))
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
else
|
else
|
||||||
echo "Runner wait time: unknown (API lookup failed)"
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
fi
|
fi
|
||||||
@@ -51,43 +51,27 @@ jobs:
|
|||||||
|
|
||||||
HEAD_SHA=$(git rev-parse HEAD)
|
HEAD_SHA=$(git rev-parse HEAD)
|
||||||
|
|
||||||
# Find the most recent workflow run where deploy-playstore actually succeeded
|
# Find the most recent successful "Build & Deploy to Play Store" task. Forgejo's API
|
||||||
# (not merely skipped). Bug fix: previous code used commit_sha (always None in
|
# does not expose per-run jobs (/runs/{id}/jobs returns 404), so query /actions/tasks
|
||||||
# Forgejo's API) instead of head_sha, causing LAST_DEPLOYED_SHA to be empty on
|
# (per-job records) directly and filter for the task we care about. Filtering at the
|
||||||
# every run and the fallback diff to only cover HEAD~1..HEAD.
|
# task level also distinguishes runs where the Play Store job actually ran from runs
|
||||||
|
# where it was skipped — at the run level both show status=success.
|
||||||
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
|
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
|
||||||
import json, os, sys, urllib.request
|
import json, os, sys, urllib.request
|
||||||
token = os.environ.get("FORGEJO_TOKEN", "")
|
token = os.environ.get("FORGEJO_TOKEN", "")
|
||||||
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||||
base_api = f"{server}/api/v1/repos/{repo}/actions"
|
url = f"{server}/api/v1/repos/{repo}/actions/tasks?status=success&limit=100"
|
||||||
url = f"{base_api}/runs?workflow_id=deploy.yml&status=success&limit=10"
|
|
||||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req) as r:
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
data = json.loads(r.read())
|
data = json.loads(r.read())
|
||||||
runs = [
|
for t in data.get("workflow_runs", []):
|
||||||
r for r in data.get("workflow_runs", [])
|
if (t.get("workflow_id") == "deploy.yml"
|
||||||
if r.get("status") == "success"
|
and t.get("name") == "Build & Deploy to Play Store"
|
||||||
]
|
and t.get("status") == "success"):
|
||||||
# Walk runs newest-first; pick the first one where deploy-playstore
|
print(t.get("head_sha") or "")
|
||||||
# actually ran (conclusion=success), not just skipped.
|
sys.exit(0)
|
||||||
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("")
|
print("")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
|
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
|
||||||
@@ -164,14 +148,14 @@ jobs:
|
|||||||
RUN_NUMBER: ${{ github.run_number }}
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
run: |
|
run: |
|
||||||
runner_start=$(date +%s)
|
runner_start=$(date +%s)
|
||||||
created_at=$(curl -sf \
|
created=$(curl -sf --max-time 30 \
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
if [ -n "$created_at" ]; then
|
if [ -n "$created" ]; then
|
||||||
queued_epoch=$(date -d "$created_at" +%s)
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
wait_seconds=$((runner_start - queued_epoch))
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
else
|
else
|
||||||
echo "Runner wait time: unknown (API lookup failed)"
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
fi
|
fi
|
||||||
@@ -215,14 +199,14 @@ jobs:
|
|||||||
RUN_NUMBER: ${{ github.run_number }}
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
run: |
|
run: |
|
||||||
runner_start=$(date +%s)
|
runner_start=$(date +%s)
|
||||||
created_at=$(curl -sf \
|
created=$(curl -sf --max-time 30 \
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
if [ -n "$created_at" ]; then
|
if [ -n "$created" ]; then
|
||||||
queued_epoch=$(date -d "$created_at" +%s)
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
wait_seconds=$((runner_start - queued_epoch))
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
else
|
else
|
||||||
echo "Runner wait time: unknown (API lookup failed)"
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
fi
|
fi
|
||||||
@@ -260,14 +244,14 @@ jobs:
|
|||||||
RUN_NUMBER: ${{ github.run_number }}
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
run: |
|
run: |
|
||||||
runner_start=$(date +%s)
|
runner_start=$(date +%s)
|
||||||
created_at=$(curl -sf \
|
created=$(curl -sf --max-time 30 \
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
if [ -n "$created_at" ]; then
|
if [ -n "$created" ]; then
|
||||||
queued_epoch=$(date -d "$created_at" +%s)
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
wait_seconds=$((runner_start - queued_epoch))
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
else
|
else
|
||||||
echo "Runner wait time: unknown (API lookup failed)"
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
fi
|
fi
|
||||||
@@ -310,14 +294,14 @@ jobs:
|
|||||||
RUN_NUMBER: ${{ github.run_number }}
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
run: |
|
run: |
|
||||||
runner_start=$(date +%s)
|
runner_start=$(date +%s)
|
||||||
created_at=$(curl -sf \
|
created=$(curl -sf --max-time 30 \
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
if [ -n "$created_at" ]; then
|
if [ -n "$created" ]; then
|
||||||
queued_epoch=$(date -d "$created_at" +%s)
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
wait_seconds=$((runner_start - queued_epoch))
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
else
|
else
|
||||||
echo "Runner wait time: unknown (API lookup failed)"
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ jobs:
|
|||||||
RUN_NUMBER: ${{ github.run_number }}
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
run: |
|
run: |
|
||||||
runner_start=$(date +%s)
|
runner_start=$(date +%s)
|
||||||
created_at=$(curl -sf \
|
created=$(curl -sf --max-time 30 \
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
if [ -n "$created_at" ]; then
|
if [ -n "$created" ]; then
|
||||||
queued_epoch=$(date -d "$created_at" +%s)
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
wait_seconds=$((runner_start - queued_epoch))
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
else
|
else
|
||||||
echo "Runner wait time: unknown (API lookup failed)"
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
fi
|
fi
|
||||||
@@ -73,14 +73,14 @@ jobs:
|
|||||||
RUN_NUMBER: ${{ github.run_number }}
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
run: |
|
run: |
|
||||||
runner_start=$(date +%s)
|
runner_start=$(date +%s)
|
||||||
created_at=$(curl -sf \
|
created=$(curl -sf --max-time 30 \
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
if [ -n "$created_at" ]; then
|
if [ -n "$created" ]; then
|
||||||
queued_epoch=$(date -d "$created_at" +%s)
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
wait_seconds=$((runner_start - queued_epoch))
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
else
|
else
|
||||||
echo "Runner wait time: unknown (API lookup failed)"
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
fi
|
fi
|
||||||
@@ -135,7 +135,7 @@ jobs:
|
|||||||
repo_labels = api_get("/labels")
|
repo_labels = api_get("/labels")
|
||||||
label_map = {l["name"]: l["id"] for l in repo_labels}
|
label_map = {l["name"]: l["id"] for l in repo_labels}
|
||||||
|
|
||||||
label_ids = [label_map["Ready"]] if "Ready" in label_map else []
|
label_ids = [label_map["loop/code"]] if "loop/code" in label_map else []
|
||||||
|
|
||||||
title = "Firebase Tests failed — find root cause and fix"
|
title = "Firebase Tests failed — find root cause and fix"
|
||||||
body = (
|
body = (
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Publish Dev Container
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'Dockerfile.dev'
|
||||||
|
- '.devcontainer/devcontainer.json'
|
||||||
|
- '.forgejo/workflows/publish-dev-container.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Build & Push sharedinbox-dev
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
env:
|
||||||
|
REGISTRY: codeberg.org
|
||||||
|
IMAGE: codeberg.org/guettli/sharedinbox-dev
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Codeberg container registry
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
echo "$FORGEJO_TOKEN" \
|
||||||
|
| docker login "$REGISTRY" -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
run: |
|
||||||
|
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||||
|
docker build \
|
||||||
|
-t "$IMAGE:latest" \
|
||||||
|
-t "$IMAGE:$SHORT_SHA" \
|
||||||
|
-f Dockerfile.dev \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push image
|
||||||
|
run: |
|
||||||
|
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||||
|
docker push "$IMAGE:latest"
|
||||||
|
docker push "$IMAGE:$SHORT_SHA"
|
||||||
@@ -38,40 +38,27 @@ jobs:
|
|||||||
|
|
||||||
HEAD_SHA=$(git rev-parse HEAD)
|
HEAD_SHA=$(git rev-parse HEAD)
|
||||||
|
|
||||||
# Find the most recent successful website.yml run where the deploy job
|
# Find the most recent successful "Build & Update Website" task. Forgejo's API
|
||||||
# actually ran (not merely skipped). Uses head_sha (not commit_sha which
|
# does not expose per-run jobs (/runs/{id}/jobs returns 404), so query /actions/tasks
|
||||||
# is always None in Forgejo's API).
|
# (per-job records) directly and filter for the task we care about. Filtering at the
|
||||||
|
# task level also distinguishes runs where the deploy job actually ran from runs
|
||||||
|
# where it was skipped — at the run level both show status=success.
|
||||||
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
|
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
|
||||||
import json, os, sys, urllib.request
|
import json, os, sys, urllib.request
|
||||||
token = os.environ.get("FORGEJO_TOKEN", "")
|
token = os.environ.get("FORGEJO_TOKEN", "")
|
||||||
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||||
base_api = f"{server}/api/v1/repos/{repo}/actions"
|
url = f"{server}/api/v1/repos/{repo}/actions/tasks?status=success&limit=100"
|
||||||
url = f"{base_api}/runs?workflow_id=website.yml&status=success&limit=10"
|
|
||||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req) as r:
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
data = json.loads(r.read())
|
data = json.loads(r.read())
|
||||||
runs = [
|
for t in data.get("workflow_runs", []):
|
||||||
r for r in data.get("workflow_runs", [])
|
if (t.get("workflow_id") == "website.yml"
|
||||||
if r.get("status") == "success"
|
and t.get("name") == "Build & Update Website"
|
||||||
]
|
and t.get("status") == "success"):
|
||||||
for run in runs:
|
print(t.get("head_sha") or "")
|
||||||
run_id = run.get("id")
|
sys.exit(0)
|
||||||
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 "Build & Update Website" 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("")
|
print("")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
|
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
|
||||||
@@ -130,14 +117,14 @@ jobs:
|
|||||||
RUN_NUMBER: ${{ github.run_number }}
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
run: |
|
run: |
|
||||||
runner_start=$(date +%s)
|
runner_start=$(date +%s)
|
||||||
created_at=$(curl -sf \
|
created=$(curl -sf --max-time 30 \
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
if [ -n "$created_at" ]; then
|
if [ -n "$created" ]; then
|
||||||
queued_epoch=$(date -d "$created_at" +%s)
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
wait_seconds=$((runner_start - queued_epoch))
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
else
|
else
|
||||||
echo "Runner wait time: unknown (API lookup failed)"
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -123,3 +123,4 @@ dagger-certs
|
|||||||
/go
|
/go
|
||||||
.last_deployed_sha
|
.last_deployed_sha
|
||||||
.fail_count
|
.fail_count
|
||||||
|
/*.kubeconfig
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ repos:
|
|||||||
- id: forbidden-files-hook
|
- id: forbidden-files-hook
|
||||||
name: check for forbidden home-directory files
|
name: check for forbidden home-directory files
|
||||||
language: system
|
language: system
|
||||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-hygiene'
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && task check-hygiene'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
- 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 dagger call --progress=plain -q -m ci --source=. check-fast'
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && 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
|
||||||
@@ -50,6 +50,12 @@ repos:
|
|||||||
- id: ci-image-exists
|
- id: ci-image-exists
|
||||||
name: verify container images in ci/main.go are reachable
|
name: verify container images in ci/main.go are reachable
|
||||||
language: system
|
language: system
|
||||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-ci-images'
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && task check-ci-images'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
files: ^(ci/main\.go|\.fvmrc)$
|
files: ^(ci/main\.go|\.fvmrc)$
|
||||||
|
- id: dagger-versions-aligned
|
||||||
|
name: verify Dagger version is consistent across dagger.json, Dockerfile and DAGGER.md
|
||||||
|
language: system
|
||||||
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && scripts/check_dagger_versions.sh'
|
||||||
|
pass_filenames: false
|
||||||
|
files: ^(ci/dagger\.json|\.forgejo/Dockerfile|DAGGER\.md)$
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Development and Testing Container for SharedInbox
|
||||||
|
# Replaces the Nix shell environment.
|
||||||
|
FROM ghcr.io/cirruslabs/flutter:3.44.0
|
||||||
|
|
||||||
|
# Install Linux desktop build and test dependencies, Go, NodeJS, python3, and utilities
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
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 \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
jq \
|
||||||
|
python3-pip \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
hugo \
|
||||||
|
lcov \
|
||||||
|
rsync \
|
||||||
|
openssh-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Task runner
|
||||||
|
RUN curl -fsSL https://taskfile.dev/install.sh \
|
||||||
|
| sh -s -- -b /usr/local/bin v3.48.0
|
||||||
|
|
||||||
|
# Install Dagger CLI
|
||||||
|
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
||||||
|
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
||||||
|
|
||||||
|
# Install python packages (Play Store API clients + pre-commit)
|
||||||
|
RUN pip install --break-system-packages --no-cache-dir \
|
||||||
|
google-api-python-client \
|
||||||
|
google-auth-httplib2 \
|
||||||
|
httplib2 \
|
||||||
|
pre-commit==4.5.1
|
||||||
|
|
||||||
|
# Install acpx CLI globally
|
||||||
|
RUN npm install -g acpx@0.10.0
|
||||||
|
|
||||||
|
# Setup user "ci"
|
||||||
|
RUN useradd -m -s /bin/bash ci
|
||||||
|
USER ci
|
||||||
|
ENV HOME=/home/ci
|
||||||
|
ENV PATH=/home/ci/.pub-cache/bin:$PATH
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
+6
-1
@@ -544,7 +544,7 @@ tasks:
|
|||||||
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
|
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
|
||||||
|
|
||||||
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 + closed-testing tracks (local/fvm)
|
||||||
deps: [build-android-bundle-local]
|
deps: [build-android-bundle-local]
|
||||||
dotenv: [".env"]
|
dotenv: [".env"]
|
||||||
cmds:
|
cmds:
|
||||||
@@ -712,6 +712,11 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- scripts/check_ci_images.sh
|
- scripts/check_ci_images.sh
|
||||||
|
|
||||||
|
check-dagger-versions:
|
||||||
|
desc: Verify ci/dagger.json, flake.nix, .forgejo/Dockerfile and DAGGER.md pin the same Dagger version
|
||||||
|
cmds:
|
||||||
|
- scripts/check_dagger_versions.sh
|
||||||
|
|
||||||
_integrations:
|
_integrations:
|
||||||
internal: true
|
internal: true
|
||||||
run: once
|
run: once
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Agentloop is working on sialoop!
|
||||||
+19
-16
@@ -503,23 +503,19 @@ func (m *Ci) CheckFast(ctx context.Context) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CheckGenerated verifies that all generated files (*.g.dart, *.mocks.dart) are up to date.
|
// 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
|
// It reuses the codegenBase() output instead of running build_runner a second time,
|
||||||
// running build_runner, so git diff detects real staleness instead of always
|
// diffing committed generated files against the freshly built ones.
|
||||||
// comparing two freshly-generated outputs.
|
|
||||||
func (m *Ci) CheckGenerated(ctx context.Context) (string, error) {
|
func (m *Ci) CheckGenerated(ctx context.Context) (string, error) {
|
||||||
|
fresh := m.codegenBase().Directory("/src")
|
||||||
return m.pubGetLayer().
|
return m.pubGetLayer().
|
||||||
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
WithDirectory("/committed", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||||
WithWorkdir("/src").
|
WithDirectory("/generated", fresh, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||||
WithExec([]string{"git", "init"}).
|
|
||||||
WithExec([]string{"git", "config", "user.email", "ci@sharedinbox.de"}).
|
|
||||||
WithExec([]string{"git", "config", "user.name", "CI"}).
|
|
||||||
WithExec([]string{"git", "add", "."}).
|
|
||||||
WithExec([]string{"git", "commit", "-q", "-m", "baseline"}).
|
|
||||||
WithExec([]string{"/bin/bash", "-c",
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
`stale=$(find /committed -name '*.g.dart' -o -name '*.mocks.dart' | ` +
|
||||||
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
`while IFS= read -r f; do rel="${f#/committed/}"; diff -q "$f" "/generated/$rel" >/dev/null 2>&1 || echo "$rel"; done); ` +
|
||||||
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
|
`if [ -n "$stale" ]; then ` +
|
||||||
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.\""}).
|
`echo "ERROR: Generated files are out of date — run: dart run build_runner build"; echo "$stale"; exit 1; ` +
|
||||||
|
`else echo "Generated files are up to date."; fi`}).
|
||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,7 +814,14 @@ func (m *Ci) DeployApk(
|
|||||||
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
||||||
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
||||||
built := m.firebaseBase().
|
built := m.firebaseBase().
|
||||||
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
// `flutter build apk` spawns a Gradle daemon. When this WithExec ends the
|
||||||
|
// container is torn down and the daemon is killed, but its journal-cache
|
||||||
|
// lock file on the persistent gradle-cache volume keeps its dead PID — the
|
||||||
|
// next gradlew invocation then times out waiting for that lock. `gradlew
|
||||||
|
// --stop` shuts the daemon down gracefully so the lock is released before
|
||||||
|
// Dagger snapshots the layer.
|
||||||
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
|
`flutter build apk --debug --no-pub && (cd android && ./gradlew --stop)`}).
|
||||||
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
|
||||||
// preserved in the Dagger layer snapshot but whose process no longer exists.
|
// preserved in the Dagger layer snapshot but whose process no longer exists.
|
||||||
@@ -900,7 +903,7 @@ func withGoCache(c *dagger.Container) *dagger.Container {
|
|||||||
WithEnvVariable("GOMODCACHE", "/home/ci/go/pkg/mod")
|
WithEnvVariable("GOMODCACHE", "/home/ci/go/pkg/mod")
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal track.
|
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal and closed-testing (alpha) tracks.
|
||||||
func (m *Ci) UploadToPlayStore(
|
func (m *Ci) UploadToPlayStore(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
aab *dagger.File,
|
aab *dagger.File,
|
||||||
|
|||||||
Generated
-82
@@ -1,82 +0,0 @@
|
|||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"dagger": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": [
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1778107833,
|
|
||||||
"narHash": "sha256-q5XQep2mpgTPiWwuYB1+L2dsFeACT6sHx8J939iM+HE=",
|
|
||||||
"owner": "dagger",
|
|
||||||
"repo": "nix",
|
|
||||||
"rev": "873cc22ba46b73d4a6c1aa6c102ef3aabc736496",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "dagger",
|
|
||||||
"repo": "nix",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"flake-utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1731533236,
|
|
||||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1778737229,
|
|
||||||
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixos-25.11",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"dagger": "dagger",
|
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": "nixpkgs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"systems": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
{
|
|
||||||
description = "SharedInbox — IMAP/SMTP Flutter client";
|
|
||||||
|
|
||||||
inputs = {
|
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
|
||||||
dagger.url = "github:dagger/nix";
|
|
||||||
dagger.inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
};
|
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils, dagger }:
|
|
||||||
flake-utils.lib.eachDefaultSystem (system:
|
|
||||||
let
|
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
|
||||||
|
|
||||||
# All Linux desktop runtime libraries needed by flutter build linux and
|
|
||||||
# the UI integration tests (xvfb-run). Kept as a list so we can reuse
|
|
||||||
# it for both buildInputs and LD_LIBRARY_PATH / PKG_CONFIG_PATH.
|
|
||||||
linuxDesktopLibs = with pkgs; [
|
|
||||||
gtk3
|
|
||||||
libsecret
|
|
||||||
fontconfig
|
|
||||||
libepoxy
|
|
||||||
mesa
|
|
||||||
libGL # libglvnd — vendor-neutral GL/EGL/GLX dispatch layer
|
|
||||||
at-spi2-core
|
|
||||||
glib
|
|
||||||
pango
|
|
||||||
cairo
|
|
||||||
gdk-pixbuf
|
|
||||||
harfbuzz
|
|
||||||
# Dagger remote setup dependencies
|
|
||||||
stunnel
|
|
||||||
netcat
|
|
||||||
];
|
|
||||||
|
|
||||||
fgj = pkgs.stdenv.mkDerivation {
|
|
||||||
pname = "fgj";
|
|
||||||
version = "0.4.0";
|
|
||||||
src = pkgs.fetchurl {
|
|
||||||
url = "https://codeberg.org/romaintb/fgj/releases/download/v0.4.0/fgj_linux_amd64";
|
|
||||||
sha256 = "07pia03facvvxq9i1dgl7p47ccv1iqj4drpkp45gvw26d4afkbj7";
|
|
||||||
};
|
|
||||||
dontUnpack = true;
|
|
||||||
installPhase = ''
|
|
||||||
mkdir -p $out/bin
|
|
||||||
cp $src $out/bin/fgj
|
|
||||||
chmod +x $out/bin/fgj
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
# The dagger/nix flake pins 0.20.8, whose Nix wrapper is a broken self-exec
|
|
||||||
# loop. Fetch 0.21.4 directly so the pre-commit dart-check hook can run.
|
|
||||||
dagger021 = pkgs.stdenv.mkDerivation {
|
|
||||||
pname = "dagger";
|
|
||||||
version = "0.21.4";
|
|
||||||
src = pkgs.fetchurl {
|
|
||||||
url = "https://dl.dagger.io/dagger/releases/0.21.4/dagger_v0.21.4_linux_amd64.tar.gz";
|
|
||||||
sha256 = "0wlnbr4g5069755131yjp2a6alacn64f1c8b27xn0cbynq3zicjd";
|
|
||||||
};
|
|
||||||
sourceRoot = ".";
|
|
||||||
installPhase = ''
|
|
||||||
mkdir -p $out/bin
|
|
||||||
cp dagger $out/bin/dagger
|
|
||||||
chmod +x $out/bin/dagger
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
in {
|
|
||||||
devShells.default = pkgs.mkShell {
|
|
||||||
buildInputs = with pkgs; [
|
|
||||||
# Dagger CLI
|
|
||||||
dagger021
|
|
||||||
|
|
||||||
# Go compiler — for Dagger development
|
|
||||||
go
|
|
||||||
|
|
||||||
# Java JDK — required by Gradle for Android builds
|
|
||||||
|
|
||||||
# Task runner
|
|
||||||
go-task
|
|
||||||
|
|
||||||
# Flutter version manager — needed for host builds (task build-linux, task run)
|
|
||||||
fvm
|
|
||||||
|
|
||||||
# Git hooks
|
|
||||||
pre-commit
|
|
||||||
|
|
||||||
# Linux desktop build + runtime dependencies (flutter build linux / task run)
|
|
||||||
] ++ linuxDesktopLibs ++ (with pkgs; [
|
|
||||||
pkg-config
|
|
||||||
clang
|
|
||||||
cmake
|
|
||||||
ninja
|
|
||||||
|
|
||||||
# Local IMAP/SMTP dev server for integration tests
|
|
||||||
stalwart-mail
|
|
||||||
|
|
||||||
# Headless display for UI integration tests
|
|
||||||
xvfb-run # wraps Xvfb; xvfb-run --auto-servernum ...
|
|
||||||
|
|
||||||
# Coverage merging (flutter test --merge-coverage requires lcov)
|
|
||||||
lcov
|
|
||||||
|
|
||||||
# Website
|
|
||||||
hugo
|
|
||||||
|
|
||||||
# Utilities
|
|
||||||
git
|
|
||||||
curl
|
|
||||||
jq
|
|
||||||
sqlite
|
|
||||||
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
|
|
||||||
(python3.withPackages (ps: with ps; [
|
|
||||||
google-api-python-client
|
|
||||||
google-auth-httplib2
|
|
||||||
httplib2
|
|
||||||
])) # used by stalwart-dev/start and deploy_playstore.py
|
|
||||||
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
|
||||||
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
|
|
||||||
librsvg # rsvg-convert — SVG→PNG for generate-icons task
|
|
||||||
]);
|
|
||||||
|
|
||||||
shellHook = ''
|
|
||||||
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
|
|
||||||
export IN_NIX_SHELL=1
|
|
||||||
|
|
||||||
# Point Dagger client at the running engine socket
|
|
||||||
export DAGGER_HOST=unix:///run/dagger/engine.sock
|
|
||||||
|
|
||||||
# Disable Flutter telemetry inside dev shell
|
|
||||||
export FLUTTER_SUPPRESS_ANALYTICS=true
|
|
||||||
|
|
||||||
# Expose dev headers to cmake's FindPkgConfig.
|
|
||||||
# The nix pkg-config wrapper works in bash but cmake invokes pkg-config
|
|
||||||
# as a subprocess and needs PKG_CONFIG_PATH set explicitly.
|
|
||||||
export PKG_CONFIG_PATH="${pkgs.gtk3.dev}/lib/pkgconfig:${pkgs.glib.dev}/lib/pkgconfig:${pkgs.pango.dev}/lib/pkgconfig:${pkgs.cairo.dev}/lib/pkgconfig:${pkgs.gdk-pixbuf.dev}/lib/pkgconfig:${pkgs.at-spi2-core.dev}/lib/pkgconfig:${pkgs.harfbuzz.dev}/lib/pkgconfig:${pkgs.libsecret}/lib/pkgconfig:${pkgs.fontconfig.dev}/lib/pkgconfig:${pkgs.libepoxy}/lib/pkgconfig:$PKG_CONFIG_PATH"
|
|
||||||
|
|
||||||
# Nix ld uses --no-copy-dt-needed-entries (strict mode): transitive shared-lib
|
|
||||||
# deps are not followed automatically, so link them explicitly.
|
|
||||||
export LDFLAGS="-L${pkgs.fontconfig.lib}/lib -lfontconfig $LDFLAGS"
|
|
||||||
|
|
||||||
# Make nix-built runtime libs visible to the dynamic linker so the
|
|
||||||
# Flutter Linux bundle and integration-ui tests can run.
|
|
||||||
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath linuxDesktopLibs}:$LD_LIBRARY_PATH"
|
|
||||||
|
|
||||||
# Wire the libglvnd dispatch to the nix mesa vendor ICDs so GTK/Flutter
|
|
||||||
# can create an OpenGL (EGL + GLX) context under Xvfb without a real GPU.
|
|
||||||
export __EGL_VENDOR_LIBRARY_DIRS="${pkgs.mesa}/share/glvnd/egl_vendor.d"
|
|
||||||
export __GLX_VENDOR_LIBRARY_DIRS="${pkgs.mesa}/lib"
|
|
||||||
export LIBGL_ALWAYS_SOFTWARE=1
|
|
||||||
export MESA_LOADER_DRIVER_OVERRIDE=softpipe
|
|
||||||
|
|
||||||
echo "SharedInbox Flutter dev environment ready."
|
|
||||||
echo " Analyze : task analyze"
|
|
||||||
echo " Unit tests : task test"
|
|
||||||
echo " Integration : task integration"
|
|
||||||
echo " All checks : task check"
|
|
||||||
echo " Run (Linux) : task run"
|
|
||||||
echo " Start Stalwart : stalwart-dev/start"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +1 @@
|
|||||||
const int dbSchemaVersion = 40;
|
const int dbSchemaVersion = 41;
|
||||||
|
|||||||
@@ -679,6 +679,116 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
if (from < 40) {
|
if (from < 40) {
|
||||||
await m.createTable(installedVersions);
|
await m.createTable(installedVersions);
|
||||||
}
|
}
|
||||||
|
if (from < 41) {
|
||||||
|
// Fix IMAP email IDs to include mailboxPath, preventing UID
|
||||||
|
// collisions across mailboxes (IMAP UIDs are mailbox-scoped).
|
||||||
|
// New format: "accountId:mailboxPath:uid" (was "accountId:uid").
|
||||||
|
//
|
||||||
|
// defer_foreign_keys defers the email_bodies→emails FK check
|
||||||
|
// to COMMIT so the two tables can be updated sequentially inside
|
||||||
|
// the migration transaction without a transient FK violation.
|
||||||
|
await customStatement('PRAGMA defer_foreign_keys = ON');
|
||||||
|
|
||||||
|
// 1. Remap email_bodies.email_id before emails.id changes.
|
||||||
|
await customStatement('''
|
||||||
|
UPDATE email_bodies
|
||||||
|
SET email_id = (
|
||||||
|
SELECT e.account_id || ':' || e.mailbox_path || ':' || CAST(e.uid AS TEXT)
|
||||||
|
FROM emails e
|
||||||
|
JOIN accounts a ON a.id = e.account_id
|
||||||
|
WHERE e.id = email_bodies.email_id
|
||||||
|
AND a.account_type = 'imap'
|
||||||
|
)
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM emails e
|
||||||
|
JOIN accounts a ON a.id = e.account_id
|
||||||
|
WHERE e.id = email_bodies.email_id
|
||||||
|
AND a.account_type = 'imap'
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 2. Update emails.thread_id where it was set to the email's own
|
||||||
|
// id (fallback for messages with no Message-ID header).
|
||||||
|
await customStatement('''
|
||||||
|
UPDATE emails
|
||||||
|
SET thread_id = account_id || ':' || mailbox_path || ':' || CAST(uid AS TEXT)
|
||||||
|
WHERE account_id IN (SELECT id FROM accounts WHERE account_type = 'imap')
|
||||||
|
AND thread_id = id
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 3. Update the primary key on emails.
|
||||||
|
await customStatement('''
|
||||||
|
UPDATE emails
|
||||||
|
SET id = account_id || ':' || mailbox_path || ':' || CAST(uid AS TEXT)
|
||||||
|
WHERE account_id IN (
|
||||||
|
SELECT id FROM accounts WHERE account_type = 'imap'
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 5. Rebuild threads for IMAP accounts from the updated email rows.
|
||||||
|
// The threads table stores denormalised data (latest_email_id,
|
||||||
|
// email_ids_json) that references email IDs, so it is simpler to
|
||||||
|
// delete and reconstruct than to patch the JSON in SQL.
|
||||||
|
await customStatement('''
|
||||||
|
DELETE FROM threads
|
||||||
|
WHERE account_id IN (SELECT id FROM accounts WHERE account_type = 'imap')
|
||||||
|
''');
|
||||||
|
|
||||||
|
final imapAccounts = await (select(accounts)
|
||||||
|
..where((t) => t.accountType.equals('imap')))
|
||||||
|
.get();
|
||||||
|
for (final acct in imapAccounts) {
|
||||||
|
final emailRows = await (select(emails)
|
||||||
|
..where((t) => t.accountId.equals(acct.id)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
final groups = <String, List<Email>>{};
|
||||||
|
for (final row in emailRows) {
|
||||||
|
final key = '${row.mailboxPath}:${row.threadId ?? row.id}';
|
||||||
|
groups.putIfAbsent(key, () => []).add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final threadEmails in groups.values) {
|
||||||
|
threadEmails.sort((a, b) {
|
||||||
|
final da = a.sentAt ?? a.receivedAt;
|
||||||
|
final db = b.sentAt ?? b.receivedAt;
|
||||||
|
return da.compareTo(db);
|
||||||
|
});
|
||||||
|
final latest = threadEmails.last;
|
||||||
|
|
||||||
|
final seen = <String>{};
|
||||||
|
final participants = <Map<String, dynamic>>[];
|
||||||
|
for (final e in threadEmails) {
|
||||||
|
final from = jsonDecode(e.fromJson) as List<dynamic>;
|
||||||
|
for (final a in from.cast<Map<String, dynamic>>()) {
|
||||||
|
final email = a['email'] as String;
|
||||||
|
if (seen.add(email)) {
|
||||||
|
participants.add({'name': a['name'], 'email': email});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await into(threads).insert(
|
||||||
|
ThreadsCompanion.insert(
|
||||||
|
id: latest.threadId ?? latest.id,
|
||||||
|
accountId: latest.accountId,
|
||||||
|
mailboxPath: latest.mailboxPath,
|
||||||
|
subject: Value(latest.subject),
|
||||||
|
latestDate: latest.sentAt ?? latest.receivedAt,
|
||||||
|
messageCount: Value(threadEmails.length),
|
||||||
|
hasUnread: Value(threadEmails.any((e) => !e.isSeen)),
|
||||||
|
isFlagged: Value(threadEmails.any((e) => e.isFlagged)),
|
||||||
|
participantsJson: Value(jsonEncode(participants)),
|
||||||
|
preview: Value(latest.preview),
|
||||||
|
latestEmailId: latest.id,
|
||||||
|
emailIdsJson: Value(
|
||||||
|
jsonEncode(threadEmails.map((e) => e.id).toList()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -561,7 +561,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
for (final msg in result.messages) {
|
for (final msg in result.messages) {
|
||||||
final uid = msg.uid;
|
final uid = msg.uid;
|
||||||
if (uid == null) continue;
|
if (uid == null) continue;
|
||||||
final emailId = '${account.id}:$uid';
|
final emailId = '${account.id}:$mailboxPath:$uid';
|
||||||
await (_db.update(_db.emails)..where((t) => t.id.equals(emailId))).write(
|
await (_db.update(_db.emails)..where((t) => t.id.equals(emailId))).write(
|
||||||
EmailsCompanion(
|
EmailsCompanion(
|
||||||
isSeen: Value(msg.flags?.contains(r'\Seen') ?? false),
|
isSeen: Value(msg.flags?.contains(r'\Seen') ?? false),
|
||||||
@@ -616,7 +616,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
bytes += msg.size ?? 0;
|
bytes += msg.size ?? 0;
|
||||||
final emailId = '${account.id}:$uid';
|
final emailId = '${account.id}:$mailboxPath:$uid';
|
||||||
final msgId = envelope.messageId?.trim();
|
final msgId = envelope.messageId?.trim();
|
||||||
final inReplyTo = envelope.inReplyTo?.trim();
|
final inReplyTo = envelope.inReplyTo?.trim();
|
||||||
final refs = msg.getHeaderValue('References')?.trim();
|
final refs = msg.getHeaderValue('References')?.trim();
|
||||||
|
|||||||
@@ -74,10 +74,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
automaticallyImplyLeading: !isMobile,
|
automaticallyImplyLeading: !isMobile,
|
||||||
title: Text(
|
|
||||||
header?.subject ?? '(loading…)',
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.reply),
|
icon: const Icon(Icons.reply),
|
||||||
@@ -133,12 +129,20 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
if (mounted) setState(() => _isFlagged = next);
|
if (mounted) setState(() => _isFlagged = next);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.report_outlined),
|
||||||
|
tooltip: 'Mark as spam',
|
||||||
|
onPressed: header == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
unawaited(_markAsSpam(context, header));
|
||||||
|
},
|
||||||
|
),
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
itemBuilder: (ctx) => [
|
itemBuilder: (ctx) => [
|
||||||
const PopupMenuItem(value: 'forward', child: Text('Forward')),
|
const PopupMenuItem(value: 'forward', child: Text('Forward')),
|
||||||
const PopupMenuItem(value: 'move', child: Text('Move to folder')),
|
const PopupMenuItem(value: 'move', child: Text('Move to folder')),
|
||||||
const PopupMenuItem(value: 'snooze', child: Text('Snooze')),
|
const PopupMenuItem(value: 'snooze', child: Text('Snooze')),
|
||||||
const PopupMenuItem(value: 'spam', child: Text('Mark as spam')),
|
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: 'mark_unread',
|
value: 'mark_unread',
|
||||||
child: Text('Mark as unread'),
|
child: Text('Mark as unread'),
|
||||||
@@ -166,8 +170,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
unawaited(_moveTo(context, header));
|
unawaited(_moveTo(context, header));
|
||||||
} else if (value == 'snooze' && header != null) {
|
} else if (value == 'snooze' && header != null) {
|
||||||
unawaited(_snooze(context, header));
|
unawaited(_snooze(context, header));
|
||||||
} else if (value == 'spam' && header != null) {
|
|
||||||
unawaited(_markAsSpam(context, header));
|
|
||||||
} else if (value == 'mark_unread') {
|
} else if (value == 'mark_unread') {
|
||||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||||
await repo.setFlag(widget.emailId, seen: false);
|
await repo.setFlag(widget.emailId, seen: false);
|
||||||
@@ -237,6 +239,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
|
// SnackBar defaults to persist=true when an action
|
||||||
|
// is set, which disables the auto-dismiss timer.
|
||||||
|
// Explicitly opt back into duration-based dismiss.
|
||||||
|
persist: false,
|
||||||
content: const Text(
|
content: const Text(
|
||||||
'Images will be loaded automatically for this sender.',
|
'Images will be loaded automatically for this sender.',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -214,6 +214,10 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
|
// SnackBar defaults to persist=true when an
|
||||||
|
// action is set, which disables auto-dismiss.
|
||||||
|
// Explicitly opt into duration-based dismiss.
|
||||||
|
persist: false,
|
||||||
content: const Text(
|
content: const Text(
|
||||||
'Images will be loaded automatically for this sender.',
|
'Images will be loaded automatically for this sender.',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
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';
|
||||||
@@ -93,7 +94,9 @@ class UndoLogDetailScreen extends ConsumerWidget {
|
|||||||
style: theme.textTheme.bodySmall,
|
style: theme.textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
...action.originalEmails.map((email) => _EmailTile(email: email)),
|
...action.originalEmails.map(
|
||||||
|
(email) => _EmailTile(email: email, accountId: action.accountId),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -120,13 +123,14 @@ class _SectionHeader extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EmailTile extends StatelessWidget {
|
class _EmailTile extends ConsumerWidget {
|
||||||
const _EmailTile({required this.email});
|
const _EmailTile({required this.email, required this.accountId});
|
||||||
|
|
||||||
final Email email;
|
final Email email;
|
||||||
|
final String accountId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final sender = email.from.isNotEmpty
|
final sender = email.from.isNotEmpty
|
||||||
? (email.from.first.name ?? email.from.first.email)
|
? (email.from.first.name ?? email.from.first.email)
|
||||||
: '(Unknown Sender)';
|
: '(Unknown Sender)';
|
||||||
@@ -134,6 +138,43 @@ class _EmailTile extends StatelessWidget {
|
|||||||
leading: const Icon(Icons.email_outlined),
|
leading: const Icon(Icons.email_outlined),
|
||||||
title: Text(email.subject ?? '(No Subject)'),
|
title: Text(email.subject ?? '(No Subject)'),
|
||||||
subtitle: Text(sender, maxLines: 1, overflow: TextOverflow.ellipsis),
|
subtitle: Text(sender, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => _openEmail(context, ref),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openEmail(BuildContext context, WidgetRef ref) async {
|
||||||
|
final messageId = email.messageId;
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
if (messageId == null) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text('Cannot locate this email — no Message-ID.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final found = await ref
|
||||||
|
.read(emailRepositoryProvider)
|
||||||
|
.findEmailByMessageId(accountId, messageId);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (found == null) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text(
|
||||||
|
'Email no longer exists at its previous location. '
|
||||||
|
'Use Undo to restore it.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.go(
|
||||||
|
'/accounts/$accountId'
|
||||||
|
'/mailboxes/${Uri.encodeComponent(found.mailboxPath)}'
|
||||||
|
'/emails/${Uri.encodeComponent(found.id)}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-51
@@ -5,18 +5,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: _fe_analyzer_shared
|
name: _fe_analyzer_shared
|
||||||
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
|
sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "93.0.0"
|
version: "99.0.0"
|
||||||
analyzer:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer
|
name: analyzer
|
||||||
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
|
sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.1"
|
version: "12.1.0"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -165,10 +165,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: code_assets
|
name: code_assets
|
||||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.2.1"
|
||||||
code_builder:
|
code_builder:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -237,18 +237,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dart_style
|
name: dart_style
|
||||||
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
|
sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.7"
|
version: "3.1.8"
|
||||||
dbus:
|
dbus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dbus
|
name: dbus
|
||||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.12"
|
version: "0.7.14"
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -349,10 +349,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: file_picker
|
name: file_picker
|
||||||
sha256: "0204695694b687b167fd497da5252e9f4aaa162e8d274d6fa1e757380f2a5f46"
|
sha256: fc83774ce5bd7ce08168333b5e53dbe9090ec04eb21e7aa7cd7bac921032c934
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "12.0.0-beta.4"
|
version: "12.0.0-beta.5"
|
||||||
fixnum:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -391,34 +391,42 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications
|
name: flutter_local_notifications
|
||||||
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
|
sha256: be38e3854d2baabcda8e16966a5fe8748cebb655bb94701494da0f052c2fc352
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "21.0.0"
|
version: "22.0.0"
|
||||||
flutter_local_notifications_linux:
|
flutter_local_notifications_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_linux
|
name: flutter_local_notifications_linux
|
||||||
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
|
sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.0"
|
version: "8.0.1"
|
||||||
flutter_local_notifications_platform_interface:
|
flutter_local_notifications_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_platform_interface
|
name: flutter_local_notifications_platform_interface
|
||||||
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
|
sha256: ff0013eae795e8dc8fad4a8992a209e64d3ba2fbd8bf5e43c36bf448f95bd814
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "11.0.0"
|
version: "12.0.0"
|
||||||
|
flutter_local_notifications_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_web
|
||||||
|
sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
flutter_local_notifications_windows:
|
flutter_local_notifications_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_windows
|
name: flutter_local_notifications_windows
|
||||||
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
|
sha256: "5aeed973a0c1480706784fad05c5c3a911335ebb561b2274b47fe80b375201e1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "3.1.0"
|
||||||
flutter_markdown_plus:
|
flutter_markdown_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -431,10 +439,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_plugin_android_lifecycle
|
name: flutter_plugin_android_lifecycle
|
||||||
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
|
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.34"
|
version: "2.0.35"
|
||||||
flutter_riverpod:
|
flutter_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -447,10 +455,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage
|
name: flutter_secure_storage
|
||||||
sha256: d2a6ac2df7353f5ca47eb159a5407c1dba7ec48ca0e02dc38c9ff4d29447b261
|
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.3.0"
|
version: "10.3.1"
|
||||||
flutter_secure_storage_darwin:
|
flutter_secure_storage_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -526,10 +534,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: go_router
|
name: go_router
|
||||||
sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f"
|
sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "17.2.3"
|
version: "17.3.0"
|
||||||
graphs:
|
graphs:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -542,10 +550,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: hooks
|
name: hooks
|
||||||
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.3"
|
version: "2.0.2"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -675,10 +683,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.18.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -707,10 +715,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: native_toolchain_c
|
name: native_toolchain_c
|
||||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.17.6"
|
version: "0.19.1"
|
||||||
node_preamble:
|
node_preamble:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -723,10 +731,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: objective_c
|
name: objective_c
|
||||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.3.0"
|
version: "9.4.1"
|
||||||
open_filex:
|
open_filex:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1013,13 +1021,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.2"
|
version: "1.10.2"
|
||||||
sqlite3:
|
sqlite3:
|
||||||
dependency: "direct dev"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: sqlite3
|
name: sqlite3
|
||||||
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
|
sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.1"
|
version: "3.3.2"
|
||||||
sqlite3_flutter_libs:
|
sqlite3_flutter_libs:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1088,10 +1096,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: synchronized
|
name: synchronized
|
||||||
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
|
sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.4.0+1"
|
version: "3.4.1"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1104,26 +1112,26 @@ packages:
|
|||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.30.0"
|
version: "1.31.0"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.11"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.16"
|
version: "0.6.17"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1288,10 +1296,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: webview_flutter_android
|
name: webview_flutter_android
|
||||||
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
|
sha256: a97db7a44f8e71af2f3971c45550a08cce1fb60059c1b8e534251e6cfb753490
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.12.0"
|
version: "4.13.0"
|
||||||
webview_flutter_platform_interface:
|
webview_flutter_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1304,10 +1312,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: webview_flutter_wkwebview
|
name: webview_flutter_wkwebview
|
||||||
sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2"
|
sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.25.1"
|
version: "3.26.0"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1381,5 +1389,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.11.0 <4.0.0"
|
dart: ">=3.12.0 <4.0.0"
|
||||||
flutter: ">=3.38.4"
|
flutter: ">=3.44.0"
|
||||||
|
|||||||
+3
-3
@@ -28,7 +28,7 @@ dependencies:
|
|||||||
flutter_riverpod: ^3.0.0
|
flutter_riverpod: ^3.0.0
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
go_router: ^17.2.3
|
go_router: ^17.3.0
|
||||||
|
|
||||||
# Secure credential storage (passwords)
|
# Secure credential storage (passwords)
|
||||||
flutter_secure_storage: ^10.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
@@ -37,7 +37,7 @@ dependencies:
|
|||||||
intl: ^0.20.2
|
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.5
|
||||||
open_filex: ^4.6.0
|
open_filex: ^4.6.0
|
||||||
mime: ^2.0.0
|
mime: ^2.0.0
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ dependencies:
|
|||||||
flutter_markdown_plus: ^1.0.7
|
flutter_markdown_plus: ^1.0.7
|
||||||
|
|
||||||
# Background sync and local notifications
|
# Background sync and local notifications
|
||||||
flutter_local_notifications: ^21.0.0
|
flutter_local_notifications: ^22.0.0
|
||||||
workmanager: ^0.9.0
|
workmanager: ^0.9.0
|
||||||
|
|
||||||
# Stack trace chain-to-VM conversion for FlutterError.demangleStackTrace
|
# Stack trace chain-to-VM conversion for FlutterError.demangleStackTrace
|
||||||
|
|||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verify that the Dagger version is consistent across the project.
|
||||||
|
#
|
||||||
|
# The Dagger CLI must speak the same protocol as the engine it talks to. We
|
||||||
|
# pin the version in four places (engine image in DAGGER.md, the CLI in
|
||||||
|
# flake.nix, the CLI in the Forgejo runner Dockerfile, and the module
|
||||||
|
# engineVersion in ci/dagger.json). This script fails if any of them drift.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT=$(git rev-parse --show-toplevel)
|
||||||
|
|
||||||
|
# ci/dagger.json — strip leading "v" for comparison.
|
||||||
|
dagger_json=$(grep -oE '"engineVersion"[[:space:]]*:[[:space:]]*"[^"]+"' "$ROOT/ci/dagger.json" \
|
||||||
|
| sed -E 's/.*"v?([^"]+)"$/\1/')
|
||||||
|
|
||||||
|
# .forgejo/Dockerfile — DAGGER_VERSION env on the install line.
|
||||||
|
dockerfile=$(grep -oE 'DAGGER_VERSION=[0-9]+\.[0-9]+\.[0-9]+' "$ROOT/.forgejo/Dockerfile" \
|
||||||
|
| head -n1 \
|
||||||
|
| cut -d= -f2)
|
||||||
|
|
||||||
|
# DAGGER.md — engine image tag in the example systemd unit.
|
||||||
|
dagger_md=$(grep -oE 'dagger/nix/v[0-9]+\.[0-9]+\.[0-9]+' "$ROOT/DAGGER.md" \
|
||||||
|
| head -n1 \
|
||||||
|
| sed -E 's@.*/v@@')
|
||||||
|
|
||||||
|
printf 'ci/dagger.json engineVersion = v%s\n' "$dagger_json"
|
||||||
|
printf '.forgejo/Dockerf. DAGGER_VERSION= %s\n' "$dockerfile"
|
||||||
|
printf 'DAGGER.md engine tag = v%s\n' "$dagger_md"
|
||||||
|
|
||||||
|
for v in "$dockerfile" "$dagger_md"; do
|
||||||
|
if [ -z "$v" ]; then
|
||||||
|
echo "ERROR: failed to parse a Dagger version reference." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$v" != "$dagger_json" ]; then
|
||||||
|
echo "" >&2
|
||||||
|
echo "ERROR: Dagger versions are out of sync." >&2
|
||||||
|
echo " Align ci/dagger.json, .forgejo/Dockerfile and DAGGER.md to the same version." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Dagger versions aligned (v$dagger_json)."
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Upload an Android App Bundle to the Google Play Store internal track."""
|
"""Upload an Android App Bundle to the Google Play Store.
|
||||||
|
|
||||||
|
The bundle is published to every track in ``TRACKS`` within a single Play edit,
|
||||||
|
so internal testing and closed testing share the same version code. ``alpha``
|
||||||
|
is what the Play Console labels "Closed testing"; publishing there removes the
|
||||||
|
need to manually drag-and-drop the AAB into the closed-testing release form.
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -11,7 +17,7 @@ from google.oauth2 import service_account
|
|||||||
|
|
||||||
PACKAGE_NAME = "de.sharedinbox.mua"
|
PACKAGE_NAME = "de.sharedinbox.mua"
|
||||||
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
||||||
TRACK = "internal"
|
TRACKS = ("internal", "alpha")
|
||||||
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
|
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
|
||||||
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
|
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
|
||||||
_MAX_UPLOAD_ATTEMPTS = 3
|
_MAX_UPLOAD_ATTEMPTS = 3
|
||||||
@@ -94,19 +100,20 @@ def main():
|
|||||||
version_code = bundle["versionCode"]
|
version_code = bundle["versionCode"]
|
||||||
print(f"Uploaded AAB, version code: {version_code}")
|
print(f"Uploaded AAB, version code: {version_code}")
|
||||||
|
|
||||||
track_resp = session.put(
|
for track in TRACKS:
|
||||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
|
track_resp = session.put(
|
||||||
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{track}",
|
||||||
timeout=30,
|
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
||||||
)
|
timeout=30,
|
||||||
track_resp.raise_for_status()
|
)
|
||||||
|
track_resp.raise_for_status()
|
||||||
|
|
||||||
commit_resp = session.post(
|
commit_resp = session.post(
|
||||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
|
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
commit_resp.raise_for_status()
|
commit_resp.raise_for_status()
|
||||||
print(f"Deployed version {version_code} to {TRACK} track")
|
print(f"Deployed version {version_code} to tracks: {', '.join(TRACKS)}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -95,6 +95,30 @@ class TestMainHappyPath(unittest.TestCase):
|
|||||||
track_call = session.put.call_args_list[0]
|
track_call = session.put.call_args_list[0]
|
||||||
self.assertIn("/tracks/", track_call[0][0])
|
self.assertIn("/tracks/", track_call[0][0])
|
||||||
|
|
||||||
|
def test_updates_all_configured_tracks(self):
|
||||||
|
session = self._run_main()
|
||||||
|
track_urls = [c[0][0] for c in session.put.call_args_list]
|
||||||
|
self.assertEqual(len(track_urls), len(deploy_playstore.TRACKS))
|
||||||
|
for track in deploy_playstore.TRACKS:
|
||||||
|
self.assertTrue(
|
||||||
|
any(url.endswith(f"/tracks/{track}") for url in track_urls),
|
||||||
|
f"no PUT to /tracks/{track} (saw {track_urls})",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_commits_after_all_track_updates(self):
|
||||||
|
session = self._run_main()
|
||||||
|
# All PUTs are track updates; commit is the second POST after the
|
||||||
|
# initial edit-create. Verify PUTs precede the commit by checking
|
||||||
|
# mock_calls order across both methods.
|
||||||
|
method_order = [c[0] for c in session.method_calls]
|
||||||
|
commit_idx = next(
|
||||||
|
i for i, m in enumerate(method_order)
|
||||||
|
if m == "post" and ":commit" in session.method_calls[i][1][0]
|
||||||
|
)
|
||||||
|
put_indices = [i for i, m in enumerate(method_order) if m == "put"]
|
||||||
|
self.assertEqual(len(put_indices), len(deploy_playstore.TRACKS))
|
||||||
|
self.assertTrue(all(i < commit_idx for i in put_indices))
|
||||||
|
|
||||||
|
|
||||||
class TestUploadRetry(unittest.TestCase):
|
class TestUploadRetry(unittest.TestCase):
|
||||||
def _run_main(self, upload_side_effects, sleep_mock=None):
|
def _run_main(self, upload_side_effects, sleep_mock=None):
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:http/testing.dart';
|
import 'package:http/testing.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.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/data/db/database.dart' hide Account;
|
import 'package:sharedinbox/data/db/database.dart' hide Account;
|
||||||
@@ -262,6 +263,50 @@ void main() {
|
|||||||
expect(emails.map((e) => e.uid).toList(), [3, 2, 1]);
|
expect(emails.map((e) => e.uid).toList(), [3, 2, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('same UID in different mailboxes yields independent emails', () async {
|
||||||
|
// Regression test for the UID collision bug: IMAP UIDs are mailbox-scoped,
|
||||||
|
// so UID 50 in INBOX and UID 50 in Archive must get distinct local IDs.
|
||||||
|
final r = _makeRepos();
|
||||||
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
|
|
||||||
|
// New ID format: accountId:mailboxPath:uid
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:INBOX:50',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 50,
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:Archive:50',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'Archive',
|
||||||
|
uid: 50,
|
||||||
|
receivedAt: DateTime(2024, 1, 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final inboxEmail = await r.emails.getEmail('acc-1:INBOX:50');
|
||||||
|
expect(inboxEmail, isNotNull);
|
||||||
|
expect(inboxEmail!.mailboxPath, 'INBOX');
|
||||||
|
|
||||||
|
final archiveEmail = await r.emails.getEmail('acc-1:Archive:50');
|
||||||
|
expect(archiveEmail, isNotNull);
|
||||||
|
expect(archiveEmail!.mailboxPath, 'Archive');
|
||||||
|
|
||||||
|
final inboxEmails = await r.emails.observeEmails('acc-1', 'INBOX').first;
|
||||||
|
expect(inboxEmails, hasLength(1));
|
||||||
|
expect(inboxEmails.first.id, 'acc-1:INBOX:50');
|
||||||
|
|
||||||
|
final archiveEmails =
|
||||||
|
await r.emails.observeEmails('acc-1', 'Archive').first;
|
||||||
|
expect(archiveEmails, hasLength(1));
|
||||||
|
expect(archiveEmails.first.id, 'acc-1:Archive:50');
|
||||||
|
});
|
||||||
|
|
||||||
test('syncEmails propagates IMAP error', () async {
|
test('syncEmails propagates IMAP error', () async {
|
||||||
final r = _makeRepos();
|
final r = _makeRepos();
|
||||||
await r.accounts.addAccount(_account, 'pw');
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
@@ -638,6 +683,91 @@ void main() {
|
|||||||
expect(results[1].subject, 'Older meeting');
|
expect(results[1].subject, 'Older meeting');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'searchEmailsStructured returns results sorted by receivedAt descending',
|
||||||
|
() async {
|
||||||
|
final r = _makeRepos();
|
||||||
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
|
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 1,
|
||||||
|
subject: const Value('Older invoice'),
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:2',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 2,
|
||||||
|
subject: const Value('Newer invoice'),
|
||||||
|
receivedAt: DateTime(2024, 6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final filter = FilterGroup(
|
||||||
|
operator: FilterOperator.and_,
|
||||||
|
children: [
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'invoice',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final results = await r.emails.searchEmailsStructured(null, filter);
|
||||||
|
expect(results, hasLength(2));
|
||||||
|
expect(results[0].subject, 'Newer invoice');
|
||||||
|
expect(results[1].subject, 'Older invoice');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'getEmailsByAddress returns results sorted by receivedAt descending',
|
||||||
|
() async {
|
||||||
|
final r = _makeRepos();
|
||||||
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
|
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 1,
|
||||||
|
subject: const Value('Older hello'),
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
fromJson: const Value(
|
||||||
|
'[{"name":"Bob","email":"bob@example.com"}]',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:2',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 2,
|
||||||
|
subject: const Value('Newer hello'),
|
||||||
|
receivedAt: DateTime(2024, 6),
|
||||||
|
fromJson: const Value(
|
||||||
|
'[{"name":"Bob","email":"bob@example.com"}]',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final results =
|
||||||
|
await r.emails.getEmailsByAddress(null, 'bob@example.com');
|
||||||
|
expect(results, hasLength(2));
|
||||||
|
expect(results[0].subject, 'Newer hello');
|
||||||
|
expect(results[1].subject, 'Older hello');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'searchAddresses returns results sorted by most recently used',
|
'searchAddresses returns results sorted by most recently used',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ void main() {
|
|||||||
group('Migration', () {
|
group('Migration', () {
|
||||||
test('schemaVersion matches expected value', () async {
|
test('schemaVersion matches expected value', () async {
|
||||||
final db = AppDatabase(NativeDatabase.memory());
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
expect(db.schemaVersion, 40);
|
expect(db.schemaVersion, 41);
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -435,7 +435,184 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test('fresh install creates all tables at schemaVersion 40', () async {
|
test('v40→v41: IMAP email IDs gain mailboxPath segment', () async {
|
||||||
|
final dbFile = File('test_migration_v40.db');
|
||||||
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
|
|
||||||
|
final rawDb = sqlite.sqlite3.open(dbFile.path);
|
||||||
|
rawDb.execute('''
|
||||||
|
CREATE TABLE accounts (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
imap_host TEXT NOT NULL DEFAULT '',
|
||||||
|
imap_port INTEGER NOT NULL DEFAULT 993,
|
||||||
|
imap_ssl INTEGER NOT NULL DEFAULT 1,
|
||||||
|
smtp_host TEXT NOT NULL DEFAULT '',
|
||||||
|
smtp_port INTEGER NOT NULL DEFAULT 465,
|
||||||
|
smtp_ssl INTEGER NOT NULL DEFAULT 1,
|
||||||
|
account_type TEXT NOT NULL DEFAULT 'imap',
|
||||||
|
jmap_url TEXT NULL,
|
||||||
|
username TEXT NOT NULL DEFAULT '',
|
||||||
|
verbose INTEGER NOT NULL DEFAULT 0,
|
||||||
|
manage_sieve_host TEXT NOT NULL DEFAULT '',
|
||||||
|
manage_sieve_port INTEGER NOT NULL DEFAULT 4190,
|
||||||
|
manage_sieve_ssl INTEGER NOT NULL DEFAULT 1,
|
||||||
|
manage_sieve_available INTEGER NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
rawDb.execute('''
|
||||||
|
CREATE TABLE emails (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
|
||||||
|
mailbox_path TEXT NOT NULL,
|
||||||
|
uid INTEGER NOT NULL,
|
||||||
|
subject TEXT NULL,
|
||||||
|
sent_at INTEGER NULL,
|
||||||
|
received_at INTEGER NOT NULL,
|
||||||
|
from_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
to_addresses TEXT NOT NULL DEFAULT '[]',
|
||||||
|
cc_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
preview TEXT NULL,
|
||||||
|
is_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_flagged INTEGER NOT NULL DEFAULT 0,
|
||||||
|
has_attachment INTEGER NOT NULL DEFAULT 0,
|
||||||
|
thread_id TEXT NULL,
|
||||||
|
message_id TEXT NULL,
|
||||||
|
in_reply_to TEXT NULL,
|
||||||
|
"references" TEXT NULL,
|
||||||
|
snoozed_until INTEGER NULL,
|
||||||
|
snoozed_from_mailbox_path TEXT NULL,
|
||||||
|
list_unsubscribe_header TEXT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
rawDb.execute('''
|
||||||
|
CREATE TABLE email_bodies (
|
||||||
|
email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails (id) ON DELETE CASCADE,
|
||||||
|
text_body TEXT NULL,
|
||||||
|
html_body TEXT NULL,
|
||||||
|
attachments_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
cached_at INTEGER NULL,
|
||||||
|
headers_json TEXT NULL,
|
||||||
|
mime_tree_json TEXT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
rawDb.execute('''
|
||||||
|
CREATE TABLE threads (
|
||||||
|
account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
|
||||||
|
mailbox_path TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
subject TEXT NULL,
|
||||||
|
latest_date INTEGER NOT NULL,
|
||||||
|
message_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
has_unread INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_flagged INTEGER NOT NULL DEFAULT 0,
|
||||||
|
participants_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
preview TEXT NULL,
|
||||||
|
latest_email_id TEXT NOT NULL,
|
||||||
|
email_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
PRIMARY KEY (account_id, mailbox_path, id)
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
rawDb.execute('''
|
||||||
|
CREATE TABLE pending_changes (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
|
||||||
|
resource_type TEXT NOT NULL,
|
||||||
|
resource_id TEXT NOT NULL,
|
||||||
|
change_type TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// Insert an IMAP account.
|
||||||
|
rawDb.execute(
|
||||||
|
"INSERT INTO accounts (id, display_name, email) VALUES ('acc-1', 'Alice', 'alice@example.com')",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Two emails with the same UID but in different mailboxes — old format.
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
rawDb.execute(
|
||||||
|
'INSERT INTO emails (id, account_id, mailbox_path, uid, received_at, thread_id) '
|
||||||
|
"VALUES ('acc-1:50', 'acc-1', 'INBOX', 50, $now, 'acc-1:50')",
|
||||||
|
);
|
||||||
|
rawDb.execute(
|
||||||
|
'INSERT INTO emails (id, account_id, mailbox_path, uid, received_at) '
|
||||||
|
"VALUES ('acc-1:50-arch', 'acc-1', 'Archive', 50, $now)",
|
||||||
|
);
|
||||||
|
// A third email with a Message-ID-based thread_id (should not be changed).
|
||||||
|
rawDb.execute(
|
||||||
|
'INSERT INTO emails (id, account_id, mailbox_path, uid, received_at, thread_id) '
|
||||||
|
"VALUES ('acc-1:99', 'acc-1', 'INBOX', 99, $now, '<original@example.com>')",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Email body for the first email.
|
||||||
|
rawDb.execute(
|
||||||
|
"INSERT INTO email_bodies (email_id, text_body) VALUES ('acc-1:50', 'body text')",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Thread for the first email (old-format IDs).
|
||||||
|
rawDb.execute(
|
||||||
|
'INSERT INTO threads (account_id, mailbox_path, id, latest_date, latest_email_id, email_ids_json) '
|
||||||
|
"VALUES ('acc-1', 'INBOX', 'acc-1:50', $now, 'acc-1:50', '[\"acc-1:50\"]')",
|
||||||
|
);
|
||||||
|
|
||||||
|
// A pending change referencing the first email's old ID.
|
||||||
|
rawDb.execute(
|
||||||
|
'INSERT INTO pending_changes (account_id, resource_type, resource_id, change_type, payload, created_at) '
|
||||||
|
"VALUES ('acc-1', 'Email', 'acc-1:50', 'flag_seen', '{\"seen\":true}', $now)",
|
||||||
|
);
|
||||||
|
|
||||||
|
rawDb.execute('PRAGMA user_version = 40');
|
||||||
|
rawDb.close();
|
||||||
|
|
||||||
|
// Open with Drift to trigger the migration.
|
||||||
|
final db = AppDatabase(NativeDatabase(dbFile));
|
||||||
|
await db.select(db.accounts).get();
|
||||||
|
|
||||||
|
// emails.id should now use the accountId:mailboxPath:uid format.
|
||||||
|
final emailRows = await db.select(db.emails).get();
|
||||||
|
final emailIds = emailRows.map((r) => r.id).toSet();
|
||||||
|
expect(emailIds, contains('acc-1:INBOX:50'));
|
||||||
|
expect(emailIds, contains('acc-1:Archive:50'));
|
||||||
|
expect(emailIds, contains('acc-1:INBOX:99'));
|
||||||
|
// Old-format IDs must be gone.
|
||||||
|
expect(emailIds, isNot(contains('acc-1:50')));
|
||||||
|
expect(emailIds, isNot(contains('acc-1:99')));
|
||||||
|
|
||||||
|
// email_bodies.email_id must be updated.
|
||||||
|
final bodyRows = await db.select(db.emailBodies).get();
|
||||||
|
expect(bodyRows, hasLength(1));
|
||||||
|
expect(bodyRows.first.emailId, 'acc-1:INBOX:50');
|
||||||
|
|
||||||
|
// thread_id where it was the email's own ID should be updated.
|
||||||
|
final inboxEmail = emailRows.firstWhere((r) => r.id == 'acc-1:INBOX:50');
|
||||||
|
expect(inboxEmail.threadId, 'acc-1:INBOX:50');
|
||||||
|
|
||||||
|
// thread_id based on a real Message-ID must be left unchanged.
|
||||||
|
final inboxEmail99 =
|
||||||
|
emailRows.firstWhere((r) => r.id == 'acc-1:INBOX:99');
|
||||||
|
expect(inboxEmail99.threadId, '<original@example.com>');
|
||||||
|
|
||||||
|
// threads must be rebuilt with new-format IDs.
|
||||||
|
final threadRows = await db.select(db.threads).get();
|
||||||
|
final thread = threadRows.firstWhere((t) => t.mailboxPath == 'INBOX');
|
||||||
|
expect(thread.latestEmailId, 'acc-1:INBOX:50');
|
||||||
|
expect(thread.emailIdsJson, contains('acc-1:INBOX:50'));
|
||||||
|
|
||||||
|
// pending_changes.resource_id is not updated by the migration
|
||||||
|
// (IMAP operations use payload uid/mailboxPath, so this is safe).
|
||||||
|
final changeRows = await db.select(db.pendingChanges).get();
|
||||||
|
expect(changeRows, hasLength(1));
|
||||||
|
|
||||||
|
await db.close();
|
||||||
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fresh install creates all tables at schemaVersion 41', () async {
|
||||||
final db = AppDatabase(NativeDatabase.memory());
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
await db.select(db.accounts).get();
|
await db.select(db.accounts).get();
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ void main() {
|
|||||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows subject in app bar after data loads', (tester) async {
|
testWidgets('shows subject in email header section', (tester) async {
|
||||||
final email = testEmail(subject: 'Project update');
|
final email = testEmail(subject: 'Project update');
|
||||||
const body = EmailBody(
|
const body = EmailBody(
|
||||||
emailId: 'acc-1:42',
|
emailId: 'acc-1:42',
|
||||||
@@ -106,8 +106,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Subject appears in both the app bar and the email header section.
|
// Subject appears only in the email header section, not in the app bar.
|
||||||
expect(find.text('Project update'), findsAtLeastNWidgets(1));
|
expect(find.text('Project update'), findsOneWidget);
|
||||||
expect(find.text('See attached slides.'), findsOneWidget);
|
expect(find.text('See attached slides.'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -266,7 +266,7 @@ void main() {
|
|||||||
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
|
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Mark as spam is in popup menu, not a standalone button', (
|
testWidgets('Mark as spam is a standalone button, not in popup menu', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -279,19 +279,19 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// No standalone icon button for mark as spam.
|
// Standalone icon button for mark as spam is in the app bar.
|
||||||
expect(
|
expect(
|
||||||
find.byWidgetPredicate(
|
find.byWidgetPredicate(
|
||||||
(w) => w is Tooltip && w.message == 'Mark as spam',
|
(w) => w is Tooltip && w.message == 'Mark as spam',
|
||||||
),
|
),
|
||||||
findsNothing,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
|
|
||||||
// It appears in the popup menu.
|
// It does NOT appear in the popup menu.
|
||||||
await tester.tap(find.byType(PopupMenuButton<String>));
|
await tester.tap(find.byType(PopupMenuButton<String>));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Mark as spam'), findsOneWidget);
|
expect(find.text('Mark as spam'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Mark as spam shows dialog when no junk folder', (
|
testWidgets('Mark as spam shows dialog when no junk folder', (
|
||||||
@@ -309,11 +309,11 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Open the popup menu first, then tap Mark as spam.
|
await tester.tap(
|
||||||
await tester.tap(find.byType(PopupMenuButton<String>));
|
find.byWidgetPredicate(
|
||||||
await tester.pumpAndSettle();
|
(w) => w is Tooltip && w.message == 'Mark as spam',
|
||||||
|
),
|
||||||
await tester.tap(find.text('Mark as spam'));
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('No spam folder found'), findsOneWidget);
|
expect(find.text('No spam folder found'), findsOneWidget);
|
||||||
@@ -582,6 +582,54 @@ void main() {
|
|||||||
|
|
||||||
expect(find.textContaining('Structure not available'), findsOneWidget);
|
expect(find.textContaining('Structure not available'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Load remote images snack bar auto-dismisses after 3 seconds',
|
||||||
|
(tester) async {
|
||||||
|
const body = EmailBody(
|
||||||
|
emailId: 'acc-1:42',
|
||||||
|
htmlBody: '<p>Hello <img src="https://example.com/x.png"/></p>',
|
||||||
|
attachments: [],
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation:
|
||||||
|
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
|
overrides: _overrides(body: body),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The "Load remote images" button is visible because the sender is
|
||||||
|
// not yet trusted.
|
||||||
|
expect(find.text('Load remote images'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Load remote images'));
|
||||||
|
// Settle the snack bar enter animation and the setState rebuild
|
||||||
|
// that swaps in the image-loading WebView.
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
// Snack bar must be visible.
|
||||||
|
expect(
|
||||||
|
find.text('Images will be loaded automatically for this sender.'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
// After 3 seconds (the snack bar's duration) plus the reverse
|
||||||
|
// animation, the snack bar must be gone.
|
||||||
|
// Regression test for #484: SnackBar with an action defaults to
|
||||||
|
// persist=true, which disables auto-dismiss — explicit persist:false
|
||||||
|
// restores duration-based dismissal.
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text('Images will be loaded automatically for this sender.'),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -446,10 +446,10 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(EmailDetailScreen), findsOneWidget);
|
expect(find.byType(EmailDetailScreen), findsOneWidget);
|
||||||
// The detail AppBar title shows the first email's subject.
|
// The detail body header shows the first email's subject.
|
||||||
expect(
|
expect(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
of: find.byType(AppBar),
|
of: find.byType(EmailDetailScreen),
|
||||||
matching: find.text('Alpha Match'),
|
matching: find.text('Alpha Match'),
|
||||||
),
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
|
|||||||
@@ -249,5 +249,59 @@ void main() {
|
|||||||
|
|
||||||
expect(find.text('Body content here'), findsOneWidget);
|
expect(find.text('Body content here'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Load remote images snack bar auto-dismisses after 3 seconds',
|
||||||
|
(tester) async {
|
||||||
|
final email = _threadEmail();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
|
||||||
|
overrides: [
|
||||||
|
accountRepositoryProvider.overrideWithValue(
|
||||||
|
FakeAccountRepository([kTestAccount]),
|
||||||
|
),
|
||||||
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(
|
||||||
|
emails: [email],
|
||||||
|
emailBody: const EmailBody(
|
||||||
|
emailId: 'acc-1:10',
|
||||||
|
htmlBody:
|
||||||
|
'<p>Hi <img src="https://example.com/x.png"/></p>',
|
||||||
|
attachments: [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Load remote images'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Load remote images'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text('Images will be loaded automatically for this sender.'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Regression test for #484: SnackBar with an action defaults to
|
||||||
|
// persist=true, which disables auto-dismiss — explicit persist:false
|
||||||
|
// restores duration-based dismissal.
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text('Images will be loaded automatically for this sender.'),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:go_router/go_router.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/screens/undo_log_detail_screen.dart';
|
||||||
|
|
||||||
|
import 'helpers.dart';
|
||||||
|
|
||||||
|
// FakeEmailRepository subclass that returns a pre-configured email from
|
||||||
|
// findEmailByMessageId, so the tap handler in UndoLogDetailScreen can be
|
||||||
|
// exercised without a real database.
|
||||||
|
class _LookupEmailRepository extends FakeEmailRepository {
|
||||||
|
_LookupEmailRepository(this._lookup);
|
||||||
|
|
||||||
|
final Email? _lookup;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Email?> findEmailByMessageId(
|
||||||
|
String accountId,
|
||||||
|
String messageId,
|
||||||
|
) async =>
|
||||||
|
_lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
UndoAction _action({
|
||||||
|
required List<Email> originalEmails,
|
||||||
|
String accountId = 'acc-1',
|
||||||
|
}) =>
|
||||||
|
UndoAction(
|
||||||
|
id: 'undo-1',
|
||||||
|
accountId: accountId,
|
||||||
|
type: UndoType.move,
|
||||||
|
emailIds: originalEmails.map((e) => e.id).toList(),
|
||||||
|
sourceMailboxPath: 'INBOX',
|
||||||
|
destinationMailboxPath: 'Archive',
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
timestamp: DateTime(2024, 6),
|
||||||
|
);
|
||||||
|
|
||||||
|
Email _emailWith({
|
||||||
|
String id = 'acc-1:42',
|
||||||
|
String mailboxPath = 'INBOX',
|
||||||
|
String? messageId = '<msg-1@example.com>',
|
||||||
|
}) =>
|
||||||
|
Email(
|
||||||
|
id: id,
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: mailboxPath,
|
||||||
|
uid: 42,
|
||||||
|
subject: 'Hello world',
|
||||||
|
receivedAt: DateTime(2024, 6),
|
||||||
|
sentAt: DateTime(2024, 6),
|
||||||
|
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
||||||
|
to: const [EmailAddress(email: 'alice@example.com')],
|
||||||
|
cc: const [],
|
||||||
|
isSeen: false,
|
||||||
|
isFlagged: false,
|
||||||
|
hasAttachment: false,
|
||||||
|
messageId: messageId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Builds a minimal app whose initial location is the undo log detail screen
|
||||||
|
// for [action]. A placeholder email-detail route records its visit so the
|
||||||
|
// test can assert which path the tap navigated to.
|
||||||
|
Widget _buildApp({
|
||||||
|
required UndoAction action,
|
||||||
|
required FakeEmailRepository emailRepo,
|
||||||
|
ValueNotifier<String?>? lastEmailRoute,
|
||||||
|
}) {
|
||||||
|
final router = GoRouter(
|
||||||
|
initialLocation: '/undo-detail',
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/undo-detail',
|
||||||
|
builder: (ctx, state) => UndoLogDetailScreen(action: action),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/accounts/:accountId/mailboxes/:mailboxPath/emails/:emailId',
|
||||||
|
builder: (ctx, state) {
|
||||||
|
lastEmailRoute?.value = state.uri.toString();
|
||||||
|
return const Scaffold(body: Text('email-detail-route'));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
emailRepositoryProvider.overrideWithValue(emailRepo),
|
||||||
|
],
|
||||||
|
child: MaterialApp.router(routerConfig: router),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('UndoLogDetailScreen email row tap', () {
|
||||||
|
testWidgets('navigates to the current location returned by lookup', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
// Original row recorded INBOX/42; after the move it now lives in
|
||||||
|
// Archive with a fresh UID — the lookup is what bridges that gap.
|
||||||
|
final original = _emailWith();
|
||||||
|
final current = _emailWith(id: 'acc-1:77', mailboxPath: 'Archive');
|
||||||
|
final lastRoute = ValueNotifier<String?>(null);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildApp(
|
||||||
|
action: _action(originalEmails: [original]),
|
||||||
|
emailRepo: _LookupEmailRepository(current),
|
||||||
|
lastEmailRoute: lastRoute,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Hello world'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('email-detail-route'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
lastRoute.value,
|
||||||
|
'/accounts/acc-1/mailboxes/Archive/emails/acc-1%3A77',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows snackbar when lookup returns null', (tester) async {
|
||||||
|
final original = _emailWith();
|
||||||
|
final lastRoute = ValueNotifier<String?>(null);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildApp(
|
||||||
|
action: _action(originalEmails: [original]),
|
||||||
|
emailRepo: _LookupEmailRepository(null),
|
||||||
|
lastEmailRoute: lastRoute,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Hello world'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.textContaining('Email no longer exists'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(lastRoute.value, isNull);
|
||||||
|
expect(find.text('email-detail-route'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows snackbar when email has no Message-ID', (tester) async {
|
||||||
|
final original = _emailWith(messageId: null);
|
||||||
|
final lastRoute = ValueNotifier<String?>(null);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildApp(
|
||||||
|
action: _action(originalEmails: [original]),
|
||||||
|
// Lookup would succeed if called, but with no Message-ID the
|
||||||
|
// tap handler must short-circuit before reaching it.
|
||||||
|
emailRepo: _LookupEmailRepository(_emailWith()),
|
||||||
|
lastEmailRoute: lastRoute,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Hello world'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.textContaining('no Message-ID'), findsOneWidget);
|
||||||
|
expect(lastRoute.value, isNull);
|
||||||
|
expect(find.text('email-detail-route'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user