Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
774829ece5 | ||
|
|
33949b92c0 | ||
|
|
a1b9e0a8b0 | ||
|
|
3a08daa402 | ||
|
|
2336afa0d7 | ||
|
|
c343ed6bd7 | ||
|
|
1d5eb187bf |
@@ -109,51 +109,3 @@ jobs:
|
|||||||
- name: Cleanup TLS credentials
|
- name: Cleanup TLS credentials
|
||||||
if: always()
|
if: always()
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|
||||||
merge-renovate:
|
|
||||||
name: Auto-merge Renovate PR
|
|
||||||
needs: [check]
|
|
||||||
if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'renovate/')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Merge if automerge label is set
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ github.token }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
run: |
|
|
||||||
python3 - << 'PYEOF'
|
|
||||||
import os, json, urllib.request, urllib.error, sys
|
|
||||||
|
|
||||||
token = os.environ["FORGEJO_TOKEN"]
|
|
||||||
url_base = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
|
||||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
|
||||||
pr_number = os.environ["PR_NUMBER"]
|
|
||||||
api = f"{url_base}/api/v1/repos/{repo}"
|
|
||||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
|
||||||
|
|
||||||
req = urllib.request.Request(f"{api}/issues/{pr_number}/labels", headers=headers)
|
|
||||||
with urllib.request.urlopen(req) as r:
|
|
||||||
labels = [l["name"] for l in json.loads(r.read())]
|
|
||||||
|
|
||||||
if "automerge" not in labels:
|
|
||||||
print(f"PR #{pr_number}: no 'automerge' label — major update, skipping")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
body = json.dumps({"Do": "merge"}).encode()
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{api}/pulls/{pr_number}/merge",
|
|
||||||
data=body, headers=headers, method="POST"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req) as r:
|
|
||||||
print(f"PR #{pr_number} merged successfully")
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
err = e.read().decode()
|
|
||||||
if "already been merged" in err or "has been merged" in err:
|
|
||||||
print(f"PR #{pr_number} already merged — OK")
|
|
||||||
else:
|
|
||||||
print(f"Merge failed: {err}")
|
|
||||||
sys.exit(1)
|
|
||||||
PYEOF
|
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ jobs:
|
|||||||
- name: Detect Android and Linux changes
|
- name: Detect Android and Linux changes
|
||||||
id: diff
|
id: diff
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
run: |
|
||||||
# On workflow_dispatch always build everything
|
# On workflow_dispatch always build everything
|
||||||
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
||||||
@@ -32,38 +30,6 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
HEAD_SHA=$(git rev-parse HEAD)
|
|
||||||
|
|
||||||
# Skip if this exact commit was already successfully deployed (prevents
|
|
||||||
# hourly schedule from redeploying the same commit on every tick).
|
|
||||||
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
|
|
||||||
import json, os, sys, urllib.request
|
|
||||||
token = os.environ.get("FORGEJO_TOKEN", "")
|
|
||||||
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
|
||||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
|
||||||
url = f"{server}/api/v1/repos/{repo}/actions/runs?workflow_id=deploy.yml&status=success&limit=5"
|
|
||||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req) as r:
|
|
||||||
data = json.loads(r.read())
|
|
||||||
runs = [
|
|
||||||
r for r in data.get("workflow_runs", [])
|
|
||||||
if r.get("workflow_id") == "deploy.yml" and r.get("status") == "success"
|
|
||||||
]
|
|
||||||
print(runs[0].get("commit_sha") or "")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"API check failed: {e}", file=sys.stderr)
|
|
||||||
print("")
|
|
||||||
PYEOF
|
|
||||||
)
|
|
||||||
|
|
||||||
if [ -n "$LAST_DEPLOYED_SHA" ] && [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
|
|
||||||
echo "HEAD $HEAD_SHA already successfully deployed — skipping"
|
|
||||||
echo "android=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "linux=false" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
|
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
|
||||||
# when the parent is unavailable (initial commit, shallow clone).
|
# when the parent is unavailable (initial commit, shallow clone).
|
||||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
||||||
@@ -72,7 +38,7 @@ jobs:
|
|||||||
echo "Changed files:"
|
echo "Changed files:"
|
||||||
echo "$CHANGED"
|
echo "$CHANGED"
|
||||||
|
|
||||||
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
|
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/)'
|
||||||
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
||||||
|
|
||||||
echo "$CHANGED" | grep -qE "$android_re" \
|
echo "$CHANGED" | grep -qE "$android_re" \
|
||||||
@@ -83,6 +49,44 @@ jobs:
|
|||||||
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|
||||||
|| echo "linux=false" >> "$GITHUB_OUTPUT"
|
|| echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
test-android-firebase:
|
||||||
|
name: Android Instrumented Tests (Firebase Test Lab)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.android == 'true'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Check runner tools
|
||||||
|
run: |
|
||||||
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
|
env:
|
||||||
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
|
- name: Run Android Tests on Firebase Test Lab
|
||||||
|
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
|
||||||
|
env:
|
||||||
|
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
|
||||||
|
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: task test-android-firebase
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|
||||||
deploy-playstore:
|
deploy-playstore:
|
||||||
name: Build & Deploy to Play Store
|
name: Build & Deploy to Play Store
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -93,7 +97,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 100
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
@@ -132,7 +136,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 100
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
@@ -174,7 +178,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 100
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
@@ -249,9 +253,10 @@ jobs:
|
|||||||
label-deploy-health:
|
label-deploy-health:
|
||||||
name: Update Deploy Health Label
|
name: Update Deploy Health Label
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [deploy-playstore, deploy-apk, build-linux]
|
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux]
|
||||||
if: |
|
if: |
|
||||||
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
|
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
|
||||||
|
needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'failure' ||
|
||||||
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
|
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
|
||||||
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
|
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
|
||||||
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
|
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
|
||||||
@@ -264,7 +269,7 @@ jobs:
|
|||||||
FORGEJO_TOKEN: ${{ github.token }}
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
FORGEJO_URL: ${{ github.server_url }}
|
FORGEJO_URL: ${{ github.server_url }}
|
||||||
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
|
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
|
||||||
ALL_SUCCEEDED: ${{ (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
|
ALL_SUCCEEDED: ${{ (needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'skipped') && (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
|
||||||
run: |
|
run: |
|
||||||
python3 - << 'PYEOF'
|
python3 - << 'PYEOF'
|
||||||
import os, json, urllib.request, urllib.error
|
import os, json, urllib.request, urllib.error
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
name: Firebase Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 3 * * *' # once per day at 3 AM
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-changes:
|
|
||||||
name: Detect Firebase-Relevant Changes
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
outputs:
|
|
||||||
has_changes: ${{ steps.diff.outputs.has_changes }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Detect Firebase-relevant changes in last 24 hours
|
|
||||||
id: diff
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
# On workflow_dispatch always run
|
|
||||||
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
|
||||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
SINCE=$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%S')
|
|
||||||
CHANGED=$(git log --since="$SINCE" --name-only --format= -- \
|
|
||||||
'android/' 'integration_test/' 'lib/' 'pubspec.yaml' 'pubspec.lock' 'drift_schemas/' \
|
|
||||||
| sort -u | grep -v '^$')
|
|
||||||
|
|
||||||
if [ -n "$CHANGED" ]; then
|
|
||||||
echo "Firebase-relevant files changed since $SINCE:"
|
|
||||||
echo "$CHANGED"
|
|
||||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "No Firebase-relevant changes in the last 24 hours — skipping tests"
|
|
||||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
test-android-firebase:
|
|
||||||
name: Android Instrumented Tests (Firebase Test Lab)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 60
|
|
||||||
needs: [check-changes]
|
|
||||||
if: needs.check-changes.outputs.has_changes == 'true'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Check runner tools
|
|
||||||
run: |
|
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine (via stunnel)
|
|
||||||
env:
|
|
||||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
|
||||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
|
||||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
|
||||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
|
||||||
run: scripts/setup_dagger_remote.sh
|
|
||||||
|
|
||||||
- name: Run Android Tests on Firebase Test Lab
|
|
||||||
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
|
|
||||||
env:
|
|
||||||
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
|
|
||||||
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
|
||||||
run: task test-android-firebase
|
|
||||||
|
|
||||||
- name: Cleanup TLS credentials
|
|
||||||
if: always()
|
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
|
||||||
|
|
||||||
- name: Create issue on test failure
|
|
||||||
if: failure()
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ github.token }}
|
|
||||||
FORGEJO_URL: ${{ github.server_url }}
|
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
||||||
run: |
|
|
||||||
python3 - << 'PYEOF'
|
|
||||||
import os, json, urllib.request, urllib.error
|
|
||||||
|
|
||||||
token = os.environ["FORGEJO_TOKEN"]
|
|
||||||
url_base = os.environ["FORGEJO_URL"].rstrip("/")
|
|
||||||
run_url = os.environ["RUN_URL"]
|
|
||||||
|
|
||||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
|
||||||
api = f"{url_base}/api/v1/repos/guettli/sharedinbox"
|
|
||||||
|
|
||||||
def api_get(path):
|
|
||||||
req = urllib.request.Request(f"{api}{path}", headers=headers)
|
|
||||||
with urllib.request.urlopen(req) as r:
|
|
||||||
return json.loads(r.read())
|
|
||||||
|
|
||||||
def api_post(path, body):
|
|
||||||
data = json.dumps(body).encode()
|
|
||||||
req = urllib.request.Request(f"{api}{path}", data=data, headers=headers, method="POST")
|
|
||||||
with urllib.request.urlopen(req) as r:
|
|
||||||
return json.loads(r.read())
|
|
||||||
|
|
||||||
repo_labels = api_get("/labels")
|
|
||||||
label_map = {l["name"]: l["id"] for l in repo_labels}
|
|
||||||
|
|
||||||
label_ids = [label_map["Ready"]] if "Ready" in label_map else []
|
|
||||||
|
|
||||||
title = "Firebase Tests failed — find root cause and fix"
|
|
||||||
body = (
|
|
||||||
"Firebase instrumented tests failed in the daily run.\n\n"
|
|
||||||
f"**Failed run:** {run_url}\n\n"
|
|
||||||
"## Steps to resolve\n\n"
|
|
||||||
"1. **Find the root cause**: Check the test run logs linked above and identify which test(s) failed and why.\n"
|
|
||||||
"2. **Fix if possible**: If the failure is caused by a code bug, create a fix. If it is a flaky or infrastructure issue, document the findings.\n"
|
|
||||||
"3. Close this issue once the root cause is resolved and the tests pass.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
issue = api_post("/issues", {
|
|
||||||
"title": title,
|
|
||||||
"body": body,
|
|
||||||
"labels": label_ids,
|
|
||||||
})
|
|
||||||
print(f"Created issue #{issue['number']}: {issue['html_url']}")
|
|
||||||
PYEOF
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
name: Renovate
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 6 * * *'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
renovate:
|
|
||||||
name: Renovate
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Check runner tools
|
|
||||||
run: |
|
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine (via stunnel)
|
|
||||||
env:
|
|
||||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
|
||||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
|
||||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
|
||||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
|
||||||
run: scripts/setup_dagger_remote.sh
|
|
||||||
|
|
||||||
- name: Run Renovate
|
|
||||||
env:
|
|
||||||
DAGGER_NO_NAG: "1"
|
|
||||||
RENOVATE_FORGEJO_TOKEN: ${{ secrets.RENOVATE_FORGEJO_TOKEN }}
|
|
||||||
run: task renovate
|
|
||||||
|
|
||||||
- name: Cleanup TLS credentials
|
|
||||||
if: always()
|
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
|
||||||
@@ -13,42 +13,20 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
name: Build & Deploy Website
|
name: Build & Deploy Website
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Check runner tools
|
|
||||||
run: |
|
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine (via stunnel)
|
|
||||||
env:
|
|
||||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
|
||||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
|
||||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
|
||||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
|
||||||
run: scripts/setup_dagger_remote.sh
|
|
||||||
|
|
||||||
- name: Build & Deploy Website
|
- name: Build & Deploy Website
|
||||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
DAGGER_NO_NAG: "1"
|
run: task website-deploy
|
||||||
run: task publish-website
|
|
||||||
|
|
||||||
- name: Verify Website
|
- name: Verify Website
|
||||||
env:
|
env:
|
||||||
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
||||||
run: scripts/website-verify.sh
|
run: scripts/website-verify.sh
|
||||||
|
|
||||||
- name: Cleanup TLS credentials
|
|
||||||
if: always()
|
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
|
||||||
|
|||||||
+1
-2
@@ -28,8 +28,7 @@ android/.gradle/
|
|||||||
android/local.properties
|
android/local.properties
|
||||||
android/app/google-services.json
|
android/app/google-services.json
|
||||||
android/key.properties
|
android/key.properties
|
||||||
# android/app/src/main/java/io/flutter/plugins/ intentionally tracked so that
|
android/app/src/main/java/io/flutter/plugins/
|
||||||
# GeneratedPluginRegistrant.java (catch Throwable) is committed and used by CI.
|
|
||||||
.android/
|
.android/
|
||||||
Android/
|
Android/
|
||||||
.gradle/
|
.gradle/
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ repos:
|
|||||||
- id: ci-no-direct-dagger
|
- id: ci-no-direct-dagger
|
||||||
name: check for direct dagger calls in workflows (use Task instead)
|
name: check for direct dagger calls in workflows (use Task instead)
|
||||||
language: system
|
language: system
|
||||||
entry: "bash -c 'git --no-pager grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'"
|
entry: "bash -c 'git grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'"
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
- id: dagger-progress-plain
|
- id: dagger-progress-plain
|
||||||
name: ensure all dagger calls use --progress=plain
|
name: ensure all dagger calls use --progress=plain
|
||||||
language: system
|
language: system
|
||||||
entry: "bash -c 'git --no-pager grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
|
entry: "bash -c 'git grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
|
|||||||
@@ -10,21 +10,9 @@ CLI tool `fgj` is available to query issues/PRs/actions.
|
|||||||
|
|
||||||
We use issues, follow this label state machine:
|
We use issues, follow this label state machine:
|
||||||
|
|
||||||
- **State/ToPlan** — Issue needs a plan written by an agent before implementation
|
- **State/Ready** — Issue is available to pick up
|
||||||
- **State/Planned** — Plan has been posted as a comment; awaiting human review
|
- **State/InProgress** — Set this when you start working on an issue
|
||||||
- **State/Ready** — Issue is approved and ready for implementation
|
- **State/Question** — Set this when you hit a blocker or need clarification
|
||||||
- **State/InProgress** — Set while an agent (or human) is actively working
|
|
||||||
- **State/Question** — Agent hit a blocker or needs clarification
|
|
||||||
|
|
||||||
Full lifecycle:
|
|
||||||
|
|
||||||
```
|
|
||||||
State/ToPlan → State/Planned (automated: agent_loop.py runs a planning agent)
|
|
||||||
State/Planned → State/Ready (manual: human reviews the plan and approves)
|
|
||||||
State/Ready → State/InProgress (automated: agent_loop.py before starting implementation)
|
|
||||||
State/InProgress → closed (automated: after PR is merged and CI passes)
|
|
||||||
any state → State/Question (automated or manual: when blocked)
|
|
||||||
```
|
|
||||||
|
|
||||||
List open issues ready to pick up:
|
List open issues ready to pick up:
|
||||||
|
|
||||||
@@ -34,11 +22,9 @@ fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/
|
|||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- Never start implementation on an issue without `State/Ready`
|
- Never start work on an issue without `State/Ready`
|
||||||
- Planning agents only post a plan comment — they do NOT write code or open PRs
|
- When working via the agent loop: `State/Ready` → `State/InProgress` is set automatically
|
||||||
- After `State/Planned`, a human must review the plan and manually add `State/Ready`
|
by `agent_loop.py` before the agent starts — do **not** set it yourself.
|
||||||
- When working via the agent loop: label transitions are set automatically
|
|
||||||
by `agent_loop.py` — do **not** set them yourself.
|
|
||||||
- When working manually: switch to `State/InProgress` as your **first action**:
|
- When working manually: switch to `State/InProgress` as your **first action**:
|
||||||
```bash
|
```bash
|
||||||
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
|
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
|
||||||
|
|||||||
+2
-11
@@ -224,7 +224,7 @@ tasks:
|
|||||||
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
||||||
cmds:
|
cmds:
|
||||||
- mkdir -p build/app/outputs/bundle/release
|
- mkdir -p build/app/outputs/bundle/release
|
||||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
|
- dagger call --progress=plain -q -m ci --source=. build-android-release -o build/app/outputs/bundle/release/app-release.aab
|
||||||
|
|
||||||
upload-android-bundle:
|
upload-android-bundle:
|
||||||
desc: Upload AAB from build/ to Play Store via Dagger
|
desc: Upload AAB from build/ to Play Store via Dagger
|
||||||
@@ -238,7 +238,6 @@ tasks:
|
|||||||
|
|
||||||
publish-android:
|
publish-android:
|
||||||
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
||||||
deps: [generate-changelog]
|
|
||||||
preconditions:
|
preconditions:
|
||||||
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
||||||
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
||||||
@@ -247,7 +246,7 @@ tasks:
|
|||||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
|
- dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD
|
||||||
|
|
||||||
deploy-apk:
|
deploy-apk:
|
||||||
desc: Build and deploy Android APK via Dagger
|
desc: Build and deploy Android APK via Dagger
|
||||||
@@ -336,14 +335,6 @@ tasks:
|
|||||||
- |
|
- |
|
||||||
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
|
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
|
||||||
|
|
||||||
renovate:
|
|
||||||
desc: Run Renovate bot against the repository via Dagger
|
|
||||||
preconditions:
|
|
||||||
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
|
|
||||||
msg: "RENOVATE_FORGEJO_TOKEN is not set"
|
|
||||||
cmds:
|
|
||||||
- dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
|
|
||||||
|
|
||||||
integration-android:
|
integration-android:
|
||||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
||||||
deps: [_preflight, _android-sdk-check, _android-avd-setup]
|
deps: [_preflight, _android-sdk-check, _android-avd-setup]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ gradle-wrapper.jar
|
|||||||
/gradlew
|
/gradlew
|
||||||
/gradlew.bat
|
/gradlew.bat
|
||||||
/local.properties
|
/local.properties
|
||||||
|
GeneratedPluginRegistrant.java
|
||||||
.cxx/
|
.cxx/
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
# Remember to never publicly share your keystore.
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
package io.flutter.plugins;
|
|
||||||
|
|
||||||
import androidx.annotation.Keep;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import io.flutter.Log;
|
|
||||||
|
|
||||||
import io.flutter.embedding.engine.FlutterEngine;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generated file. Do not edit.
|
|
||||||
* This file is generated by the Flutter tool based on the
|
|
||||||
* plugins that support the Android platform.
|
|
||||||
*/
|
|
||||||
@Keep
|
|
||||||
public final class GeneratedPluginRegistrant {
|
|
||||||
private static final String TAG = "GeneratedPluginRegistrant";
|
|
||||||
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin device_info_plus, dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new dev.steenbakker.mobile_scanner.MobileScannerPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin mobile_scanner, dev.steenbakker.mobile_scanner.MobileScannerPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new com.crazecoder.openfile.OpenFilePlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin open_filex, com.crazecoder.openfile.OpenFilePlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.share.SharePlusPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin share_plus, dev.fluttercommunity.plus.share.SharePlusPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+15
-84
@@ -195,8 +195,7 @@ func (m *Ci) toolchain() *dagger.Container {
|
|||||||
WithUser("ci").
|
WithUser("ci").
|
||||||
WithExec([]string{"/bin/sh", "-c",
|
WithExec([]string{"/bin/sh", "-c",
|
||||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`}).
|
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`})
|
||||||
WithExec([]string{"flutter", "precache", "--linux", "--no-android", "--no-ios"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base is the Flutter toolchain container with mutable cache mounts attached.
|
// Base is the Flutter toolchain container with mutable cache mounts attached.
|
||||||
@@ -286,21 +285,6 @@ func (m *Ci) firebaseSrc() *dagger.Directory {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// androidBase wraps setup(androidSrc()) with the Gradle named-cache so that
|
|
||||||
// Gradle dependencies survive across Dagger execution-cache misses.
|
|
||||||
func (m *Ci) androidBase() *dagger.Container {
|
|
||||||
return m.setup(m.androidSrc()).
|
|
||||||
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
|
|
||||||
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// firebaseBase wraps setup(firebaseSrc()) with the Gradle named-cache.
|
|
||||||
func (m *Ci) firebaseBase() *dagger.Container {
|
|
||||||
return m.setup(m.firebaseSrc()).
|
|
||||||
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
|
|
||||||
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// linuxSrc is the source subset for Linux builds and integration tests.
|
// linuxSrc is the source subset for Linux builds and integration tests.
|
||||||
func (m *Ci) linuxSrc() *dagger.Directory {
|
func (m *Ci) linuxSrc() *dagger.Directory {
|
||||||
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
@@ -599,17 +583,9 @@ func (m *Ci) BuildLinux() *dagger.Directory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BuildLinuxRelease builds the Linux release bundle.
|
// BuildLinuxRelease builds the Linux release bundle.
|
||||||
func (m *Ci) BuildLinuxRelease(
|
func (m *Ci) BuildLinuxRelease() *dagger.Directory {
|
||||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
|
||||||
// +optional
|
|
||||||
commitHash string,
|
|
||||||
) *dagger.Directory {
|
|
||||||
args := []string{"flutter", "build", "linux", "--release"}
|
|
||||||
if commitHash != "" {
|
|
||||||
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
|
||||||
}
|
|
||||||
return m.setup(m.linuxSrc()).
|
return m.setup(m.linuxSrc()).
|
||||||
WithExec(args).
|
WithExec([]string{"flutter", "build", "linux", "--release"}).
|
||||||
Directory("build/linux/x64/release/bundle")
|
Directory("build/linux/x64/release/bundle")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,7 +598,7 @@ func (m *Ci) DeployLinux(
|
|||||||
sshHost string,
|
sshHost string,
|
||||||
commitHash string,
|
commitHash string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
bundle := m.BuildLinuxRelease(commitHash)
|
bundle := m.BuildLinuxRelease()
|
||||||
|
|
||||||
datePath := time.Now().Format("2006/01/02")
|
datePath := time.Now().Format("2006/01/02")
|
||||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||||
@@ -638,27 +614,16 @@ func (m *Ci) DeployLinux(
|
|||||||
|
|
||||||
// setupKeystore decodes the base64 keystore into the android build container.
|
// setupKeystore decodes the base64 keystore into the android build container.
|
||||||
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
|
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
|
||||||
return m.androidBase().
|
return m.setup(m.androidSrc()).
|
||||||
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
||||||
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
||||||
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
|
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildAndroidApk builds a release APK signed with the upload key.
|
// BuildAndroidApk builds a release APK signed with the upload key.
|
||||||
func (m *Ci) BuildAndroidApk(
|
func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string) *dagger.File {
|
||||||
keystoreBase64 *dagger.Secret,
|
|
||||||
keystorePassword *dagger.Secret,
|
|
||||||
buildNumber string,
|
|
||||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
|
||||||
// +optional
|
|
||||||
commitHash string,
|
|
||||||
) *dagger.File {
|
|
||||||
args := []string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}
|
|
||||||
if commitHash != "" {
|
|
||||||
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
|
||||||
}
|
|
||||||
return m.setupKeystore(keystoreBase64, keystorePassword).
|
return m.setupKeystore(keystoreBase64, keystorePassword).
|
||||||
WithExec(args).
|
WithExec([]string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}).
|
||||||
File("build/app/outputs/flutter-apk/app-release.apk")
|
File("build/app/outputs/flutter-apk/app-release.apk")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,7 +639,7 @@ func (m *Ci) DeployApk(
|
|||||||
keystorePassword *dagger.Secret,
|
keystorePassword *dagger.Secret,
|
||||||
buildNumber string,
|
buildNumber string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber, commitHash)
|
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber)
|
||||||
|
|
||||||
datePath := time.Now().Format("2006/01/02")
|
datePath := time.Now().Format("2006/01/02")
|
||||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||||
@@ -690,7 +655,8 @@ func (m *Ci) DeployApk(
|
|||||||
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
|
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
|
||||||
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
||||||
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
||||||
built := m.firebaseBase().
|
built := m.setup(m.firebaseSrc()).
|
||||||
|
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"}).
|
||||||
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
||||||
WithWorkdir("/src/android").
|
WithWorkdir("/src/android").
|
||||||
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
||||||
@@ -749,17 +715,9 @@ func (m *Ci) TestAndroidFirebase(
|
|||||||
|
|
||||||
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
|
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
|
||||||
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
|
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
|
||||||
func (m *Ci) BuildAndroidRelease(
|
func (m *Ci) BuildAndroidRelease() *dagger.File {
|
||||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
return m.setup(m.androidSrc()).
|
||||||
// +optional
|
WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}).
|
||||||
commitHash string,
|
|
||||||
) *dagger.File {
|
|
||||||
args := []string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}
|
|
||||||
if commitHash != "" {
|
|
||||||
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
|
||||||
}
|
|
||||||
return m.androidBase().
|
|
||||||
WithExec(args).
|
|
||||||
File("build/app/outputs/bundle/release/app-release.aab")
|
File("build/app/outputs/bundle/release/app-release.aab")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,41 +789,14 @@ func (m *Ci) PublishAndroid(
|
|||||||
playStoreConfig *dagger.Secret,
|
playStoreConfig *dagger.Secret,
|
||||||
keystoreBase64 *dagger.Secret,
|
keystoreBase64 *dagger.Secret,
|
||||||
keystorePassword *dagger.Secret,
|
keystorePassword *dagger.Secret,
|
||||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
|
||||||
// +optional
|
|
||||||
commitHash string,
|
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
versionCode := int(time.Now().Unix())
|
versionCode := int(time.Now().Unix())
|
||||||
aab := m.BuildAndroidRelease(commitHash)
|
aab := m.BuildAndroidRelease()
|
||||||
stamped := m.StampAndroidVersionCode(aab, versionCode)
|
stamped := m.StampAndroidVersionCode(aab, versionCode)
|
||||||
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
|
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
|
||||||
return m.UploadToPlayStore(ctx, signed, playStoreConfig)
|
return m.UploadToPlayStore(ctx, signed, playStoreConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renovate runs Renovate bot against the repository on Forgejo/Codeberg.
|
|
||||||
func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string, error) {
|
|
||||||
// Codeberg's GET /pulls?state=all&limit=100 times out with a 504, but limit=10
|
|
||||||
// completes in ~9 s. Patch the compiled pr-cache.js to use 10 instead of the
|
|
||||||
// hardcoded 20/100 values before launching renovate.
|
|
||||||
const patchCmd = `for f in \
|
|
||||||
/usr/local/renovate/dist/modules/platform/forgejo/pr-cache.js \
|
|
||||||
/usr/local/renovate/dist/modules/platform/gitea/pr-cache.js; do \
|
|
||||||
sed -i 's/limit: this\.items\.length ? 20 : 100/limit: this.items.length ? 10 : 10/' "$f" && echo "patched $f"; \
|
|
||||||
done`
|
|
||||||
return dag.Container().
|
|
||||||
From("renovate/renovate:43").
|
|
||||||
WithSecretVariable("RENOVATE_TOKEN", renovateToken).
|
|
||||||
WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
|
|
||||||
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
|
|
||||||
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
|
|
||||||
WithEnvVariable("LOG_LEVEL", "info").
|
|
||||||
WithUser("root").
|
|
||||||
WithExec([]string{"/bin/sh", "-c", patchCmd}).
|
|
||||||
WithUser("ubuntu").
|
|
||||||
WithExec([]string{"renovate"}).
|
|
||||||
Stdout(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graph returns a Mermaid diagram of the CI pipeline structure.
|
// Graph returns a Mermaid diagram of the CI pipeline structure.
|
||||||
// Paste the output into any Mermaid renderer (codeberg, github, mermaid.live)
|
// Paste the output into any Mermaid renderer (codeberg, github, mermaid.live)
|
||||||
// or save it as a .md file to get a rendered diagram.
|
// or save it as a .md file to get a rendered diagram.
|
||||||
@@ -879,7 +810,7 @@ func (m *Ci) Graph() string {
|
|||||||
` + "```" + `mermaid
|
` + "```" + `mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
subgraph dagger ["Dagger · Check pipeline"]
|
subgraph dagger ["Dagger · Check pipeline"]
|
||||||
toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"]
|
toolchain["toolchain\nflutter:3.41.6 + NDK + apt"]
|
||||||
pubGet["pubGetLayer\nflutter pub get"]
|
pubGet["pubGetLayer\nflutter pub get"]
|
||||||
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
||||||
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export SSH_PRIVATE_KEY=$(cat "$HOME/.ssh/id_ed25519")
|
|||||||
|
|
||||||
# Add nix profile and nix store tools (task, dagger) to PATH
|
# Add nix profile and nix store tools (task, dagger) to PATH
|
||||||
export PATH="$HOME/.nix-profile/bin:$PATH"
|
export PATH="$HOME/.nix-profile/bin:$PATH"
|
||||||
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger" "*fgj-*/bin/fgj"; do
|
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger"; do
|
||||||
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
|
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
|
||||||
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
|
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -4,16 +4,6 @@ This file contains tasks which got implemented.
|
|||||||
|
|
||||||
Tasks get moved from next.md to done.md
|
Tasks get moved from next.md to done.md
|
||||||
|
|
||||||
## Tasks (2026-05-26)
|
|
||||||
|
|
||||||
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
|
|
||||||
dependencies up to date. All required components are in main:
|
|
||||||
- `renovate.json` — Renovate configuration covering pub, Dockerfile, and Forgejo Actions
|
|
||||||
- `ci/main.go` — `Renovate()` Dagger function using Forgejo platform and Codeberg endpoint
|
|
||||||
- `.forgejo/workflows/renovate.yml` — daily cron (06:00 UTC) workflow
|
|
||||||
- `Taskfile.yml` — `renovate` task
|
|
||||||
- Issue #257 closed.
|
|
||||||
|
|
||||||
## Tasks (2026-05-11)
|
## Tasks (2026-05-11)
|
||||||
|
|
||||||
- **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering
|
- **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
const int dbSchemaVersion = 33;
|
|
||||||
@@ -11,13 +11,4 @@ abstract class MailboxRepository {
|
|||||||
|
|
||||||
/// Deletes all locally-cached mailbox rows for [accountId].
|
/// Deletes all locally-cached mailbox rows for [accountId].
|
||||||
Future<void> clearForResync(String accountId);
|
Future<void> clearForResync(String accountId);
|
||||||
|
|
||||||
/// Creates a new mailbox named [name] for [accountId] and tags it with
|
|
||||||
/// [role] in the local database. For JMAP accounts the role is also sent
|
|
||||||
/// to the server. Returns the newly created [Mailbox].
|
|
||||||
Future<Mailbox> createMailboxWithRole(
|
|
||||||
String accountId,
|
|
||||||
String name,
|
|
||||||
String role,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:drift/native.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:sharedinbox/core/db_schema_version.dart';
|
|
||||||
|
|
||||||
part 'database.g.dart';
|
part 'database.g.dart';
|
||||||
|
|
||||||
@@ -333,7 +332,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => dbSchemaVersion;
|
int get schemaVersion => 33;
|
||||||
|
|
||||||
Future<void> _createEmailFts() async {
|
Future<void> _createEmailFts() async {
|
||||||
await customStatement('''
|
await customStatement('''
|
||||||
|
|||||||
@@ -79,14 +79,6 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
final mailboxes = await client.listMailboxes(recursive: true);
|
final mailboxes = await client.listMailboxes(recursive: true);
|
||||||
|
|
||||||
// Pre-load existing DB roles so we can preserve manually-set roles for
|
|
||||||
// folders the server doesn't tag with a special-use attribute.
|
|
||||||
final existingRows = await (_db.select(_db.mailboxes)
|
|
||||||
..where((t) => t.accountId.equals(account.id)))
|
|
||||||
.get();
|
|
||||||
final existingRoles = {for (final r in existingRows) r.id: r.role};
|
|
||||||
|
|
||||||
for (final mb in mailboxes) {
|
for (final mb in mailboxes) {
|
||||||
final path = mb.path;
|
final path = mb.path;
|
||||||
final id = '${account.id}:$path';
|
final id = '${account.id}:$path';
|
||||||
@@ -104,12 +96,6 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
log('STATUS skipped for $path: $e');
|
log('STATUS skipped for $path: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the server-assigned role when available; fall back to the
|
|
||||||
// existing DB role so that manually-created folders (e.g. a user
|
|
||||||
// who just created their Archive folder) keep their role across syncs
|
|
||||||
// when the IMAP server does not expose a special-use attribute.
|
|
||||||
final role = _imapRole(mb) ?? existingRoles[id];
|
|
||||||
|
|
||||||
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
||||||
MailboxesCompanion.insert(
|
MailboxesCompanion.insert(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -118,7 +104,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
name: mb.name,
|
name: mb.name,
|
||||||
unreadCount: Value(unread),
|
unreadCount: Value(unread),
|
||||||
totalCount: Value(total),
|
totalCount: Value(total),
|
||||||
role: Value(role),
|
role: Value(_imapRole(mb)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -324,104 +310,4 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
..where((t) => t.accountId.equals(accountId)))
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Future<model.Mailbox> createMailboxWithRole(
|
|
||||||
String accountId,
|
|
||||||
String name,
|
|
||||||
String role,
|
|
||||||
) async {
|
|
||||||
final account = (await _accounts.getAccount(accountId))!;
|
|
||||||
final password = await _accounts.getPassword(accountId);
|
|
||||||
switch (account.type) {
|
|
||||||
case account_model.AccountType.imap:
|
|
||||||
return _createMailboxWithRoleImap(account, password, name, role);
|
|
||||||
case account_model.AccountType.jmap:
|
|
||||||
return _createMailboxWithRoleJmap(account, password, name, role);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<model.Mailbox> _createMailboxWithRoleImap(
|
|
||||||
account_model.Account account,
|
|
||||||
String password,
|
|
||||||
String name,
|
|
||||||
String role,
|
|
||||||
) async {
|
|
||||||
final client = await _imapConnect(
|
|
||||||
account,
|
|
||||||
_effectiveUsername(account),
|
|
||||||
password,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await client.createMailbox(name);
|
|
||||||
} finally {
|
|
||||||
await client.logout();
|
|
||||||
}
|
|
||||||
final id = '${account.id}:$name';
|
|
||||||
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
|
||||||
MailboxesCompanion.insert(
|
|
||||||
id: id,
|
|
||||||
accountId: account.id,
|
|
||||||
path: name,
|
|
||||||
name: name,
|
|
||||||
role: Value(role),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final row = await (_db.select(_db.mailboxes)..where((t) => t.id.equals(id)))
|
|
||||||
.getSingle();
|
|
||||||
return _toModel(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<model.Mailbox> _createMailboxWithRoleJmap(
|
|
||||||
account_model.Account account,
|
|
||||||
String password,
|
|
||||||
String name,
|
|
||||||
String role,
|
|
||||||
) async {
|
|
||||||
final jmapUrl = account.jmapUrl;
|
|
||||||
if (jmapUrl == null || jmapUrl.isEmpty) {
|
|
||||||
throw Exception('JMAP account ${account.id} has no jmapUrl');
|
|
||||||
}
|
|
||||||
final jmap = await JmapClient.connect(
|
|
||||||
httpClient: _httpClient,
|
|
||||||
jmapUrl: Uri.parse(jmapUrl),
|
|
||||||
username: _effectiveUsername(account),
|
|
||||||
password: password,
|
|
||||||
);
|
|
||||||
final responses = await jmap.call([
|
|
||||||
[
|
|
||||||
'Mailbox/set',
|
|
||||||
{
|
|
||||||
'accountId': jmap.accountId,
|
|
||||||
'create': {
|
|
||||||
'new-mailbox': {'name': name, 'role': role},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'0',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
final result = _responseArgs(responses, 0, 'Mailbox/set');
|
|
||||||
final created = result['created'] as Map<String, dynamic>?;
|
|
||||||
final newId =
|
|
||||||
(created?['new-mailbox'] as Map<String, dynamic>?)?['id'] as String?;
|
|
||||||
if (newId == null) {
|
|
||||||
throw Exception(
|
|
||||||
'Failed to create mailbox "$name": server returned no ID',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final dbId = '${account.id}:$newId';
|
|
||||||
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
|
||||||
MailboxesCompanion.insert(
|
|
||||||
id: dbId,
|
|
||||||
accountId: account.id,
|
|
||||||
path: newId,
|
|
||||||
name: name,
|
|
||||||
role: Value(role),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final row = await (_db.select(_db.mailboxes)
|
|
||||||
..where((t) => t.id.equals(dbId)))
|
|
||||||
.getSingle();
|
|
||||||
return _toModel(row);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
@@ -19,20 +20,13 @@ class AboutScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||||
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
||||||
late final Future<String?> _deviceModelFuture;
|
final Future<AndroidDeviceInfo?> _androidInfoFuture = getAndroidDeviceInfo();
|
||||||
late final Stream<List<Account>> _accountsStream;
|
late final Stream<List<Account>> _accountsStream;
|
||||||
String? _deviceModel;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
||||||
_deviceModelFuture = getDeviceModel();
|
|
||||||
unawaited(
|
|
||||||
_deviceModelFuture.then((model) {
|
|
||||||
if (mounted) setState(() => _deviceModel = model);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _copyToClipboard(
|
Future<void> _copyToClipboard(
|
||||||
@@ -44,10 +38,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
try {
|
try {
|
||||||
pkg = await _packageInfoFuture;
|
pkg = await _packageInfoFuture;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
String? deviceModel;
|
final androidInfo = await _androidInfoFuture;
|
||||||
try {
|
|
||||||
deviceModel = await _deviceModelFuture;
|
|
||||||
} catch (_) {}
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
await Clipboard.setData(
|
await Clipboard.setData(
|
||||||
ClipboardData(
|
ClipboardData(
|
||||||
@@ -56,7 +47,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
pkg: pkg,
|
pkg: pkg,
|
||||||
imapCount: imapCount,
|
imapCount: imapCount,
|
||||||
jmapCount: jmapCount,
|
jmapCount: jmapCount,
|
||||||
deviceModel: deviceModel,
|
androidInfo: androidInfo,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -70,30 +61,6 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _launchUrl(BuildContext context, Uri url) async {
|
|
||||||
try {
|
|
||||||
final launched =
|
|
||||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
|
||||||
if (!launched && context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
duration: Duration(seconds: 5),
|
|
||||||
content: Text('Could not open browser.'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
duration: const Duration(seconds: 5),
|
|
||||||
content: Text('Error: $e'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _createIssue(
|
Future<void> _createIssue(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
int imapCount,
|
int imapCount,
|
||||||
@@ -103,10 +70,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
try {
|
try {
|
||||||
pkg = await _packageInfoFuture;
|
pkg = await _packageInfoFuture;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
String? deviceModel;
|
final androidInfo = await _androidInfoFuture;
|
||||||
try {
|
|
||||||
deviceModel = await _deviceModelFuture;
|
|
||||||
} catch (_) {}
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
final body = Uri.encodeComponent(
|
final body = Uri.encodeComponent(
|
||||||
buildAboutMarkdown(
|
buildAboutMarkdown(
|
||||||
@@ -114,7 +78,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
pkg: pkg,
|
pkg: pkg,
|
||||||
imapCount: imapCount,
|
imapCount: imapCount,
|
||||||
jmapCount: jmapCount,
|
jmapCount: jmapCount,
|
||||||
deviceModel: deviceModel,
|
androidInfo: androidInfo,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final url = Uri.parse(
|
final url = Uri.parse(
|
||||||
@@ -165,21 +129,29 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
return Markdown(
|
return FutureBuilder<AndroidDeviceInfo?>(
|
||||||
data: buildAboutMarkdown(
|
future: _androidInfoFuture,
|
||||||
context: context,
|
builder: (context, androidSnapshot) {
|
||||||
pkg: snapshot.data,
|
return Markdown(
|
||||||
imapCount: imapCount,
|
data: buildAboutMarkdown(
|
||||||
jmapCount: jmapCount,
|
context: context,
|
||||||
deviceModel: _deviceModel,
|
pkg: snapshot.data,
|
||||||
),
|
imapCount: imapCount,
|
||||||
selectable: true,
|
jmapCount: jmapCount,
|
||||||
onTapLink: (text, href, title) {
|
androidInfo: androidSnapshot.data,
|
||||||
if (href != null) {
|
),
|
||||||
unawaited(
|
selectable: true,
|
||||||
_launchUrl(context, Uri.parse(href)),
|
onTapLink: (text, href, title) {
|
||||||
);
|
if (href != null) {
|
||||||
}
|
unawaited(
|
||||||
|
launchUrl(
|
||||||
|
Uri.parse(href),
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
|
|||||||
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||||
_Step _step = _Step.generatingKey;
|
_Step _step = _Step.generatingKey;
|
||||||
ShareKeyMaterial? _keyMaterial;
|
ShareKeyMaterial? _keyMaterial;
|
||||||
DateTime? _keyExpiresAt;
|
|
||||||
String? _pubKeyQr;
|
String? _pubKeyQr;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
bool _scannerActive = false;
|
bool _scannerActive = false;
|
||||||
@@ -65,7 +64,6 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_keyMaterial = material;
|
_keyMaterial = material;
|
||||||
_keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
|
|
||||||
_pubKeyQr = qr;
|
_pubKeyQr = qr;
|
||||||
_step = _Step.showingPubKey;
|
_step = _Step.showingPubKey;
|
||||||
});
|
});
|
||||||
@@ -87,24 +85,22 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-flight: probe the scanner's permission-state method to verify the
|
// Pre-flight: start + stop the scanner to verify the plugin is available.
|
||||||
// plugin is registered. MissingPluginException is thrown on Android builds
|
// Falls back to text entry on any exception (including MissingPluginException).
|
||||||
// where the plugin is not linked (issue #204). All other exceptions mean
|
|
||||||
// the plugin exists but something else failed — the MobileScanner widget
|
|
||||||
// will surface those via its own error builder.
|
|
||||||
Future<void> _initScanner() async {
|
Future<void> _initScanner() async {
|
||||||
|
MobileScannerController? ctrl;
|
||||||
bool available = false;
|
bool available = false;
|
||||||
try {
|
try {
|
||||||
await const MethodChannel(
|
ctrl = MobileScannerController();
|
||||||
'dev.steenbakker.mobile_scanner/scanner/method',
|
await ctrl.start();
|
||||||
).invokeMethod<int>('state');
|
await ctrl.stop();
|
||||||
available = true;
|
available = true;
|
||||||
} on MissingPluginException {
|
|
||||||
// Plugin not registered on this device; text fallback will be shown.
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Plugin registered but state check failed; let the scanner widget
|
// Plugin not available on this device; text fallback will be shown.
|
||||||
// handle it via its errorBuilder.
|
} finally {
|
||||||
available = true;
|
try {
|
||||||
|
await ctrl?.dispose();
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (available) {
|
if (available) {
|
||||||
@@ -278,7 +274,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_ExpiryHint(expiresAt: _keyExpiresAt!),
|
const _ExpiryHint(),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
if (_errorMessage != null) ...[
|
if (_errorMessage != null) ...[
|
||||||
Text(
|
Text(
|
||||||
@@ -408,37 +404,8 @@ bool _cameraScanSupported() =>
|
|||||||
Platform.isMacOS ||
|
Platform.isMacOS ||
|
||||||
Platform.isWindows;
|
Platform.isWindows;
|
||||||
|
|
||||||
class _ExpiryHint extends StatefulWidget {
|
class _ExpiryHint extends StatelessWidget {
|
||||||
const _ExpiryHint({required this.expiresAt});
|
const _ExpiryHint();
|
||||||
|
|
||||||
final DateTime expiresAt;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_ExpiryHint> createState() => _ExpiryHintState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ExpiryHintState extends State<_ExpiryHint> {
|
|
||||||
late Timer _timer;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_timer.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatRemaining() {
|
|
||||||
final remaining = widget.expiresAt.difference(DateTime.now().toUtc());
|
|
||||||
if (remaining.isNegative) return 'expired';
|
|
||||||
final minutes = remaining.inMinutes;
|
|
||||||
final seconds = remaining.inSeconds % 60;
|
|
||||||
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -448,7 +415,7 @@ class _ExpiryHintState extends State<_ExpiryHint> {
|
|||||||
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
|
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'This key expires in ${_formatRemaining()}',
|
'This key expires in 20 minutes',
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -57,24 +57,22 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-flight: probe the scanner's permission-state method to verify the
|
// Pre-flight: start + stop the scanner to verify the plugin is available.
|
||||||
// plugin is registered. MissingPluginException is thrown on Android builds
|
// Falls back to text entry on any exception (including MissingPluginException).
|
||||||
// where the plugin is not linked (issue #204). All other exceptions mean
|
|
||||||
// the plugin exists but something else failed — the MobileScanner widget
|
|
||||||
// will surface those via its own error builder.
|
|
||||||
Future<void> _initScanner() async {
|
Future<void> _initScanner() async {
|
||||||
|
MobileScannerController? ctrl;
|
||||||
bool available = false;
|
bool available = false;
|
||||||
try {
|
try {
|
||||||
await const MethodChannel(
|
ctrl = MobileScannerController();
|
||||||
'dev.steenbakker.mobile_scanner/scanner/method',
|
await ctrl.start();
|
||||||
).invokeMethod<int>('state');
|
await ctrl.stop();
|
||||||
available = true;
|
available = true;
|
||||||
} on MissingPluginException {
|
|
||||||
// Plugin not registered on this device; text fallback will be shown.
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Plugin registered but state check failed; let the scanner widget
|
// Plugin not available on this device; text fallback will be shown.
|
||||||
// handle it via its errorBuilder.
|
} finally {
|
||||||
available = true;
|
try {
|
||||||
|
await ctrl?.dispose();
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (available) {
|
if (available) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
@@ -12,8 +13,7 @@ class ChangeLogScreen extends StatelessWidget {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('ChangeLog')),
|
appBar: AppBar(title: const Text('ChangeLog')),
|
||||||
body: FutureBuilder<String>(
|
body: FutureBuilder<String>(
|
||||||
future:
|
future: rootBundle.loadString('assets/changelog.txt'),
|
||||||
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
|
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
@@ -18,23 +17,12 @@ class CrashScreen extends StatelessWidget {
|
|||||||
final StackTrace? stackTrace;
|
final StackTrace? stackTrace;
|
||||||
final String gitHash;
|
final String gitHash;
|
||||||
|
|
||||||
String get _buildMode {
|
Future<String> _buildReport() async {
|
||||||
if (kDebugMode) return 'debug';
|
String version = 'unknown';
|
||||||
if (kProfileMode) return 'profile';
|
|
||||||
return 'release';
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> _fetchVersion() async {
|
|
||||||
try {
|
try {
|
||||||
final info = await PackageInfo.fromPlatform();
|
final info = await PackageInfo.fromPlatform();
|
||||||
return '${info.version}+${info.buildNumber}';
|
version = '${info.version}+${info.buildNumber}';
|
||||||
} catch (_) {
|
} catch (_) {}
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> _buildReport() async {
|
|
||||||
final version = await _fetchVersion();
|
|
||||||
final platform =
|
final platform =
|
||||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||||
final versionDisplay = gitHash.isNotEmpty
|
final versionDisplay = gitHash.isNotEmpty
|
||||||
@@ -43,13 +31,9 @@ class CrashScreen extends StatelessWidget {
|
|||||||
final gitLine = gitHash.isNotEmpty
|
final gitLine = gitHash.isNotEmpty
|
||||||
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
|
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
|
||||||
: '';
|
: '';
|
||||||
final timestamp = DateTime.now().toUtc().toIso8601String();
|
|
||||||
return 'App Version: $versionDisplay\n'
|
return 'App Version: $versionDisplay\n'
|
||||||
'Build Mode: $_buildMode\n'
|
|
||||||
'$gitLine'
|
'$gitLine'
|
||||||
'Platform: $platform\n'
|
'Platform: $platform\n\n'
|
||||||
'Dart: ${Platform.version}\n'
|
|
||||||
'Timestamp: $timestamp\n\n'
|
|
||||||
'Error:\n```\n$exception\n```\n\n'
|
'Error:\n```\n$exception\n```\n\n'
|
||||||
'Stack Trace:\n```\n$stackTrace\n```';
|
'Stack Trace:\n```\n$stackTrace\n```';
|
||||||
}
|
}
|
||||||
@@ -75,18 +59,6 @@ class CrashScreen extends StatelessWidget {
|
|||||||
style: Theme.of(ctx).textTheme.titleMedium,
|
style: Theme.of(ctx).textTheme.titleMedium,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
|
||||||
FutureBuilder<String>(
|
|
||||||
future: _fetchVersion(),
|
|
||||||
builder: (context, snapshot) => Text(
|
|
||||||
'v${snapshot.data ?? '…'} • $_buildMode • '
|
|
||||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: Colors.grey[600],
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (gitHash.isNotEmpty) ...[
|
if (gitHash.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
FutureBuilder<PackageInfo>(
|
FutureBuilder<PackageInfo>(
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
_smtpHostCtrl.addListener(_rebuild);
|
_smtpHostCtrl.addListener(_rebuild);
|
||||||
_sieveHostCtrl.addListener(_rebuild);
|
_sieveHostCtrl.addListener(_rebuild);
|
||||||
_imapHostCtrl.addListener(_rebuild);
|
_imapHostCtrl.addListener(_rebuild);
|
||||||
_passwordCtrl.addListener(_rebuild);
|
|
||||||
unawaited(_load());
|
unawaited(_load());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +90,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
_smtpHostCtrl.removeListener(_rebuild);
|
_smtpHostCtrl.removeListener(_rebuild);
|
||||||
_sieveHostCtrl.removeListener(_rebuild);
|
_sieveHostCtrl.removeListener(_rebuild);
|
||||||
_imapHostCtrl.removeListener(_rebuild);
|
_imapHostCtrl.removeListener(_rebuild);
|
||||||
_passwordCtrl.removeListener(_rebuild);
|
|
||||||
for (final c in [
|
for (final c in [
|
||||||
_displayNameCtrl,
|
_displayNameCtrl,
|
||||||
_usernameCtrl,
|
_usernameCtrl,
|
||||||
@@ -355,17 +353,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
testing: _tryTesting,
|
testing: _tryTesting,
|
||||||
okMessage: _tryOk,
|
okMessage: _tryOk,
|
||||||
errorMessage: _tryErr,
|
errorMessage: _tryErr,
|
||||||
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
|
onPressed: _tryConnection,
|
||||||
? _tryConnection
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
FilledButton(
|
FilledButton(onPressed: _save, child: const Text('Save')),
|
||||||
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
|
|
||||||
? _save
|
|
||||||
: null,
|
|
||||||
child: const Text('Save'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
|
||||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
|
||||||
|
|
||||||
enum _MissingFolderChoice { chooseExisting, createNew }
|
|
||||||
|
|
||||||
/// Resolves a mailbox by role, prompting the user to choose or create one when
|
|
||||||
/// the role is not found. Returns the target [Mailbox], or null if cancelled.
|
|
||||||
Future<Mailbox?> resolveMailboxByRole(
|
|
||||||
BuildContext context,
|
|
||||||
MailboxRepository mailboxRepo,
|
|
||||||
String accountId,
|
|
||||||
String currentMailboxPath,
|
|
||||||
String role, {
|
|
||||||
required String dialogTitle,
|
|
||||||
required String createFolderName,
|
|
||||||
}) async {
|
|
||||||
Mailbox? mailbox = await mailboxRepo.findMailboxByRole(accountId, role);
|
|
||||||
if (!context.mounted) return null;
|
|
||||||
if (mailbox != null) return mailbox;
|
|
||||||
|
|
||||||
final choice = await showDialog<_MissingFolderChoice>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: Text(dialogTitle),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () =>
|
|
||||||
Navigator.pop(ctx, _MissingFolderChoice.chooseExisting),
|
|
||||||
child: const Text('Choose existing folder'),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx, _MissingFolderChoice.createNew),
|
|
||||||
child: Text('Create "$createFolderName"'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (!context.mounted || choice == null) return null;
|
|
||||||
|
|
||||||
switch (choice) {
|
|
||||||
case _MissingFolderChoice.chooseExisting:
|
|
||||||
final mailboxes = await mailboxRepo.observeMailboxes(accountId).first;
|
|
||||||
if (!context.mounted) return null;
|
|
||||||
final chosen = await showModalBottomSheet<String>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => ListView(
|
|
||||||
shrinkWrap: true,
|
|
||||||
children: [
|
|
||||||
const ListTile(
|
|
||||||
title: Text(
|
|
||||||
'Move to…',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
for (final m
|
|
||||||
in mailboxes.where((m) => m.path != currentMailboxPath))
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.folder_outlined),
|
|
||||||
title: Text(m.name),
|
|
||||||
onTap: () => Navigator.pop(ctx, m.path),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (chosen == null || !context.mounted) return null;
|
|
||||||
mailbox = mailboxes.firstWhere((m) => m.path == chosen);
|
|
||||||
case _MissingFolderChoice.createNew:
|
|
||||||
mailbox = await mailboxRepo.createMailboxWithRole(
|
|
||||||
accountId,
|
|
||||||
createFolderName,
|
|
||||||
role,
|
|
||||||
);
|
|
||||||
if (!context.mounted) return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return mailbox;
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ import 'package:sharedinbox/core/models/undo_action.dart';
|
|||||||
import 'package:sharedinbox/core/utils/format_utils.dart';
|
import 'package:sharedinbox/core/utils/format_utils.dart';
|
||||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
|
||||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -71,9 +70,16 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
onPressed: header == null
|
onPressed: header == null
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
unawaited(
|
unawaited(_reply(context, header, body, replyAll: false));
|
||||||
_replyWithRecipientDialog(context, header, body),
|
},
|
||||||
);
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.reply_all),
|
||||||
|
tooltip: 'Reply all',
|
||||||
|
onPressed: header == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
unawaited(_reply(context, header, body, replyAll: true));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -86,13 +92,34 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.archive),
|
icon: const Icon(Icons.mark_email_unread_outlined),
|
||||||
tooltip: 'Archive',
|
tooltip: 'Mark as unread',
|
||||||
onPressed: header == null
|
onPressed: () async {
|
||||||
? null
|
await repo.setFlag(widget.emailId, seen: false);
|
||||||
: () {
|
if (context.mounted) context.pop();
|
||||||
unawaited(_archive(context, header));
|
},
|
||||||
},
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_isFlagged ? Icons.star : Icons.star_border,
|
||||||
|
color: _isFlagged ? Colors.amber : null,
|
||||||
|
),
|
||||||
|
tooltip: _isFlagged ? 'Unflag' : 'Flag',
|
||||||
|
onPressed: () async {
|
||||||
|
final next = !_isFlagged;
|
||||||
|
await repo.setFlag(widget.emailId, flagged: next);
|
||||||
|
if (mounted) setState(() => _isFlagged = next);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.drive_file_move_outline),
|
||||||
|
tooltip: 'Move to folder',
|
||||||
|
onPressed: header == null ? null : () => _moveTo(context, header),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.access_time),
|
||||||
|
tooltip: 'Snooze',
|
||||||
|
onPressed: header == null ? null : () => _snooze(context, header),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete),
|
icon: const Icon(Icons.delete),
|
||||||
@@ -119,43 +146,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
if (context.mounted) context.pop();
|
if (context.mounted) context.pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.report_outlined),
|
|
||||||
tooltip: 'Mark as spam',
|
|
||||||
onPressed: header == null
|
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
unawaited(_markAsSpam(context, header));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.drive_file_move_outline),
|
|
||||||
tooltip: 'Move to folder',
|
|
||||||
onPressed: header == null ? null : () => _moveTo(context, header),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.access_time),
|
|
||||||
tooltip: 'Snooze',
|
|
||||||
onPressed: header == null ? null : () => _snooze(context, header),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
_isFlagged ? Icons.star : Icons.star_border,
|
|
||||||
color: _isFlagged ? Colors.amber : null,
|
|
||||||
),
|
|
||||||
tooltip: _isFlagged ? 'Unflag' : 'Flag',
|
|
||||||
onPressed: () async {
|
|
||||||
final next = !_isFlagged;
|
|
||||||
await repo.setFlag(widget.emailId, flagged: next);
|
|
||||||
if (mounted) setState(() => _isFlagged = next);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
itemBuilder: (ctx) => [
|
itemBuilder: (ctx) => [
|
||||||
const PopupMenuItem(
|
|
||||||
value: 'mark_unread',
|
|
||||||
child: Text('Mark as unread'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: 'headers',
|
value: 'headers',
|
||||||
child: Text('Show Mail Headers'),
|
child: Text('Show Mail Headers'),
|
||||||
@@ -169,11 +161,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
child: Text('Show Raw Email'),
|
child: Text('Show Raw Email'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onSelected: (value) async {
|
onSelected: (value) {
|
||||||
if (value == 'mark_unread') {
|
if (value == 'headers' && body != null) {
|
||||||
await repo.setFlag(widget.emailId, seen: false);
|
|
||||||
if (context.mounted) context.pop();
|
|
||||||
} else if (value == 'headers' && body != null) {
|
|
||||||
_showHeaders(context, body);
|
_showHeaders(context, body);
|
||||||
} else if (value == 'structure' && body != null) {
|
} else if (value == 'structure' && body != null) {
|
||||||
_showStructure(context, body);
|
_showStructure(context, body);
|
||||||
@@ -314,78 +303,17 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
return '\n\n— On $date, $from wrote:\n$quoted';
|
return '\n\n— On $date, $from wrote:\n$quoted';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _replyWithRecipientDialog(
|
Future<void> _reply(
|
||||||
BuildContext context,
|
|
||||||
Email header,
|
|
||||||
EmailBody? body,
|
|
||||||
) async {
|
|
||||||
final account =
|
|
||||||
await ref.read(accountRepositoryProvider).getAccount(header.accountId);
|
|
||||||
final ownEmail = account?.email.toLowerCase() ?? '';
|
|
||||||
|
|
||||||
final seen = <String>{};
|
|
||||||
final candidates = <_Candidate>[];
|
|
||||||
|
|
||||||
void addIfNew(EmailAddress addr, _Placement defaultPlacement) {
|
|
||||||
final key = addr.email.toLowerCase();
|
|
||||||
if (key == ownEmail || seen.contains(key)) return;
|
|
||||||
seen.add(key);
|
|
||||||
candidates.add(_Candidate(addr, defaultPlacement));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final addr in header.from) {
|
|
||||||
addIfNew(addr, _Placement.to);
|
|
||||||
}
|
|
||||||
for (final addr in header.to) {
|
|
||||||
addIfNew(addr, _Placement.to);
|
|
||||||
}
|
|
||||||
for (final addr in header.cc) {
|
|
||||||
addIfNew(addr, _Placement.cc);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
if (candidates.length <= 1) {
|
|
||||||
final to = candidates
|
|
||||||
.where((c) => c.placement == _Placement.to)
|
|
||||||
.map((c) => c.address.email)
|
|
||||||
.join(', ');
|
|
||||||
final cc = candidates
|
|
||||||
.where((c) => c.placement == _Placement.cc)
|
|
||||||
.map((c) => c.address.email)
|
|
||||||
.join(', ');
|
|
||||||
await _composeReply(context, header, body, to: to, cc: cc);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final confirmed = await showDialog<List<_Candidate>>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => _ReplyAllDialog(candidates: candidates),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed == null || !context.mounted) return;
|
|
||||||
|
|
||||||
final to = confirmed
|
|
||||||
.where((c) => c.placement == _Placement.to)
|
|
||||||
.map((c) => c.address.email)
|
|
||||||
.join(', ');
|
|
||||||
final cc = confirmed
|
|
||||||
.where((c) => c.placement == _Placement.cc)
|
|
||||||
.map((c) => c.address.email)
|
|
||||||
.join(', ');
|
|
||||||
await _composeReply(context, header, body, to: to, cc: cc);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _composeReply(
|
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Email header,
|
Email header,
|
||||||
EmailBody? body, {
|
EmailBody? body, {
|
||||||
required String to,
|
required bool replyAll,
|
||||||
required String cc,
|
|
||||||
}) async {
|
}) async {
|
||||||
|
final to = header.from.isNotEmpty ? header.from.first.email : '';
|
||||||
final subject = (header.subject?.startsWith('Re:') ?? false)
|
final subject = (header.subject?.startsWith('Re:') ?? false)
|
||||||
? header.subject!
|
? header.subject!
|
||||||
: 'Re: ${header.subject ?? ''}';
|
: 'Re: ${header.subject ?? ''}';
|
||||||
|
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
|
||||||
final quoted = await _quotedBody(header, body);
|
final quoted = await _quotedBody(header, body);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
unawaited(
|
unawaited(
|
||||||
@@ -402,72 +330,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _archive(BuildContext context, Email header) async {
|
|
||||||
final mailbox = await resolveMailboxByRole(
|
|
||||||
context,
|
|
||||||
ref.read(mailboxRepositoryProvider),
|
|
||||||
header.accountId,
|
|
||||||
header.mailboxPath,
|
|
||||||
'archive',
|
|
||||||
dialogTitle: 'No archive folder found',
|
|
||||||
createFolderName: 'Archive',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mailbox == null || !context.mounted) return;
|
|
||||||
|
|
||||||
await ref
|
|
||||||
.read(emailRepositoryProvider)
|
|
||||||
.moveEmail(widget.emailId, mailbox.path);
|
|
||||||
|
|
||||||
unawaited(
|
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
|
||||||
UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: header.accountId,
|
|
||||||
type: UndoType.move,
|
|
||||||
emailIds: [widget.emailId],
|
|
||||||
sourceMailboxPath: header.mailboxPath,
|
|
||||||
destinationMailboxPath: mailbox.path,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (context.mounted) context.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _markAsSpam(BuildContext context, Email header) async {
|
|
||||||
final mailbox = await resolveMailboxByRole(
|
|
||||||
context,
|
|
||||||
ref.read(mailboxRepositoryProvider),
|
|
||||||
header.accountId,
|
|
||||||
header.mailboxPath,
|
|
||||||
'junk',
|
|
||||||
dialogTitle: 'No spam folder found',
|
|
||||||
createFolderName: 'Junk',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mailbox == null || !context.mounted) return;
|
|
||||||
|
|
||||||
await ref
|
|
||||||
.read(emailRepositoryProvider)
|
|
||||||
.moveEmail(widget.emailId, mailbox.path);
|
|
||||||
|
|
||||||
unawaited(
|
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
|
||||||
UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: header.accountId,
|
|
||||||
type: UndoType.move,
|
|
||||||
emailIds: [widget.emailId],
|
|
||||||
sourceMailboxPath: header.mailboxPath,
|
|
||||||
destinationMailboxPath: mailbox.path,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (context.mounted) context.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _forward(
|
Future<void> _forward(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Email header,
|
Email header,
|
||||||
@@ -808,94 +670,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _Placement { to, cc, skip }
|
|
||||||
|
|
||||||
class _Candidate {
|
|
||||||
_Candidate(this.address, this.placement);
|
|
||||||
final EmailAddress address;
|
|
||||||
_Placement placement;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ReplyAllDialog extends StatefulWidget {
|
|
||||||
const _ReplyAllDialog({required this.candidates});
|
|
||||||
final List<_Candidate> candidates;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_ReplyAllDialog> createState() => _ReplyAllDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ReplyAllDialogState extends State<_ReplyAllDialog> {
|
|
||||||
late final List<_Candidate> _candidates;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_candidates = [
|
|
||||||
for (final c in widget.candidates) _Candidate(c.address, c.placement),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AlertDialog(
|
|
||||||
title: const Text('Reply All'),
|
|
||||||
content: SizedBox(
|
|
||||||
width: double.maxFinite,
|
|
||||||
child: ListView(
|
|
||||||
shrinkWrap: true,
|
|
||||||
children: [
|
|
||||||
for (final c in _candidates)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
c.address.toString(),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
SegmentedButton<_Placement>(
|
|
||||||
showSelectedIcon: false,
|
|
||||||
segments: const [
|
|
||||||
ButtonSegment(
|
|
||||||
value: _Placement.to,
|
|
||||||
label: Text('To'),
|
|
||||||
),
|
|
||||||
ButtonSegment(
|
|
||||||
value: _Placement.cc,
|
|
||||||
label: Text('Cc'),
|
|
||||||
),
|
|
||||||
ButtonSegment(
|
|
||||||
value: _Placement.skip,
|
|
||||||
label: Text('Skip'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
selected: {c.placement},
|
|
||||||
onSelectionChanged: (s) =>
|
|
||||||
setState(() => c.placement = s.first),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, _candidates),
|
|
||||||
child: const Text('Reply'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MimeRow {
|
class _MimeRow {
|
||||||
const _MimeRow(this.depth, this.label);
|
const _MimeRow(this.depth, this.label);
|
||||||
final int depth;
|
final int depth;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import 'package:sharedinbox/core/models/email.dart';
|
|||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
|
||||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||||
@@ -421,26 +420,24 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _batchMoveToRole(
|
Future<void> _batchMoveToRole(String role, String notFoundMessage) async {
|
||||||
String role, {
|
|
||||||
required String dialogTitle,
|
|
||||||
required String createFolderName,
|
|
||||||
}) async {
|
|
||||||
final ids = _selectedEmailIds;
|
final ids = _selectedEmailIds;
|
||||||
_clearSelection();
|
_clearSelection();
|
||||||
|
final mailbox = await ref
|
||||||
final mailbox = await resolveMailboxByRole(
|
.read(mailboxRepositoryProvider)
|
||||||
context,
|
.findMailboxByRole(widget.accountId, role);
|
||||||
ref.read(mailboxRepositoryProvider),
|
if (!mounted) return;
|
||||||
widget.accountId,
|
if (mailbox == null) {
|
||||||
widget.mailboxPath,
|
ScaffoldMessenger.of(
|
||||||
role,
|
context,
|
||||||
dialogTitle: dialogTitle,
|
).showSnackBar(
|
||||||
createFolderName: createFolderName,
|
SnackBar(
|
||||||
);
|
duration: const Duration(seconds: 5),
|
||||||
|
content: Text(notFoundMessage),
|
||||||
if (!mounted || mailbox == null) return;
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
|
||||||
// Fetch full email data before moving so we can restore them if user clicks Undo.
|
// Fetch full email data before moving so we can restore them if user clicks Undo.
|
||||||
@@ -466,11 +463,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _batchArchive() => _batchMoveToRole(
|
Future<void> _batchArchive() =>
|
||||||
'archive',
|
_batchMoveToRole('archive', 'No archive folder found');
|
||||||
dialogTitle: 'No archive folder found',
|
|
||||||
createFolderName: 'Archive',
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> _refreshSearchAndPopIfEmpty() async {
|
Future<void> _refreshSearchAndPopIfEmpty() async {
|
||||||
if (!mounted || !_searching) return;
|
if (!mounted || !_searching) return;
|
||||||
@@ -549,11 +543,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _batchMarkSpam() => _batchMoveToRole(
|
Future<void> _batchMarkSpam() =>
|
||||||
'junk',
|
_batchMoveToRole('junk', 'No spam folder found');
|
||||||
dialogTitle: 'No spam folder found',
|
|
||||||
createFolderName: 'Junk',
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> _batchMove() async {
|
Future<void> _batchMove() async {
|
||||||
final ids = _selectedEmailIds;
|
final ids = _selectedEmailIds;
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
|
|||||||
pkg = await PackageInfo.fromPlatform();
|
pkg = await PackageInfo.fromPlatform();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
final deviceModel = await getDeviceModel();
|
final androidInfo = await getAndroidDeviceInfo();
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
|
|||||||
pkg: pkg,
|
pkg: pkg,
|
||||||
imapCount: imapCount,
|
imapCount: imapCount,
|
||||||
jmapCount: jmapCount,
|
jmapCount: jmapCount,
|
||||||
deviceModel: deviceModel,
|
androidInfo: androidInfo,
|
||||||
);
|
);
|
||||||
await Clipboard.setData(ClipboardData(text: '$syncMd\n$aboutMd'));
|
await Clipboard.setData(ClipboardData(text: '$syncMd\n$aboutMd'));
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ import 'dart:io';
|
|||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:sharedinbox/core/db_schema_version.dart';
|
|
||||||
|
|
||||||
const _gitHash = String.fromEnvironment('GIT_HASH');
|
const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||||
|
|
||||||
/// Builds the About markdown table used in [AboutScreen] and sync log copies.
|
/// Builds the About markdown table used in [AboutScreen] and sync log copies.
|
||||||
|
///
|
||||||
|
/// Pass [androidInfo] when running on Android; omit on other platforms.
|
||||||
String buildAboutMarkdown({
|
String buildAboutMarkdown({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
PackageInfo? pkg,
|
PackageInfo? pkg,
|
||||||
required int imapCount,
|
required int imapCount,
|
||||||
required int jmapCount,
|
required int jmapCount,
|
||||||
String? deviceModel,
|
AndroidDeviceInfo? androidInfo,
|
||||||
}) {
|
}) {
|
||||||
final size = MediaQuery.of(context).size;
|
final size = MediaQuery.of(context).size;
|
||||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||||
@@ -25,15 +26,15 @@ String buildAboutMarkdown({
|
|||||||
: version;
|
: version;
|
||||||
final osName = _capitalize(Platform.operatingSystem);
|
final osName = _capitalize(Platform.operatingSystem);
|
||||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||||
final locale = Localizations.localeOf(context).toString();
|
|
||||||
final textScale =
|
|
||||||
MediaQuery.of(context).textScaler.scale(1.0).toStringAsFixed(1);
|
|
||||||
|
|
||||||
final gitCommitLine = _gitHash.isNotEmpty
|
final gitCommitLine = _gitHash.isNotEmpty
|
||||||
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
||||||
: '';
|
: '';
|
||||||
final deviceModelLine =
|
final androidLines = androidInfo != null
|
||||||
deviceModel != null ? '| Device Model | $deviceModel |\n' : '';
|
? '| Android Manufacturer | ${androidInfo.manufacturer} |\n'
|
||||||
|
'| Android Model | ${androidInfo.model} |\n'
|
||||||
|
'| Android Version | ${androidInfo.version.release} |\n'
|
||||||
|
: '';
|
||||||
|
|
||||||
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
||||||
'| Property | Value |\n'
|
'| Property | Value |\n'
|
||||||
@@ -42,33 +43,25 @@ String buildAboutMarkdown({
|
|||||||
'$gitCommitLine'
|
'$gitCommitLine'
|
||||||
'| Platform | ${Platform.operatingSystem} |\n'
|
'| Platform | ${Platform.operatingSystem} |\n'
|
||||||
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
||||||
'$deviceModelLine'
|
'$androidLines'
|
||||||
'| Resolution | ${physW}x$physH px'
|
'| Resolution | ${physW}x$physH px'
|
||||||
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
||||||
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
||||||
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
||||||
'| Processors | ${Platform.numberOfProcessors} |\n'
|
'| Processors | ${Platform.numberOfProcessors} |\n'
|
||||||
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
||||||
'| Locale | $locale |\n'
|
|
||||||
'| Text Scale | $textScale× |\n'
|
|
||||||
'| DB Schema Version | $dbSchemaVersion |\n'
|
|
||||||
'| IMAP Accounts | $imapCount |\n'
|
'| IMAP Accounts | $imapCount |\n'
|
||||||
'| JMAP Accounts | $jmapCount |\n';
|
'| JMAP Accounts | $jmapCount |\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches device model string, or null when unavailable.
|
/// Fetches Android device info, or null on non-Android platforms.
|
||||||
Future<String?> getDeviceModel() async {
|
Future<AndroidDeviceInfo?> getAndroidDeviceInfo() async {
|
||||||
|
if (!Platform.isAndroid) return null;
|
||||||
try {
|
try {
|
||||||
final info = DeviceInfoPlugin();
|
return await DeviceInfoPlugin().androidInfo;
|
||||||
if (Platform.isAndroid) {
|
} catch (_) {
|
||||||
final android = await info.androidInfo;
|
return null;
|
||||||
return '${android.manufacturer} / ${android.model}';
|
}
|
||||||
} else if (Platform.isIOS) {
|
|
||||||
final ios = await info.iosInfo;
|
|
||||||
return ios.utsname.machine;
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _capitalize(String s) =>
|
String _capitalize(String s) =>
|
||||||
|
|||||||
@@ -31,13 +31,10 @@ String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
|
|||||||
<meta name="color-scheme" content="light">
|
<meta name="color-scheme" content="light">
|
||||||
<meta http-equiv="Content-Security-Policy" content="$csp">
|
<meta http-equiv="Content-Security-Policy" content="$csp">
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; overflow-x: hidden; color-scheme: light; background-color: #ffffff; color: #000000; }
|
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; color-scheme: light; background-color: #ffffff; color: #000000; }
|
||||||
img { max-width: 100%; height: auto; }
|
img { max-width: 100%; height: auto; }
|
||||||
a { color: #1976D2; }
|
a { color: #1976D2; }
|
||||||
* { box-sizing: border-box; max-width: 100%; }
|
* { box-sizing: border-box; }
|
||||||
table { width: 100%; border-collapse: collapse; }
|
|
||||||
td, th { overflow-wrap: break-word; word-break: break-word; }
|
|
||||||
pre { white-space: pre-wrap; word-break: break-word; overflow-x: auto; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+3
-3
@@ -1133,13 +1133,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.2"
|
version: "6.3.2"
|
||||||
url_launcher_android:
|
url_launcher_android:
|
||||||
dependency: "direct overridden"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
|
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.24"
|
version: "6.3.30"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+3
-5
@@ -61,7 +61,9 @@ dependencies:
|
|||||||
# App version metadata for crash reports
|
# App version metadata for crash reports
|
||||||
package_info_plus: ^10.1.0
|
package_info_plus: ^10.1.0
|
||||||
share_plus: ^13.1.0
|
share_plus: ^13.1.0
|
||||||
device_info_plus: ^13.1.0
|
|
||||||
|
# Device hardware info for bug reports
|
||||||
|
device_info_plus: ^13.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -90,7 +92,3 @@ dependency_overrides:
|
|||||||
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
|
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
|
||||||
# stable Pigeon and is known to work reliably.
|
# stable Pigeon and is known to work reliably.
|
||||||
path_provider_android: ">=2.2.0 <2.2.21"
|
path_provider_android: ">=2.2.0 <2.2.21"
|
||||||
# url_launcher_android 6.3.25 updated to Pigeon 26, which causes a
|
|
||||||
# channel-error on launchUrl on some Android devices (same root cause as
|
|
||||||
# path_provider_android). Pin to <6.3.25 which uses stable Pigeon.
|
|
||||||
url_launcher_android: ">=6.3.0 <6.3.25"
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
||||||
"extends": [
|
|
||||||
"config:recommended"
|
|
||||||
],
|
|
||||||
"labels": ["dependencies"],
|
|
||||||
"github-actions": {
|
|
||||||
"fileMatch": ["^\\.forgejo/workflows/[^/]+\\.ya?ml$"]
|
|
||||||
},
|
|
||||||
"packageRules": [
|
|
||||||
{
|
|
||||||
"matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"],
|
|
||||||
"addLabels": ["automerge"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
+53
-206
@@ -8,25 +8,21 @@ Flow
|
|||||||
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
|
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
|
||||||
b. Age ≤ 1 h → print status, exit 0 (let it keep working)
|
b. Age ≤ 1 h → print status, exit 0 (let it keep working)
|
||||||
2. No agent running → extract pending_issue from state (if any), then check CI
|
2. No agent running → extract pending_issue from state (if any), then check CI
|
||||||
a. pending_issue type=="plan" → post resume comment, set State/Planned, exit 0
|
a. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
|
||||||
b. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
|
b. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
|
||||||
c. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
|
c. Main CI running → save pending-ci state, exit 0
|
||||||
d. Main CI running → save pending-ci state, exit 0
|
d. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
|
||||||
e. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
|
e. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
|
||||||
f. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
|
section 2a always returns first)
|
||||||
section 2b always returns first)
|
f. Main CI ok (or no run yet) → find oldest Ready issue, start issue agent,
|
||||||
g. Main CI ok (or no run yet) → find oldest ToPlan issue, start plan agent,
|
|
||||||
save state, exit 0
|
save state, exit 0
|
||||||
h. No ToPlan issues → find oldest Ready issue, start issue agent,
|
g. No Ready issues → print "nothing to do", exit 0
|
||||||
save state, exit 0
|
|
||||||
i. No Ready issues → print "nothing to do", exit 0
|
|
||||||
|
|
||||||
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
|
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
|
||||||
Plan agents must NOT write any code or create PRs; they only post a plan comment.
|
|
||||||
|
|
||||||
State file: ~/.sharedinbox-agent-state.json
|
State file: ~/.sharedinbox-agent-state.json
|
||||||
{ "pid": 12345, "issue": 91,
|
{ "pid": 12345, "issue": 91,
|
||||||
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue|plan|ci-fix|pending-ci" }
|
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue" }
|
||||||
|
|
||||||
Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
|
Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
|
||||||
To resume the Claude conversation, look up the session UUID first:
|
To resume the Claude conversation, look up the session UUID first:
|
||||||
@@ -42,11 +38,10 @@ import re
|
|||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Cron runs with a minimal PATH; ensure Nix profile binaries (claude) and ~/go/bin (fgj) are found.
|
# Cron runs with a minimal PATH; ensure Nix profile binaries (tea, claude) and ~/go/bin (fgj) are found.
|
||||||
os.environ["PATH"] = (
|
os.environ["PATH"] = (
|
||||||
f"{Path.home()}/.nix-profile/bin"
|
f"{Path.home()}/.nix-profile/bin"
|
||||||
f":{Path.home()}/go/bin"
|
f":{Path.home()}/go/bin"
|
||||||
@@ -70,8 +65,6 @@ LABEL_READY = "State/Ready"
|
|||||||
LABEL_IN_PROGRESS = "State/InProgress"
|
LABEL_IN_PROGRESS = "State/InProgress"
|
||||||
LABEL_QUESTION = "State/Question"
|
LABEL_QUESTION = "State/Question"
|
||||||
LABEL_PRIO_HIGH = "Prio/High"
|
LABEL_PRIO_HIGH = "Prio/High"
|
||||||
LABEL_TO_PLAN = "State/ToPlan"
|
|
||||||
LABEL_PLANNED = "State/Planned"
|
|
||||||
|
|
||||||
# Only pick up issues filed by these accounts.
|
# Only pick up issues filed by these accounts.
|
||||||
ALLOWED_ISSUE_AUTHORS = {"guettli", "guettlibot", "guettlibot2"}
|
ALLOWED_ISSUE_AUTHORS = {"guettli", "guettlibot", "guettlibot2"}
|
||||||
@@ -97,27 +90,22 @@ def _fgj(*args: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _fgj_run_list(limit: int = 20) -> list[dict]:
|
def _tea_get(path: str) -> dict | list | None:
|
||||||
"""Return workflow runs via fgj actions run list."""
|
"""Run a tea api GET and return parsed JSON. Only use for reads — tea PATCH/PUT
|
||||||
result = subprocess.run(
|
silently fails (exits 0) when unauthenticated, so writes must go via fgj."""
|
||||||
["fgj", "--hostname", "codeberg.org", "actions", "run", "list",
|
cmd = ["tea", "api", path]
|
||||||
"--repo", REPO, "--json", "-L", str(limit)],
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
capture_output=True, text=True,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"fgj actions run list failed:\n{result.stderr or result.stdout}"
|
f"tea api {path} failed:\n{result.stderr or result.stdout}"
|
||||||
)
|
)
|
||||||
out = result.stdout.strip()
|
out = result.stdout.strip()
|
||||||
if not out:
|
if not out:
|
||||||
return []
|
return None
|
||||||
try:
|
data = json.loads(out)
|
||||||
data = json.loads(out)
|
if isinstance(data, dict) and "message" in data and "url" in data:
|
||||||
except json.JSONDecodeError as exc:
|
raise RuntimeError(f"tea api {path} returned error: {data['message']}")
|
||||||
raise RuntimeError(
|
return data
|
||||||
f"fgj actions run list returned non-JSON:\n{out[:500]}"
|
|
||||||
) from exc
|
|
||||||
return data if isinstance(data, list) else []
|
|
||||||
|
|
||||||
|
|
||||||
def _set_labels(issue: int, add: list[str], remove: list[str]) -> None:
|
def _set_labels(issue: int, add: list[str], remove: list[str]) -> None:
|
||||||
@@ -159,37 +147,17 @@ def _ready_issues() -> list[dict]:
|
|||||||
return ready
|
return ready
|
||||||
|
|
||||||
|
|
||||||
def _to_plan_issues() -> list[dict]:
|
|
||||||
"""Return open issues with State/ToPlan, Prio/High first, then oldest."""
|
|
||||||
result = subprocess.run(
|
|
||||||
["fgj", "--hostname", "codeberg.org", "issue", "list",
|
|
||||||
"--repo", REPO, "--state", "open", "--json"],
|
|
||||||
capture_output=True, text=True, check=True,
|
|
||||||
)
|
|
||||||
data = json.loads(result.stdout) if result.stdout.strip() else []
|
|
||||||
to_plan = [
|
|
||||||
i for i in data
|
|
||||||
if any(lbl["name"] == LABEL_TO_PLAN for lbl in i.get("labels", []))
|
|
||||||
and i.get("user", {}).get("login", "") in ALLOWED_ISSUE_AUTHORS
|
|
||||||
]
|
|
||||||
to_plan.sort(key=lambda i: (
|
|
||||||
0 if any(lbl["name"] == LABEL_PRIO_HIGH for lbl in i.get("labels", [])) else 1,
|
|
||||||
i["number"],
|
|
||||||
))
|
|
||||||
return to_plan
|
|
||||||
|
|
||||||
|
|
||||||
def _latest_main_ci_run() -> dict | None:
|
def _latest_main_ci_run() -> dict | None:
|
||||||
"""Return the latest ci.yml run on the main branch.
|
"""Return the latest CI run on the main branch (excludes PR and schedule runs).
|
||||||
|
|
||||||
Forgejo reports scheduled/dispatch workflows (e.g. deploy.yml) with
|
Using the global latest run (limit=1) is wrong: a passing or failing run
|
||||||
event=push and prettyref=main, so filtering by event alone is not enough.
|
on a PR branch could mask the true state of main. We filter to push
|
||||||
We also require workflow_id == "ci.yml".
|
events on the 'main' prettyref so section-3 logic only reacts to main.
|
||||||
"""
|
"""
|
||||||
for run in _fgj_run_list(limit=20):
|
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
|
||||||
if (run.get("event") == "push"
|
runs = (data or {}).get("workflow_runs", [])
|
||||||
and run.get("prettyref") == "main"
|
for run in runs:
|
||||||
and run.get("workflow_id") == "ci.yml"):
|
if run.get("event") == "push" and run.get("prettyref") == "main":
|
||||||
return run
|
return run
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -197,16 +165,20 @@ def _latest_main_ci_run() -> dict | None:
|
|||||||
def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
||||||
"""Return the latest CI run for a specific branch, or None.
|
"""Return the latest CI run for a specific branch, or None.
|
||||||
|
|
||||||
For push events fgj reports the branch in ``prettyref``; for pull_request
|
Forgejo's workflow_runs API has no top-level head_branch field.
|
||||||
events ``prettyref`` is ``#N``, so we resolve the PR number first.
|
For push events the branch is in ``prettyref``; for pull_request
|
||||||
|
events it lives inside ``event_payload["pull_request"]["head"]["ref"]``.
|
||||||
"""
|
"""
|
||||||
runs = _fgj_run_list(limit=20)
|
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
|
||||||
pr_data = _find_pr_for_branch(branch)
|
runs = (data or {}).get("workflow_runs", [])
|
||||||
pr_ref = f"#{pr_data['number']}" if pr_data else None
|
|
||||||
for run in runs:
|
for run in runs:
|
||||||
if run.get("event") == "pull_request":
|
if run.get("event") == "pull_request":
|
||||||
if pr_ref and run.get("prettyref") == pr_ref:
|
try:
|
||||||
return run
|
payload = json.loads(run.get("event_payload", "{}"))
|
||||||
|
if payload.get("pull_request", {}).get("head", {}).get("ref") == branch:
|
||||||
|
return run
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
pass
|
||||||
elif run.get("event") == "push":
|
elif run.get("event") == "push":
|
||||||
if run.get("prettyref") == branch:
|
if run.get("prettyref") == branch:
|
||||||
return run
|
return run
|
||||||
@@ -253,79 +225,23 @@ def _open_issue_prs() -> list[dict]:
|
|||||||
|
|
||||||
def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
|
def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
|
||||||
"""Return the latest CI run triggered by a pull_request event for the given PR number."""
|
"""Return the latest CI run triggered by a pull_request event for the given PR number."""
|
||||||
pr_ref = f"#{pr_number}"
|
data = _tea_get(f"repos/{REPO}/actions/runs?event=pull_request&limit=50")
|
||||||
for run in _fgj_run_list(limit=50):
|
runs = (data or {}).get("workflow_runs", [])
|
||||||
if run.get("event") == "pull_request" and run.get("prettyref") == pr_ref:
|
for run in runs:
|
||||||
return run
|
try:
|
||||||
|
payload = json.loads(run.get("event_payload", "{}"))
|
||||||
|
if payload.get("pull_request", {}).get("number") == pr_number:
|
||||||
|
return run
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _get_issue_labels(issue: int) -> list[str]:
|
|
||||||
"""Return label names for an issue."""
|
|
||||||
result = subprocess.run(
|
|
||||||
["fgj", "--hostname", "codeberg.org", "issue", "view", str(issue),
|
|
||||||
"--repo", REPO, "--json"],
|
|
||||||
capture_output=True, text=True,
|
|
||||||
)
|
|
||||||
if result.returncode != 0 or not result.stdout.strip():
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
data = json.loads(result.stdout)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return []
|
|
||||||
return [lbl["name"] for lbl in data.get("issue", {}).get("labels", [])]
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_pr(pr_number: int) -> None:
|
def _merge_pr(pr_number: int) -> None:
|
||||||
"""Squash-merge a PR via fgj."""
|
"""Squash-merge a PR via fgj."""
|
||||||
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
|
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
|
||||||
|
|
||||||
|
|
||||||
def _handle_pr_still_open_after_merge(pr_number: int, branch: str, issue_num: int | None) -> str:
|
|
||||||
"""Handle a PR that is still open after a successful _merge_pr() call.
|
|
||||||
|
|
||||||
Returns one of:
|
|
||||||
"rebase-spawned" — merge conflict detected; rebase agent started, state written
|
|
||||||
"merged" — PR closed after a retry
|
|
||||||
"fallback" — all options exhausted; caller should set State/Question
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["fgj", "--hostname", "codeberg.org", "pr", "view", str(pr_number),
|
|
||||||
"--repo", REPO, "--json"],
|
|
||||||
capture_output=True, text=True,
|
|
||||||
)
|
|
||||||
pr_data: dict = {}
|
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
|
||||||
try:
|
|
||||||
pr_data = json.loads(result.stdout)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
mergeable = pr_data.get("mergeable")
|
|
||||||
|
|
||||||
if mergeable is False:
|
|
||||||
prompt = (
|
|
||||||
f"Rebase branch `{branch}` onto main to resolve merge conflicts, then push. "
|
|
||||||
"Do not change any logic — only resolve conflicts and push."
|
|
||||||
)
|
|
||||||
session_name = f"rebase-pr-{pr_number}"
|
|
||||||
pid = _start_agent(prompt, session_name)
|
|
||||||
_write_state(pid, issue_num, "pending-ci", session_name=session_name)
|
|
||||||
print(f"PR #{pr_number} has merge conflicts — spawned rebase agent (pid={pid}).")
|
|
||||||
return "rebase-spawned"
|
|
||||||
|
|
||||||
for attempt in range(1, 3):
|
|
||||||
time.sleep(5)
|
|
||||||
try:
|
|
||||||
_merge_pr(pr_number)
|
|
||||||
except RuntimeError as e:
|
|
||||||
print(f"PR #{pr_number} merge retry {attempt} failed: {e}")
|
|
||||||
if not _find_pr_for_branch(branch):
|
|
||||||
print(f"PR #{pr_number} merged on retry {attempt}.")
|
|
||||||
return "merged"
|
|
||||||
|
|
||||||
return "fallback"
|
|
||||||
|
|
||||||
|
|
||||||
# ── state file ────────────────────────────────────────────────────────────────
|
# ── state file ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -626,29 +542,13 @@ def _run_loop() -> int:
|
|||||||
|
|
||||||
# Agent not running (or no state) — extract any pending issue, then clean up.
|
# Agent not running (or no state) — extract any pending issue, then clean up.
|
||||||
pending_issue: int | None = None
|
pending_issue: int | None = None
|
||||||
pending_type: str | None = None
|
|
||||||
ci_run_id_at_start: int | None = None
|
ci_run_id_at_start: int | None = None
|
||||||
if state:
|
if state:
|
||||||
pending_issue = state.get("issue")
|
pending_issue = state.get("issue")
|
||||||
pending_type = state.get("type")
|
|
||||||
ci_run_id_at_start = state.get("ci_run_id_at_start")
|
ci_run_id_at_start = state.get("ci_run_id_at_start")
|
||||||
_clear_state()
|
_clear_state()
|
||||||
|
|
||||||
# ── 2a. Finished planning agent ───────────────────────────────────────────
|
# ── 2. Check for a PR opened by the agent ────────────────────────────────
|
||||||
if pending_issue and pending_type == "plan":
|
|
||||||
session_name = f"plan-issue-{pending_issue}"
|
|
||||||
uuid = _find_session_uuid(session_name)
|
|
||||||
if uuid:
|
|
||||||
resume_cmd = f"claude --resume {shlex.quote(uuid)}"
|
|
||||||
_comment_issue(
|
|
||||||
pending_issue,
|
|
||||||
f"Planning complete. To resume this session:\n\n```\n{resume_cmd}\n```",
|
|
||||||
)
|
|
||||||
_set_labels(pending_issue, add=[LABEL_PLANNED], remove=[LABEL_IN_PROGRESS])
|
|
||||||
print(f"Planning done for {_issue_url(pending_issue)} — set State/Planned.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# ── 2b. Check for a PR opened by the agent ───────────────────────────────
|
|
||||||
if pending_issue:
|
if pending_issue:
|
||||||
branch = f"issue-{pending_issue}-fix"
|
branch = f"issue-{pending_issue}-fix"
|
||||||
pr = _find_pr_for_branch(branch)
|
pr = _find_pr_for_branch(branch)
|
||||||
@@ -724,13 +624,6 @@ def _run_loop() -> int:
|
|||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
if _find_pr_for_branch(branch):
|
if _find_pr_for_branch(branch):
|
||||||
merge_result = _handle_pr_still_open_after_merge(pr_number, branch, pending_issue)
|
|
||||||
if merge_result == "rebase-spawned":
|
|
||||||
return 0
|
|
||||||
if merge_result == "merged":
|
|
||||||
_close_issue(pending_issue)
|
|
||||||
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
|
||||||
return 0
|
|
||||||
print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.")
|
print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.")
|
||||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||||
_comment_issue(
|
_comment_issue(
|
||||||
@@ -787,9 +680,6 @@ def _run_loop() -> int:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if pr_run and pr_run.get("status") == "success":
|
if pr_run and pr_run.get("status") == "success":
|
||||||
if issue_num and LABEL_QUESTION in _get_issue_labels(issue_num):
|
|
||||||
print(f"Catch-up: PR #{pr_number} — issue #{issue_num} is State/Question, skipping.")
|
|
||||||
continue
|
|
||||||
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
|
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
|
||||||
try:
|
try:
|
||||||
_merge_pr(pr_number)
|
_merge_pr(pr_number)
|
||||||
@@ -799,16 +689,6 @@ def _run_loop() -> int:
|
|||||||
# Verify the merge actually happened; fgj can exit 0 without merging
|
# Verify the merge actually happened; fgj can exit 0 without merging
|
||||||
# (e.g. branch-protection rules not satisfied).
|
# (e.g. branch-protection rules not satisfied).
|
||||||
if _find_pr_for_branch(branch):
|
if _find_pr_for_branch(branch):
|
||||||
merge_result = _handle_pr_still_open_after_merge(pr_number, branch, issue_num)
|
|
||||||
if merge_result == "rebase-spawned":
|
|
||||||
return 0
|
|
||||||
if merge_result == "merged":
|
|
||||||
if issue_num:
|
|
||||||
_close_issue(issue_num)
|
|
||||||
print(f"Catch-up: merged PR #{pr_number} and closed issue #{issue_num} after retry.")
|
|
||||||
else:
|
|
||||||
print(f"Catch-up: merged PR #{pr_number} after retry.")
|
|
||||||
return 0
|
|
||||||
print(
|
print(
|
||||||
f"Catch-up: PR #{pr_number} is still open after merge attempt "
|
f"Catch-up: PR #{pr_number} is still open after merge attempt "
|
||||||
"— skipping to avoid infinite retry."
|
"— skipping to avoid infinite retry."
|
||||||
@@ -843,8 +723,9 @@ def _run_loop() -> int:
|
|||||||
# spawning another agent, check whether any CI run is currently in
|
# spawning another agent, check whether any CI run is currently in
|
||||||
# progress (the branch run) and wait if so.
|
# progress (the branch run) and wait if so.
|
||||||
if ci_run_id_at_start is not None and run["id"] == ci_run_id_at_start:
|
if ci_run_id_at_start is not None and run["id"] == ci_run_id_at_start:
|
||||||
|
check = _tea_get(f"repos/{REPO}/actions/runs?limit=5")
|
||||||
in_flight = [
|
in_flight = [
|
||||||
r for r in _fgj_run_list(limit=5)
|
r for r in (check or {}).get("workflow_runs", [])
|
||||||
if r.get("status") == "running"
|
if r.get("status") == "running"
|
||||||
]
|
]
|
||||||
if in_flight:
|
if in_flight:
|
||||||
@@ -895,44 +776,10 @@ def _run_loop() -> int:
|
|||||||
print(f"CI passed{ci_run_part} — closed {_issue_url(pending_issue)}.")
|
print(f"CI passed{ci_run_part} — closed {_issue_url(pending_issue)}.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Find a ToPlan issue — planning takes priority over implementation.
|
|
||||||
to_plan = _to_plan_issues()
|
|
||||||
if to_plan:
|
|
||||||
issue = to_plan[0]
|
|
||||||
issue_number = issue["number"]
|
|
||||||
issue_title = issue["title"]
|
|
||||||
issue_body = issue.get("body", "")
|
|
||||||
|
|
||||||
print(f"Starting planning agent for {_issue_url(issue_number)} {issue_title}")
|
|
||||||
_set_labels(issue_number, add=[LABEL_IN_PROGRESS], remove=[LABEL_TO_PLAN])
|
|
||||||
|
|
||||||
plan_prompt = f"""Analyze Codeberg issue #{issue_number} in the guettli/sharedinbox repository and write a detailed implementation plan.
|
|
||||||
|
|
||||||
Issue title: {issue_title}
|
|
||||||
|
|
||||||
Issue body:
|
|
||||||
{issue_body}
|
|
||||||
|
|
||||||
Instructions:
|
|
||||||
- Read and understand the issue thoroughly.
|
|
||||||
- Explore the relevant parts of the codebase to understand the current structure.
|
|
||||||
- Write a detailed implementation plan as a comment on the issue using:
|
|
||||||
fgj issue comment {issue_number} --repo {REPO} --body "..."
|
|
||||||
The plan should cover: which files to change, what approach to take, and any risks or open questions.
|
|
||||||
- Do NOT write any code, do NOT create any branches or PRs, do NOT modify any files.
|
|
||||||
- If the issue is unclear or you need more information, set the label to State/Question
|
|
||||||
and stop (do NOT close the issue).
|
|
||||||
- When you have posted the plan as an issue comment, stop.
|
|
||||||
"""
|
|
||||||
session_name = f"plan-issue-{issue_number}"
|
|
||||||
pid = _start_agent(plan_prompt, session_name)
|
|
||||||
_write_state(pid, issue_number, "plan", issue_title, session_name=session_name)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# Find a Ready issue.
|
# Find a Ready issue.
|
||||||
issues = _ready_issues()
|
issues = _ready_issues()
|
||||||
if not issues:
|
if not issues:
|
||||||
print("No issues with State/ToPlan or State/Ready. Nothing to do.")
|
print("No issues with State/Ready. Nothing to do.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
issue = issues[0]
|
issue = issues[0]
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const _minCoveragePercent = 80;
|
|||||||
|
|
||||||
// Pure-abstract interfaces: no executable code, Dart VM never instruments them.
|
// Pure-abstract interfaces: no executable code, Dart VM never instruments them.
|
||||||
const _noCode = {
|
const _noCode = {
|
||||||
'lib/core/db_schema_version.dart',
|
|
||||||
'lib/core/repositories/account_repository.dart',
|
'lib/core/repositories/account_repository.dart',
|
||||||
'lib/core/repositories/draft_repository.dart',
|
'lib/core/repositories/draft_repository.dart',
|
||||||
'lib/core/repositories/email_repository.dart',
|
'lib/core/repositories/email_repository.dart',
|
||||||
@@ -58,7 +57,6 @@ const _excluded = {
|
|||||||
'lib/ui/widgets/try_connection_button.dart',
|
'lib/ui/widgets/try_connection_button.dart',
|
||||||
'lib/ui/widgets/undo_shell.dart',
|
'lib/ui/widgets/undo_shell.dart',
|
||||||
'lib/ui/screens/about_screen.dart',
|
'lib/ui/screens/about_screen.dart',
|
||||||
'lib/ui/screens/email_action_helpers.dart',
|
|
||||||
'lib/ui/utils/about_markdown.dart',
|
'lib/ui/utils/about_markdown.dart',
|
||||||
'lib/ui/widgets/email_tile.dart',
|
'lib/ui/widgets/email_tile.dart',
|
||||||
'lib/core/sync/account_sync_manager.dart',
|
'lib/core/sync/account_sync_manager.dart',
|
||||||
|
|||||||
+11
-147
@@ -506,40 +506,28 @@ class TestOutputFormat(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestLatestMainCiRun(unittest.TestCase):
|
class TestLatestMainCiRun(unittest.TestCase):
|
||||||
"""_latest_main_ci_run() must return only ci.yml push-to-main runs."""
|
"""_latest_main_ci_run() must return only push-to-main runs, ignoring schedule/deploy workflows."""
|
||||||
|
|
||||||
def _ci_run(self, run_id, status="success"):
|
def test_skips_schedule_runs_returns_push_to_main(self):
|
||||||
return {"event": "push", "prettyref": "main", "workflow_id": "ci.yml",
|
runs = [
|
||||||
"status": status, "id": run_id}
|
{"event": "schedule", "prettyref": "main", "status": "success", "id": 1},
|
||||||
|
{"event": "push", "prettyref": "main", "status": "success", "id": 2},
|
||||||
def _deploy_run(self, run_id, status="success"):
|
]
|
||||||
return {"event": "push", "prettyref": "main", "workflow_id": "deploy.yml",
|
|
||||||
"status": status, "id": run_id}
|
|
||||||
|
|
||||||
def test_skips_deploy_run_returns_ci_run(self):
|
|
||||||
# Forgejo reports deploy.yml schedule runs as event=push/prettyref=main;
|
|
||||||
# must be excluded by workflow_id filter.
|
|
||||||
runs = [self._deploy_run(1), self._ci_run(2)]
|
|
||||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||||
result = agent_loop._latest_main_ci_run()
|
result = agent_loop._latest_main_ci_run()
|
||||||
self.assertIsNotNone(result)
|
self.assertIsNotNone(result)
|
||||||
self.assertEqual(result["id"], 2)
|
self.assertEqual(result["id"], 2)
|
||||||
|
|
||||||
def test_returns_none_when_only_deploy_runs_exist(self):
|
|
||||||
runs = [self._deploy_run(1)]
|
|
||||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
|
||||||
result = agent_loop._latest_main_ci_run()
|
|
||||||
self.assertIsNone(result)
|
|
||||||
|
|
||||||
def test_returns_none_when_only_schedule_runs_exist(self):
|
def test_returns_none_when_only_schedule_runs_exist(self):
|
||||||
runs = [{"event": "schedule", "prettyref": "main", "workflow_id": "deploy.yml",
|
runs = [
|
||||||
"status": "success", "id": 1}]
|
{"event": "schedule", "prettyref": "main", "status": "success", "id": 1},
|
||||||
|
]
|
||||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||||
result = agent_loop._latest_main_ci_run()
|
result = agent_loop._latest_main_ci_run()
|
||||||
self.assertIsNone(result)
|
self.assertIsNone(result)
|
||||||
|
|
||||||
def test_returns_ci_push_to_main_run(self):
|
def test_returns_push_to_main_run(self):
|
||||||
runs = [self._ci_run(42, status="running")]
|
runs = [{"event": "push", "prettyref": "main", "status": "running", "id": 42}]
|
||||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||||
result = agent_loop._latest_main_ci_run()
|
result = agent_loop._latest_main_ci_run()
|
||||||
self.assertIsNotNone(result)
|
self.assertIsNotNone(result)
|
||||||
@@ -745,130 +733,6 @@ class TestRunLoopResumeCommand(unittest.TestCase):
|
|||||||
self.assertNotIn("Resume:", output)
|
self.assertNotIn("Resume:", output)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TestCatchupSkipsQuestionIssues(unittest.TestCase):
|
|
||||||
"""Catch-up must not retry merging a PR whose issue is already State/Question."""
|
|
||||||
|
|
||||||
def _make_pr(self, pr_number=50, branch="issue-10-fix"):
|
|
||||||
return {"number": pr_number, "head": {"ref": branch}}
|
|
||||||
|
|
||||||
def test_skips_merge_when_issue_has_question_label(self):
|
|
||||||
pr = self._make_pr()
|
|
||||||
ci_run = {"id": 999, "status": "success"}
|
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
|
||||||
patch("agent_loop._open_issue_prs", return_value=[pr]), \
|
|
||||||
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
|
|
||||||
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
|
|
||||||
patch("agent_loop._merge_pr") as mock_merge, \
|
|
||||||
patch("agent_loop._comment_issue") as mock_comment, \
|
|
||||||
patch("agent_loop._set_labels") as mock_labels, \
|
|
||||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
|
||||||
patch("agent_loop._ready_issues", return_value=[]):
|
|
||||||
result = agent_loop._run_loop()
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
mock_merge.assert_not_called()
|
|
||||||
mock_comment.assert_not_called()
|
|
||||||
mock_labels.assert_not_called()
|
|
||||||
|
|
||||||
def test_proceeds_with_merge_when_issue_lacks_question_label(self):
|
|
||||||
pr = self._make_pr()
|
|
||||||
ci_run = {"id": 999, "status": "success"}
|
|
||||||
with patch("agent_loop._read_state", return_value=None), \
|
|
||||||
patch("agent_loop._open_issue_prs", return_value=[pr]), \
|
|
||||||
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
|
|
||||||
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_IN_PROGRESS]), \
|
|
||||||
patch("agent_loop._merge_pr") as mock_merge, \
|
|
||||||
patch("agent_loop._find_pr_for_branch", return_value=None), \
|
|
||||||
patch("agent_loop._close_issue"):
|
|
||||||
result = agent_loop._run_loop()
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
mock_merge.assert_called_once_with(50)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMergeFailsOpen(unittest.TestCase):
|
|
||||||
"""Tests for auto-resolution when a PR is still open after the merge command."""
|
|
||||||
|
|
||||||
def _dead_state(self, issue: int, kind: str = "issue") -> dict:
|
|
||||||
return {
|
|
||||||
"pid": 999999999,
|
|
||||||
"issue": issue,
|
|
||||||
"started_at": "2026-01-01T00:00:00+00:00",
|
|
||||||
"type": kind,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _open_pr(self, branch: str = "issue-10-fix") -> dict:
|
|
||||||
return {"number": 5, "head": {"ref": branch}, "created_at": "2026-01-01T00:00:00+00:00"}
|
|
||||||
|
|
||||||
def test_merge_fails_open_with_conflicts_spawns_rebase_agent(self):
|
|
||||||
"""mergeable=false → rebase agent spawned, state written as pending-ci."""
|
|
||||||
written_state = {}
|
|
||||||
|
|
||||||
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
|
|
||||||
written_state["pid"] = pid
|
|
||||||
written_state["issue"] = issue
|
|
||||||
written_state["kind"] = kind
|
|
||||||
written_state["session_name"] = session_name
|
|
||||||
|
|
||||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
|
||||||
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), self._open_pr()]), \
|
|
||||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
|
||||||
patch("agent_loop._merge_pr"), \
|
|
||||||
patch("agent_loop._tea_get", return_value={"mergeable": False}), \
|
|
||||||
patch("agent_loop._start_agent", return_value=77) as mock_start, \
|
|
||||||
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
|
||||||
patch("agent_loop._clear_state"):
|
|
||||||
result = agent_loop._run_loop()
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
mock_start.assert_called_once()
|
|
||||||
prompt = mock_start.call_args[0][0]
|
|
||||||
self.assertIn("Rebase branch", prompt)
|
|
||||||
self.assertIn("issue-10-fix", prompt)
|
|
||||||
self.assertEqual(written_state.get("kind"), "pending-ci")
|
|
||||||
self.assertEqual(written_state.get("issue"), 10)
|
|
||||||
|
|
||||||
def test_merge_fails_open_no_conflicts_retries_and_succeeds(self):
|
|
||||||
"""mergeable=true, second attempt succeeds → issue closed."""
|
|
||||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
|
||||||
patch("agent_loop._find_pr_for_branch",
|
|
||||||
side_effect=[self._open_pr(), self._open_pr(), None]), \
|
|
||||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
|
||||||
patch("agent_loop._merge_pr"), \
|
|
||||||
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
|
|
||||||
patch("agent_loop.time.sleep"), \
|
|
||||||
patch("agent_loop._close_issue") as mock_close, \
|
|
||||||
patch("agent_loop._clear_state"):
|
|
||||||
result = agent_loop._run_loop()
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
mock_close.assert_called_once_with(10)
|
|
||||||
|
|
||||||
def test_merge_fails_open_no_conflicts_all_retries_exhausted(self):
|
|
||||||
"""All retries exhausted with PR still open → falls through to State/Question."""
|
|
||||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
|
||||||
patch("agent_loop._find_pr_for_branch",
|
|
||||||
side_effect=[self._open_pr(), self._open_pr(),
|
|
||||||
self._open_pr(), self._open_pr()]), \
|
|
||||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
|
||||||
patch("agent_loop._merge_pr"), \
|
|
||||||
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
|
|
||||||
patch("agent_loop.time.sleep"), \
|
|
||||||
patch("agent_loop._set_labels") as mock_labels, \
|
|
||||||
patch("agent_loop._comment_issue") as mock_comment, \
|
|
||||||
patch("agent_loop._close_issue") as mock_close, \
|
|
||||||
patch("agent_loop._clear_state"):
|
|
||||||
result = agent_loop._run_loop()
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
mock_close.assert_not_called()
|
|
||||||
mock_labels.assert_called_once_with(
|
|
||||||
10,
|
|
||||||
add=[agent_loop.LABEL_QUESTION],
|
|
||||||
remove=[agent_loop.LABEL_IN_PROGRESS],
|
|
||||||
)
|
|
||||||
mock_comment.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestHeartbeat(unittest.TestCase):
|
class TestHeartbeat(unittest.TestCase):
|
||||||
"""Tests for _update_heartbeat() and cmd_monitor()."""
|
"""Tests for _update_heartbeat() and cmd_monitor()."""
|
||||||
|
|
||||||
|
|||||||
@@ -149,22 +149,6 @@ class _FakeMailboxes implements MailboxRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> clearForResync(String accountId) async {}
|
Future<void> clearForResync(String accountId) async {}
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Mailbox> createMailboxWithRole(
|
|
||||||
String accountId,
|
|
||||||
String name,
|
|
||||||
String role,
|
|
||||||
) async =>
|
|
||||||
Mailbox(
|
|
||||||
id: '$accountId:$name',
|
|
||||||
accountId: accountId,
|
|
||||||
path: name,
|
|
||||||
name: name,
|
|
||||||
role: role,
|
|
||||||
unreadCount: 0,
|
|
||||||
totalCount: 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeEmails implements EmailRepository {
|
class _FakeEmails implements EmailRepository {
|
||||||
|
|||||||
@@ -224,21 +224,6 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
|
|||||||
Future<Mailbox?> findMailboxByRole(String id, String role) async => null;
|
Future<Mailbox?> findMailboxByRole(String id, String role) async => null;
|
||||||
@override
|
@override
|
||||||
Future<void> clearForResync(String accountId) async {}
|
Future<void> clearForResync(String accountId) async {}
|
||||||
@override
|
|
||||||
Future<Mailbox> createMailboxWithRole(
|
|
||||||
String accountId,
|
|
||||||
String name,
|
|
||||||
String role,
|
|
||||||
) async =>
|
|
||||||
Mailbox(
|
|
||||||
id: '$accountId:$name',
|
|
||||||
accountId: accountId,
|
|
||||||
path: name,
|
|
||||||
name: name,
|
|
||||||
role: role,
|
|
||||||
unreadCount: 0,
|
|
||||||
totalCount: 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
|
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
// Do not manually edit this file.
|
// Do not manually edit this file.
|
||||||
|
|
||||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
import 'dart:async' as _i5;
|
import 'dart:async' as _i4;
|
||||||
|
|
||||||
import 'package:mockito/mockito.dart' as _i1;
|
import 'package:mockito/mockito.dart' as _i1;
|
||||||
import 'package:mockito/src/dummies.dart' as _i7;
|
import 'package:mockito/src/dummies.dart' as _i6;
|
||||||
import 'package:sharedinbox/core/models/account.dart' as _i6;
|
import 'package:sharedinbox/core/models/account.dart' as _i5;
|
||||||
import 'package:sharedinbox/core/models/email.dart' as _i3;
|
import 'package:sharedinbox/core/models/email.dart' as _i2;
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart' as _i2;
|
import 'package:sharedinbox/core/models/mailbox.dart' as _i8;
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart' as _i4;
|
import 'package:sharedinbox/core/repositories/account_repository.dart' as _i3;
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i9;
|
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i9;
|
||||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i8;
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i7;
|
||||||
|
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
// ignore_for_file: avoid_redundant_argument_values
|
// ignore_for_file: avoid_redundant_argument_values
|
||||||
@@ -29,8 +29,8 @@ import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i8;
|
|||||||
// ignore_for_file: subtype_of_sealed_class
|
// ignore_for_file: subtype_of_sealed_class
|
||||||
// ignore_for_file: invalid_use_of_internal_member
|
// ignore_for_file: invalid_use_of_internal_member
|
||||||
|
|
||||||
class _FakeMailbox_0 extends _i1.SmartFake implements _i2.Mailbox {
|
class _FakeEmailBody_0 extends _i1.SmartFake implements _i2.EmailBody {
|
||||||
_FakeMailbox_0(
|
_FakeEmailBody_0(
|
||||||
Object parent,
|
Object parent,
|
||||||
Invocation parentInvocation,
|
Invocation parentInvocation,
|
||||||
) : super(
|
) : super(
|
||||||
@@ -39,8 +39,9 @@ class _FakeMailbox_0 extends _i1.SmartFake implements _i2.Mailbox {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeEmailBody_1 extends _i1.SmartFake implements _i3.EmailBody {
|
class _FakeSyncEmailsResult_1 extends _i1.SmartFake
|
||||||
_FakeEmailBody_1(
|
implements _i2.SyncEmailsResult {
|
||||||
|
_FakeSyncEmailsResult_1(
|
||||||
Object parent,
|
Object parent,
|
||||||
Invocation parentInvocation,
|
Invocation parentInvocation,
|
||||||
) : super(
|
) : super(
|
||||||
@@ -49,20 +50,9 @@ class _FakeEmailBody_1 extends _i1.SmartFake implements _i3.EmailBody {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeSyncEmailsResult_2 extends _i1.SmartFake
|
class _FakeReliabilityResult_2 extends _i1.SmartFake
|
||||||
implements _i3.SyncEmailsResult {
|
implements _i2.ReliabilityResult {
|
||||||
_FakeSyncEmailsResult_2(
|
_FakeReliabilityResult_2(
|
||||||
Object parent,
|
|
||||||
Invocation parentInvocation,
|
|
||||||
) : super(
|
|
||||||
parent,
|
|
||||||
parentInvocation,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FakeReliabilityResult_3 extends _i1.SmartFake
|
|
||||||
implements _i3.ReliabilityResult {
|
|
||||||
_FakeReliabilityResult_3(
|
|
||||||
Object parent,
|
Object parent,
|
||||||
Invocation parentInvocation,
|
Invocation parentInvocation,
|
||||||
) : super(
|
) : super(
|
||||||
@@ -74,32 +64,32 @@ class _FakeReliabilityResult_3 extends _i1.SmartFake
|
|||||||
/// A class which mocks [AccountRepository].
|
/// A class which mocks [AccountRepository].
|
||||||
///
|
///
|
||||||
/// See the documentation for Mockito's code generation for more information.
|
/// See the documentation for Mockito's code generation for more information.
|
||||||
class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository {
|
class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository {
|
||||||
MockAccountRepository() {
|
MockAccountRepository() {
|
||||||
_i1.throwOnMissingStub(this);
|
_i1.throwOnMissingStub(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Stream<List<_i6.Account>> observeAccounts() => (super.noSuchMethod(
|
_i4.Stream<List<_i5.Account>> observeAccounts() => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#observeAccounts,
|
#observeAccounts,
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Stream<List<_i6.Account>>.empty(),
|
returnValue: _i4.Stream<List<_i5.Account>>.empty(),
|
||||||
) as _i5.Stream<List<_i6.Account>>);
|
) as _i4.Stream<List<_i5.Account>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<_i6.Account?> getAccount(String? id) => (super.noSuchMethod(
|
_i4.Future<_i5.Account?> getAccount(String? id) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#getAccount,
|
#getAccount,
|
||||||
[id],
|
[id],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<_i6.Account?>.value(),
|
returnValue: _i4.Future<_i5.Account?>.value(),
|
||||||
) as _i5.Future<_i6.Account?>);
|
) as _i4.Future<_i5.Account?>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> addAccount(
|
_i4.Future<void> addAccount(
|
||||||
_i6.Account? account,
|
_i5.Account? account,
|
||||||
String? password,
|
String? password,
|
||||||
) =>
|
) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
@@ -110,13 +100,13 @@ class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository {
|
|||||||
password,
|
password,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> updateAccount(
|
_i4.Future<void> updateAccount(
|
||||||
_i6.Account? account, {
|
_i5.Account? account, {
|
||||||
String? password,
|
String? password,
|
||||||
}) =>
|
}) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
@@ -125,65 +115,65 @@ class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository {
|
|||||||
[account],
|
[account],
|
||||||
{#password: password},
|
{#password: password},
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> removeAccount(String? id) => (super.noSuchMethod(
|
_i4.Future<void> removeAccount(String? id) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#removeAccount,
|
#removeAccount,
|
||||||
[id],
|
[id],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<String> getPassword(String? accountId) => (super.noSuchMethod(
|
_i4.Future<String> getPassword(String? accountId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#getPassword,
|
#getPassword,
|
||||||
[accountId],
|
[accountId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
|
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
|
||||||
this,
|
this,
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#getPassword,
|
#getPassword,
|
||||||
[accountId],
|
[accountId],
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
) as _i5.Future<String>);
|
) as _i4.Future<String>);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A class which mocks [MailboxRepository].
|
/// A class which mocks [MailboxRepository].
|
||||||
///
|
///
|
||||||
/// See the documentation for Mockito's code generation for more information.
|
/// See the documentation for Mockito's code generation for more information.
|
||||||
class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository {
|
class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository {
|
||||||
MockMailboxRepository() {
|
MockMailboxRepository() {
|
||||||
_i1.throwOnMissingStub(this);
|
_i1.throwOnMissingStub(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Stream<List<_i2.Mailbox>> observeMailboxes(String? accountId) =>
|
_i4.Stream<List<_i8.Mailbox>> observeMailboxes(String? accountId) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#observeMailboxes,
|
#observeMailboxes,
|
||||||
[accountId],
|
[accountId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Stream<List<_i2.Mailbox>>.empty(),
|
returnValue: _i4.Stream<List<_i8.Mailbox>>.empty(),
|
||||||
) as _i5.Stream<List<_i2.Mailbox>>);
|
) as _i4.Stream<List<_i8.Mailbox>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod(
|
_i4.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#syncMailboxes,
|
#syncMailboxes,
|
||||||
[accountId],
|
[accountId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<int>.value(0),
|
returnValue: _i4.Future<int>.value(0),
|
||||||
) as _i5.Future<int>);
|
) as _i4.Future<int>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<_i2.Mailbox?> findMailboxByRole(
|
_i4.Future<_i8.Mailbox?> findMailboxByRole(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? role,
|
String? role,
|
||||||
) =>
|
) =>
|
||||||
@@ -195,46 +185,18 @@ class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository {
|
|||||||
role,
|
role,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<_i2.Mailbox?>.value(),
|
returnValue: _i4.Future<_i8.Mailbox?>.value(),
|
||||||
) as _i5.Future<_i2.Mailbox?>);
|
) as _i4.Future<_i8.Mailbox?>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
|
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#clearForResync,
|
#clearForResync,
|
||||||
[accountId],
|
[accountId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
|
||||||
_i5.Future<_i2.Mailbox> createMailboxWithRole(
|
|
||||||
String? accountId,
|
|
||||||
String? name,
|
|
||||||
String? role,
|
|
||||||
) =>
|
|
||||||
(super.noSuchMethod(
|
|
||||||
Invocation.method(
|
|
||||||
#createMailboxWithRole,
|
|
||||||
[
|
|
||||||
accountId,
|
|
||||||
name,
|
|
||||||
role,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
returnValue: _i5.Future<_i2.Mailbox>.value(_FakeMailbox_0(
|
|
||||||
this,
|
|
||||||
Invocation.method(
|
|
||||||
#createMailboxWithRole,
|
|
||||||
[
|
|
||||||
accountId,
|
|
||||||
name,
|
|
||||||
role,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
) as _i5.Future<_i2.Mailbox>);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A class which mocks [EmailRepository].
|
/// A class which mocks [EmailRepository].
|
||||||
@@ -246,13 +208,13 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Stream<String> get onChangesQueued => (super.noSuchMethod(
|
_i4.Stream<String> get onChangesQueued => (super.noSuchMethod(
|
||||||
Invocation.getter(#onChangesQueued),
|
Invocation.getter(#onChangesQueued),
|
||||||
returnValue: _i5.Stream<String>.empty(),
|
returnValue: _i4.Stream<String>.empty(),
|
||||||
) as _i5.Stream<String>);
|
) as _i4.Stream<String>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Stream<List<_i3.Email>> observeEmails(
|
_i4.Stream<List<_i2.Email>> observeEmails(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? mailboxPath, {
|
String? mailboxPath, {
|
||||||
int? limit = 50,
|
int? limit = 50,
|
||||||
@@ -266,11 +228,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
],
|
],
|
||||||
{#limit: limit},
|
{#limit: limit},
|
||||||
),
|
),
|
||||||
returnValue: _i5.Stream<List<_i3.Email>>.empty(),
|
returnValue: _i4.Stream<List<_i2.Email>>.empty(),
|
||||||
) as _i5.Stream<List<_i3.Email>>);
|
) as _i4.Stream<List<_i2.Email>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Stream<List<_i3.EmailThread>> observeThreads(
|
_i4.Stream<List<_i2.EmailThread>> observeThreads(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? mailboxPath, {
|
String? mailboxPath, {
|
||||||
int? limit = 50,
|
int? limit = 50,
|
||||||
@@ -284,11 +246,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
],
|
],
|
||||||
{#limit: limit},
|
{#limit: limit},
|
||||||
),
|
),
|
||||||
returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
|
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
|
||||||
) as _i5.Stream<List<_i3.EmailThread>>);
|
) as _i4.Stream<List<_i2.EmailThread>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Stream<List<_i3.Email>> observeEmailsInThread(
|
_i4.Stream<List<_i2.Email>> observeEmailsInThread(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? mailboxPath,
|
String? mailboxPath,
|
||||||
String? threadId,
|
String? threadId,
|
||||||
@@ -302,36 +264,36 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
threadId,
|
threadId,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Stream<List<_i3.Email>>.empty(),
|
returnValue: _i4.Stream<List<_i2.Email>>.empty(),
|
||||||
) as _i5.Stream<List<_i3.Email>>);
|
) as _i4.Stream<List<_i2.Email>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<_i3.Email?> getEmail(String? emailId) => (super.noSuchMethod(
|
_i4.Future<_i2.Email?> getEmail(String? emailId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#getEmail,
|
#getEmail,
|
||||||
[emailId],
|
[emailId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<_i3.Email?>.value(),
|
returnValue: _i4.Future<_i2.Email?>.value(),
|
||||||
) as _i5.Future<_i3.Email?>);
|
) as _i4.Future<_i2.Email?>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<_i3.EmailBody> getEmailBody(String? emailId) =>
|
_i4.Future<_i2.EmailBody> getEmailBody(String? emailId) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#getEmailBody,
|
#getEmailBody,
|
||||||
[emailId],
|
[emailId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<_i3.EmailBody>.value(_FakeEmailBody_1(
|
returnValue: _i4.Future<_i2.EmailBody>.value(_FakeEmailBody_0(
|
||||||
this,
|
this,
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#getEmailBody,
|
#getEmailBody,
|
||||||
[emailId],
|
[emailId],
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
) as _i5.Future<_i3.EmailBody>);
|
) as _i4.Future<_i2.EmailBody>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<_i3.SyncEmailsResult> syncEmails(
|
_i4.Future<_i2.SyncEmailsResult> syncEmails(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? mailboxPath,
|
String? mailboxPath,
|
||||||
) =>
|
) =>
|
||||||
@@ -344,7 +306,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue:
|
returnValue:
|
||||||
_i5.Future<_i3.SyncEmailsResult>.value(_FakeSyncEmailsResult_2(
|
_i4.Future<_i2.SyncEmailsResult>.value(_FakeSyncEmailsResult_1(
|
||||||
this,
|
this,
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#syncEmails,
|
#syncEmails,
|
||||||
@@ -354,10 +316,10 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
) as _i5.Future<_i3.SyncEmailsResult>);
|
) as _i4.Future<_i2.SyncEmailsResult>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> setFlag(
|
_i4.Future<void> setFlag(
|
||||||
String? emailId, {
|
String? emailId, {
|
||||||
bool? seen,
|
bool? seen,
|
||||||
bool? flagged,
|
bool? flagged,
|
||||||
@@ -371,12 +333,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
#flagged: flagged,
|
#flagged: flagged,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> markAllAsRead(
|
_i4.Future<void> markAllAsRead(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? mailboxPath,
|
String? mailboxPath,
|
||||||
) =>
|
) =>
|
||||||
@@ -388,12 +350,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
mailboxPath,
|
mailboxPath,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> moveEmail(
|
_i4.Future<void> moveEmail(
|
||||||
String? emailId,
|
String? emailId,
|
||||||
String? destMailboxPath,
|
String? destMailboxPath,
|
||||||
) =>
|
) =>
|
||||||
@@ -405,23 +367,23 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
destMailboxPath,
|
destMailboxPath,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
|
_i4.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#deleteEmail,
|
#deleteEmail,
|
||||||
[emailId],
|
[emailId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<String?>.value(),
|
returnValue: _i4.Future<String?>.value(),
|
||||||
) as _i5.Future<String?>);
|
) as _i4.Future<String?>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> sendEmail(
|
_i4.Future<void> sendEmail(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
_i3.EmailDraft? draft,
|
_i2.EmailDraft? draft,
|
||||||
) =>
|
) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
@@ -431,14 +393,14 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
draft,
|
draft,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<String> downloadAttachment(
|
_i4.Future<String> downloadAttachment(
|
||||||
String? emailId,
|
String? emailId,
|
||||||
_i3.EmailAttachment? attachment,
|
_i2.EmailAttachment? attachment,
|
||||||
) =>
|
) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
@@ -448,7 +410,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
attachment,
|
attachment,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
|
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
|
||||||
this,
|
this,
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#downloadAttachment,
|
#downloadAttachment,
|
||||||
@@ -458,25 +420,25 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
) as _i5.Future<String>);
|
) as _i4.Future<String>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<String> fetchRawRfc822(String? emailId) => (super.noSuchMethod(
|
_i4.Future<String> fetchRawRfc822(String? emailId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#fetchRawRfc822,
|
#fetchRawRfc822,
|
||||||
[emailId],
|
[emailId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
|
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
|
||||||
this,
|
this,
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#fetchRawRfc822,
|
#fetchRawRfc822,
|
||||||
[emailId],
|
[emailId],
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
) as _i5.Future<String>);
|
) as _i4.Future<String>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<List<_i3.Email>> searchEmails(
|
_i4.Future<List<_i2.Email>> searchEmails(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? mailboxPath,
|
String? mailboxPath,
|
||||||
String? query,
|
String? query,
|
||||||
@@ -490,11 +452,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
query,
|
query,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
|
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
||||||
) as _i5.Future<List<_i3.Email>>);
|
) as _i4.Future<List<_i2.Email>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<List<_i3.Email>> searchEmailsGlobal(
|
_i4.Future<List<_i2.Email>> searchEmailsGlobal(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? query,
|
String? query,
|
||||||
) =>
|
) =>
|
||||||
@@ -506,11 +468,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
query,
|
query,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
|
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
||||||
) as _i5.Future<List<_i3.Email>>);
|
) as _i4.Future<List<_i2.Email>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<List<_i3.Email>> getEmailsByAddress(
|
_i4.Future<List<_i2.Email>> getEmailsByAddress(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? address,
|
String? address,
|
||||||
) =>
|
) =>
|
||||||
@@ -522,11 +484,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
address,
|
address,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
|
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
||||||
) as _i5.Future<List<_i3.Email>>);
|
) as _i4.Future<List<_i2.Email>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<List<_i3.EmailAddress>> searchAddresses(
|
_i4.Future<List<_i2.EmailAddress>> searchAddresses(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? query, {
|
String? query, {
|
||||||
int? limit = 10,
|
int? limit = 10,
|
||||||
@@ -541,11 +503,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
{#limit: limit},
|
{#limit: limit},
|
||||||
),
|
),
|
||||||
returnValue:
|
returnValue:
|
||||||
_i5.Future<List<_i3.EmailAddress>>.value(<_i3.EmailAddress>[]),
|
_i4.Future<List<_i2.EmailAddress>>.value(<_i2.EmailAddress>[]),
|
||||||
) as _i5.Future<List<_i3.EmailAddress>>);
|
) as _i4.Future<List<_i2.EmailAddress>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<int> flushPendingChanges(
|
_i4.Future<int> flushPendingChanges(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? password,
|
String? password,
|
||||||
) =>
|
) =>
|
||||||
@@ -557,42 +519,42 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
password,
|
password,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<int>.value(0),
|
returnValue: _i4.Future<int>.value(0),
|
||||||
) as _i5.Future<int>);
|
) as _i4.Future<int>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Stream<List<_i3.FailedMutation>> observeFailedMutations(
|
_i4.Stream<List<_i2.FailedMutation>> observeFailedMutations(
|
||||||
String? accountId) =>
|
String? accountId) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#observeFailedMutations,
|
#observeFailedMutations,
|
||||||
[accountId],
|
[accountId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Stream<List<_i3.FailedMutation>>.empty(),
|
returnValue: _i4.Stream<List<_i2.FailedMutation>>.empty(),
|
||||||
) as _i5.Stream<List<_i3.FailedMutation>>);
|
) as _i4.Stream<List<_i2.FailedMutation>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> discardMutation(int? id) => (super.noSuchMethod(
|
_i4.Future<void> discardMutation(int? id) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#discardMutation,
|
#discardMutation,
|
||||||
[id],
|
[id],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> retryMutation(int? id) => (super.noSuchMethod(
|
_i4.Future<void> retryMutation(int? id) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#retryMutation,
|
#retryMutation,
|
||||||
[id],
|
[id],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<bool> cancelPendingChange(
|
_i4.Future<bool> cancelPendingChange(
|
||||||
String? emailId,
|
String? emailId,
|
||||||
String? changeType,
|
String? changeType,
|
||||||
) =>
|
) =>
|
||||||
@@ -604,11 +566,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
changeType,
|
changeType,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<bool>.value(false),
|
returnValue: _i4.Future<bool>.value(false),
|
||||||
) as _i5.Future<bool>);
|
) as _i4.Future<bool>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> snoozeEmail(
|
_i4.Future<void> snoozeEmail(
|
||||||
String? emailId,
|
String? emailId,
|
||||||
DateTime? until,
|
DateTime? until,
|
||||||
) =>
|
) =>
|
||||||
@@ -620,32 +582,32 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
until,
|
until,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod(
|
_i4.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#wakeUpEmails,
|
#wakeUpEmails,
|
||||||
[accountId],
|
[accountId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<int>.value(0),
|
returnValue: _i4.Future<int>.value(0),
|
||||||
) as _i5.Future<int>);
|
) as _i4.Future<int>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> restoreEmails(List<_i3.Email>? emails) =>
|
_i4.Future<void> restoreEmails(List<_i2.Email>? emails) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#restoreEmails,
|
#restoreEmails,
|
||||||
[emails],
|
[emails],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<_i3.Email?> findEmailByMessageId(
|
_i4.Future<_i2.Email?> findEmailByMessageId(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? messageId,
|
String? messageId,
|
||||||
) =>
|
) =>
|
||||||
@@ -657,20 +619,20 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
messageId,
|
messageId,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<_i3.Email?>.value(),
|
returnValue: _i4.Future<_i2.Email?>.value(),
|
||||||
) as _i5.Future<_i3.Email?>);
|
) as _i4.Future<_i2.Email?>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<int> applySieveRules(String? accountId) => (super.noSuchMethod(
|
_i4.Future<int> applySieveRules(String? accountId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#applySieveRules,
|
#applySieveRules,
|
||||||
[accountId],
|
[accountId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<int>.value(0),
|
returnValue: _i4.Future<int>.value(0),
|
||||||
) as _i5.Future<int>);
|
) as _i4.Future<int>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Stream<void> watchJmapPush(
|
_i4.Stream<void> watchJmapPush(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? password,
|
String? password,
|
||||||
) =>
|
) =>
|
||||||
@@ -682,11 +644,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
password,
|
password,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Stream<void>.empty(),
|
returnValue: _i4.Stream<void>.empty(),
|
||||||
) as _i5.Stream<void>);
|
) as _i4.Stream<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<_i3.ReliabilityResult> verifySyncReliability(
|
_i4.Future<_i2.ReliabilityResult> verifySyncReliability(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
String? mailboxPath,
|
String? mailboxPath,
|
||||||
) =>
|
) =>
|
||||||
@@ -699,7 +661,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
returnValue:
|
returnValue:
|
||||||
_i5.Future<_i3.ReliabilityResult>.value(_FakeReliabilityResult_3(
|
_i4.Future<_i2.ReliabilityResult>.value(_FakeReliabilityResult_2(
|
||||||
this,
|
this,
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#verifySyncReliability,
|
#verifySyncReliability,
|
||||||
@@ -709,15 +671,15 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
) as _i5.Future<_i3.ReliabilityResult>);
|
) as _i4.Future<_i2.ReliabilityResult>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
|
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#clearForResync,
|
#clearForResync,
|
||||||
[accountId],
|
[accountId],
|
||||||
),
|
),
|
||||||
returnValue: _i5.Future<void>.value(),
|
returnValue: _i4.Future<void>.value(),
|
||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i4.Future<void>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
|||||||
|
|
||||||
import 'account_repository_impl_test.dart' show MapSecureStorage;
|
import 'account_repository_impl_test.dart' show MapSecureStorage;
|
||||||
import 'db_test_helper.dart';
|
import 'db_test_helper.dart';
|
||||||
import 'fake_imap.dart' show SnoozeSpyImapClient;
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const _account = Account(
|
const _account = Account(
|
||||||
@@ -433,177 +432,5 @@ void main() {
|
|||||||
expect(result, isNotNull);
|
expect(result, isNotNull);
|
||||||
expect(result!.role, 'inbox');
|
expect(result!.role, 'inbox');
|
||||||
});
|
});
|
||||||
|
|
||||||
group('createMailboxWithRole', () {
|
|
||||||
test('IMAP: creates mailbox on server and persists with role', () async {
|
|
||||||
final spy = SnoozeSpyImapClient();
|
|
||||||
final db = openTestDatabase();
|
|
||||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
|
||||||
final mailboxes = MailboxRepositoryImpl(
|
|
||||||
db,
|
|
||||||
accounts,
|
|
||||||
imapConnect: (_, __, ___) async => spy,
|
|
||||||
);
|
|
||||||
await accounts.addAccount(_account, 'pw');
|
|
||||||
|
|
||||||
final result = await mailboxes.createMailboxWithRole(
|
|
||||||
'acc-1',
|
|
||||||
'Archive',
|
|
||||||
'archive',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy.createdMailbox, 'Archive');
|
|
||||||
expect(result.name, 'Archive');
|
|
||||||
expect(result.role, 'archive');
|
|
||||||
expect(result.path, 'Archive');
|
|
||||||
|
|
||||||
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
|
|
||||||
expect(found, isNotNull);
|
|
||||||
expect(found!.name, 'Archive');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('JMAP: creates mailbox on server and persists with role', () async {
|
|
||||||
final r = _makeRepos(
|
|
||||||
httpClient: _mockJmap(
|
|
||||||
apiResponses: [
|
|
||||||
{
|
|
||||||
'sessionState': 'sess1',
|
|
||||||
'methodResponses': [
|
|
||||||
[
|
|
||||||
'Mailbox/set',
|
|
||||||
{
|
|
||||||
'accountId': 'acct1',
|
|
||||||
'created': {
|
|
||||||
'new-mailbox': {'id': 'mbx-archive'},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
|
||||||
|
|
||||||
final result = await r.mailboxes
|
|
||||||
.createMailboxWithRole('jmap-1', 'Archive', 'archive');
|
|
||||||
|
|
||||||
expect(result.name, 'Archive');
|
|
||||||
expect(result.role, 'archive');
|
|
||||||
expect(result.path, 'mbx-archive');
|
|
||||||
|
|
||||||
final found = await r.mailboxes.findMailboxByRole('jmap-1', 'archive');
|
|
||||||
expect(found, isNotNull);
|
|
||||||
expect(found!.name, 'Archive');
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
|
||||||
'JMAP: throws when server returns no created ID',
|
|
||||||
() async {
|
|
||||||
final r = _makeRepos(
|
|
||||||
httpClient: _mockJmap(
|
|
||||||
apiResponses: [
|
|
||||||
{
|
|
||||||
'sessionState': 'sess1',
|
|
||||||
'methodResponses': [
|
|
||||||
[
|
|
||||||
'Mailbox/set',
|
|
||||||
{
|
|
||||||
'accountId': 'acct1',
|
|
||||||
'created': null,
|
|
||||||
'notCreated': {
|
|
||||||
'new-mailbox': {'type': 'serverFail'},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
|
||||||
|
|
||||||
await expectLater(
|
|
||||||
r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'),
|
|
||||||
throwsA(isA<Exception>()),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
group('syncMailboxes IMAP preserves manually-set role', () {
|
|
||||||
test('existing role is kept when server returns no special-use flag',
|
|
||||||
() async {
|
|
||||||
final spy = SnoozeSpyImapClient();
|
|
||||||
// Make listMailboxes return a plain folder without \Archive.
|
|
||||||
final db = openTestDatabase();
|
|
||||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
|
||||||
|
|
||||||
// Override listMailboxes to return one plain folder.
|
|
||||||
final fakeClient = _PlainArchiveImapClient();
|
|
||||||
final mailboxes = MailboxRepositoryImpl(
|
|
||||||
db,
|
|
||||||
accounts,
|
|
||||||
imapConnect: (_, __, ___) async => fakeClient,
|
|
||||||
);
|
|
||||||
await accounts.addAccount(_account, 'pw');
|
|
||||||
|
|
||||||
// Pre-seed the DB with role='archive' (as if user created the folder).
|
|
||||||
await db.into(db.mailboxes).insert(
|
|
||||||
MailboxesCompanion.insert(
|
|
||||||
id: 'acc-1:Archive',
|
|
||||||
accountId: 'acc-1',
|
|
||||||
path: 'Archive',
|
|
||||||
name: 'Archive',
|
|
||||||
role: const Value('archive'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await mailboxes.syncMailboxes('acc-1');
|
|
||||||
|
|
||||||
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
|
|
||||||
expect(
|
|
||||||
found,
|
|
||||||
isNotNull,
|
|
||||||
reason: 'Manually-set role should be preserved after sync',
|
|
||||||
);
|
|
||||||
expect(found!.path, 'Archive');
|
|
||||||
// Suppress unused warning on spy.
|
|
||||||
expect(spy, isNotNull);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fake IMAP client that lists one mailbox named 'Archive' without any
|
|
||||||
/// special-use flags, and logs out cleanly.
|
|
||||||
class _PlainArchiveImapClient extends SnoozeSpyImapClient {
|
|
||||||
@override
|
|
||||||
Future<List<imap.Mailbox>> listMailboxes({
|
|
||||||
String path = '""',
|
|
||||||
bool recursive = false,
|
|
||||||
List<String>? mailboxPatterns,
|
|
||||||
List<String>? selectionOptions,
|
|
||||||
List<imap.ReturnOption>? returnOptions,
|
|
||||||
}) async =>
|
|
||||||
[
|
|
||||||
imap.Mailbox(
|
|
||||||
encodedName: 'Archive',
|
|
||||||
encodedPath: 'Archive',
|
|
||||||
pathSeparator: '/',
|
|
||||||
flags: [], // No \Archive special-use flag
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<imap.Mailbox> statusMailbox(
|
|
||||||
imap.Mailbox mailbox,
|
|
||||||
List<imap.StatusFlags> flags,
|
|
||||||
) async =>
|
|
||||||
mailbox;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<dynamic> logout() async {}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -62,21 +62,6 @@ class _FakeMailboxes implements MailboxRepository {
|
|||||||
null;
|
null;
|
||||||
@override
|
@override
|
||||||
Future<void> clearForResync(String accountId) async {}
|
Future<void> clearForResync(String accountId) async {}
|
||||||
@override
|
|
||||||
Future<Mailbox> createMailboxWithRole(
|
|
||||||
String accountId,
|
|
||||||
String name,
|
|
||||||
String role,
|
|
||||||
) async =>
|
|
||||||
Mailbox(
|
|
||||||
id: '$accountId:$name',
|
|
||||||
accountId: accountId,
|
|
||||||
path: name,
|
|
||||||
name: name,
|
|
||||||
role: role,
|
|
||||||
unreadCount: 0,
|
|
||||||
totalCount: 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeEmails implements EmailRepository {
|
class _FakeEmails implements EmailRepository {
|
||||||
|
|||||||
@@ -54,21 +54,6 @@ class _FakeMailboxes implements MailboxRepository {
|
|||||||
null;
|
null;
|
||||||
@override
|
@override
|
||||||
Future<void> clearForResync(String accountId) async {}
|
Future<void> clearForResync(String accountId) async {}
|
||||||
@override
|
|
||||||
Future<Mailbox> createMailboxWithRole(
|
|
||||||
String accountId,
|
|
||||||
String name,
|
|
||||||
String role,
|
|
||||||
) async =>
|
|
||||||
Mailbox(
|
|
||||||
id: '$accountId:$name',
|
|
||||||
accountId: accountId,
|
|
||||||
path: name,
|
|
||||||
name: name,
|
|
||||||
role: role,
|
|
||||||
unreadCount: 0,
|
|
||||||
totalCount: 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CountingEmails implements EmailRepository {
|
class _CountingEmails implements EmailRepository {
|
||||||
|
|||||||
@@ -27,22 +27,6 @@ class MockUrlLauncher extends Mock
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ThrowingUrlLauncher extends Mock
|
|
||||||
with MockPlatformInterfaceMixin
|
|
||||||
implements UrlLauncherPlatform {
|
|
||||||
@override
|
|
||||||
Future<bool> canLaunch(String? url) async => true;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<bool> launchUrl(String? url, LaunchOptions? options) async {
|
|
||||||
throw PlatformException(
|
|
||||||
code: 'channel-error',
|
|
||||||
message: 'Unable to establish connection on channel: '
|
|
||||||
'"dev.flutter.pigeon.url_launcher_android.UrlLauncherApi.launchUrl".',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildScreen({List<Account> accounts = const []}) {
|
Widget _buildScreen({List<Account> accounts = const []}) {
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
@@ -80,9 +64,6 @@ void main() {
|
|||||||
expect(find.textContaining('Dark Mode'), findsWidgets);
|
expect(find.textContaining('Dark Mode'), findsWidgets);
|
||||||
expect(find.textContaining('IMAP Accounts'), findsWidgets);
|
expect(find.textContaining('IMAP Accounts'), findsWidgets);
|
||||||
expect(find.textContaining('JMAP Accounts'), findsWidgets);
|
expect(find.textContaining('JMAP Accounts'), findsWidgets);
|
||||||
expect(find.textContaining('Locale'), findsWidgets);
|
|
||||||
expect(find.textContaining('Text Scale'), findsWidgets);
|
|
||||||
expect(find.textContaining('DB Schema Version'), findsWidgets);
|
|
||||||
// Buttons are in the body, not in the AppBar actions
|
// Buttons are in the body, not in the AppBar actions
|
||||||
expect(find.byIcon(Icons.copy), findsOneWidget);
|
expect(find.byIcon(Icons.copy), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.bug_report), findsOneWidget);
|
expect(find.byIcon(Icons.bug_report), findsOneWidget);
|
||||||
@@ -170,9 +151,6 @@ void main() {
|
|||||||
expect(clipboardText, contains('Dark Mode'));
|
expect(clipboardText, contains('Dark Mode'));
|
||||||
expect(clipboardText, contains('IMAP Accounts'));
|
expect(clipboardText, contains('IMAP Accounts'));
|
||||||
expect(clipboardText, contains('JMAP Accounts'));
|
expect(clipboardText, contains('JMAP Accounts'));
|
||||||
expect(clipboardText, contains('Locale'));
|
|
||||||
expect(clipboardText, contains('Text Scale'));
|
|
||||||
expect(clipboardText, contains('DB Schema Version'));
|
|
||||||
expect(
|
expect(
|
||||||
clipboardText,
|
clipboardText,
|
||||||
contains('[sharedinbox.de](https://sharedinbox.de)'),
|
contains('[sharedinbox.de](https://sharedinbox.de)'),
|
||||||
@@ -202,24 +180,4 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(mock.launchedUrl, contains('1.2.3%2B99'));
|
expect(mock.launchedUrl, contains('1.2.3%2B99'));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'AboutScreen link tap with failed url_launcher shows error snackbar',
|
|
||||||
(tester) async {
|
|
||||||
tester.view.physicalSize = const Size(800, 1200);
|
|
||||||
tester.view.devicePixelRatio = 1.0;
|
|
||||||
addTearDown(tester.view.resetPhysicalSize);
|
|
||||||
addTearDown(tester.view.resetDevicePixelRatio);
|
|
||||||
|
|
||||||
UrlLauncherPlatform.instance = ThrowingUrlLauncher();
|
|
||||||
|
|
||||||
await tester.pumpWidget(_buildScreen());
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.textContaining('sharedinbox.de').first);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.textContaining('Error:'), findsOneWidget);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ void main() {
|
|||||||
expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget);
|
expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows expiry countdown hint', (tester) async {
|
testWidgets('shows 20-minute expiry hint', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/receive',
|
initialLocation: '/accounts/receive',
|
||||||
@@ -32,106 +32,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.textContaining('expires in'), findsOneWidget);
|
expect(find.textContaining('20 minutes'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'step 2 button shows text-input fallback on platforms without camera',
|
|
||||||
(tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/receive',
|
|
||||||
overrides: baseOverrides(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// On Linux (desktop, no camera) the text fallback field must appear.
|
|
||||||
expect(find.byKey(const Key('encryptedCodeField')), findsOneWidget);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'step 2 — valid encrypted QR imports account via text fallback',
|
|
||||||
(tester) async {
|
|
||||||
// Pre-generate a key pair so we can encrypt a QR code with the same
|
|
||||||
// material the screen will use for decryption.
|
|
||||||
final material = await ShareEncryptionService.generateKeyPair();
|
|
||||||
final repo = FakeShareKeyRepository(material: material);
|
|
||||||
|
|
||||||
const account = Account(
|
|
||||||
id: 'src-1',
|
|
||||||
displayName: 'Alice',
|
|
||||||
email: 'alice@example.com',
|
|
||||||
imapHost: 'imap.example.com',
|
|
||||||
smtpHost: 'smtp.example.com',
|
|
||||||
);
|
|
||||||
|
|
||||||
final encryptedQr = await ShareEncryptionService.encryptAccounts(
|
|
||||||
recipientKeyId: material.keyId,
|
|
||||||
recipientPublicKeyBytes: material.publicKeyBytes,
|
|
||||||
accounts: [
|
|
||||||
AccountPayload(
|
|
||||||
accountJson: account.toJson(),
|
|
||||||
password: 'secret',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/receive',
|
|
||||||
overrides: baseOverrides(shareKeyRepository: repo),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle(); // key generation completes
|
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.enterText(
|
|
||||||
find.byKey(const Key('encryptedCodeField')),
|
|
||||||
encryptedQr,
|
|
||||||
);
|
|
||||||
await tester.tap(find.text('Import'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
find.text('Imported 1 account successfully.'),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'step 2 — invalid encrypted QR shows error and returns to pub-key step',
|
|
||||||
(tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/receive',
|
|
||||||
overrides: baseOverrides(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.enterText(
|
|
||||||
find.byKey(const Key('encryptedCodeField')),
|
|
||||||
'not-a-valid-qr-code',
|
|
||||||
);
|
|
||||||
await tester.tap(find.text('Import'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Screen returns to the pub-key step with an error message visible.
|
|
||||||
expect(find.byKey(const Key('pubKeyQrCode')), findsOneWidget);
|
|
||||||
expect(find.textContaining('Import failed:'), findsWidgets);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group('AccountSendScreen', () {
|
group('AccountSendScreen', () {
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
|
||||||
|
|
||||||
class _FakeAssetBundle extends CachingAssetBundle {
|
|
||||||
final Map<String, String> _assets;
|
|
||||||
_FakeAssetBundle(this._assets);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ByteData> load(String key) async {
|
|
||||||
if (_assets.containsKey(key)) {
|
|
||||||
final encoded = utf8.encode(_assets[key]!);
|
|
||||||
return ByteData.view(Uint8List.fromList(encoded).buffer);
|
|
||||||
}
|
|
||||||
throw FlutterError('Asset not found: "$key"');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const _fakeChangelog =
|
|
||||||
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
DefaultAssetBundle(
|
|
||||||
bundle: _FakeAssetBundle({'assets/changelog.txt': _fakeChangelog}),
|
|
||||||
child: const MaterialApp(home: ChangeLogScreen()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('ChangeLog'), findsOneWidget);
|
|
||||||
expect(find.textContaining('initial release'), findsOneWidget);
|
|
||||||
expect(find.textContaining('resolve crash'), findsOneWidget);
|
|
||||||
expect(find.textContaining('Error loading changelog'), findsNothing);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('ChangeLogScreen shows error when asset is missing', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
DefaultAssetBundle(
|
|
||||||
bundle: _FakeAssetBundle({}),
|
|
||||||
child: const MaterialApp(home: ChangeLogScreen()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.textContaining('Error loading changelog'), findsOneWidget);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -116,10 +116,7 @@ void main() {
|
|||||||
|
|
||||||
expect(clipboardText, isNotNull);
|
expect(clipboardText, isNotNull);
|
||||||
expect(clipboardText, contains('App Version: 1.0.0+42'));
|
expect(clipboardText, contains('App Version: 1.0.0+42'));
|
||||||
expect(clipboardText, contains('Build Mode:'));
|
|
||||||
expect(clipboardText, contains('Platform:'));
|
expect(clipboardText, contains('Platform:'));
|
||||||
expect(clipboardText, contains('Dart:'));
|
|
||||||
expect(clipboardText, contains('Timestamp:'));
|
|
||||||
expect(clipboardText, contains('TestException: clipboard test'));
|
expect(clipboardText, contains('TestException: clipboard test'));
|
||||||
// GIT_HASH is empty in test builds — no Git Commit line expected
|
// GIT_HASH is empty in test builds — no Git Commit line expected
|
||||||
expect(clipboardText, isNot(contains('Git Commit:')));
|
expect(clipboardText, isNot(contains('Git Commit:')));
|
||||||
@@ -171,35 +168,6 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'CrashScreen shows version, build mode, and platform in the UI',
|
|
||||||
(tester) async {
|
|
||||||
tester.view.physicalSize = const Size(800, 1200);
|
|
||||||
tester.view.devicePixelRatio = 1.0;
|
|
||||||
addTearDown(() => tester.view.resetPhysicalSize());
|
|
||||||
|
|
||||||
const exception = 'TestException: info row test';
|
|
||||||
final stackTrace = StackTrace.current;
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
MaterialApp(
|
|
||||||
home: CrashScreen(exception: exception, stackTrace: stackTrace),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Info row shows app version (from mock), build mode, and platform OS.
|
|
||||||
expect(find.textContaining('1.0.0+42'), findsWidgets);
|
|
||||||
// In test builds kDebugMode is true.
|
|
||||||
expect(find.textContaining('debug'), findsOneWidget);
|
|
||||||
// Platform OS is always present (linux in CI, android/ios on device).
|
|
||||||
expect(
|
|
||||||
find.textContaining(RegExp(r'linux|android|ios|windows|macos')),
|
|
||||||
findsWidgets,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'CrashScreen shows app version as clickable link when git hash is set',
|
'CrashScreen shows app version as clickable link when git hash is set',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
|||||||
@@ -106,8 +106,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'try connection button is disabled when no password stored or entered',
|
'try connection shows password required when no password stored', (
|
||||||
(
|
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
tester.view.physicalSize = const Size(800, 1400);
|
tester.view.physicalSize = const Size(800, 1400);
|
||||||
@@ -126,65 +125,11 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final button = tester.widget<OutlinedButton>(
|
await tester.tap(find.byKey(const Key('editTryConnectionButton')));
|
||||||
find.byKey(const Key('editTryConnectionButton')),
|
|
||||||
);
|
|
||||||
expect(button.onPressed, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'try connection button is enabled after typing password with no stored password',
|
|
||||||
(tester) async {
|
|
||||||
tester.view.physicalSize = const Size(800, 1400);
|
|
||||||
tester.view.devicePixelRatio = 1.0;
|
|
||||||
addTearDown(tester.view.resetPhysicalSize);
|
|
||||||
addTearDown(tester.view.resetDevicePixelRatio);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/acc-1/edit',
|
|
||||||
overrides: baseOverrides(
|
|
||||||
accounts: [kTestAccount],
|
|
||||||
hasStoredPassword: false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(
|
// App must not crash; password field shows a validation error.
|
||||||
find.byKey(const Key('editPasswordField')),
|
expect(find.text('Required'), findsOneWidget);
|
||||||
'mypassword',
|
|
||||||
);
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
final button = tester.widget<OutlinedButton>(
|
|
||||||
find.byKey(const Key('editTryConnectionButton')),
|
|
||||||
);
|
|
||||||
expect(button.onPressed, isNotNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('save button is disabled when no password stored or entered', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
tester.view.physicalSize = const Size(800, 1400);
|
|
||||||
tester.view.devicePixelRatio = 1.0;
|
|
||||||
addTearDown(tester.view.resetPhysicalSize);
|
|
||||||
addTearDown(tester.view.resetDevicePixelRatio);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/acc-1/edit',
|
|
||||||
overrides: baseOverrides(
|
|
||||||
accounts: [kTestAccount],
|
|
||||||
hasStoredPassword: false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
final button = tester
|
|
||||||
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Save'));
|
|
||||||
expect(button.onPressed, isNull);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('connection error shows error message', (tester) async {
|
testWidgets('connection error shows error message', (tester) async {
|
||||||
|
|||||||
@@ -179,210 +179,6 @@ void main() {
|
|||||||
expect(find.text('report.pdf'), findsOneWidget);
|
expect(find.text('report.pdf'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Reply All button is not present in app bar', (tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
|
||||||
overrides: _overrides(
|
|
||||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
find.byWidgetPredicate(
|
|
||||||
(w) => w is Tooltip && w.message == 'Reply all',
|
|
||||||
),
|
|
||||||
findsNothing,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('Reply on single-recipient email navigates directly to compose',
|
|
||||||
(tester) async {
|
|
||||||
// testEmail has from=[bob], to=[alice]. After removing alice (own),
|
|
||||||
// only bob remains → no dialog, navigate straight to compose.
|
|
||||||
final email = testEmail();
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
|
||||||
overrides: [
|
|
||||||
..._overrides(
|
|
||||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
|
||||||
email: email,
|
|
||||||
),
|
|
||||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(
|
|
||||||
find.byWidgetPredicate(
|
|
||||||
(w) => w is Tooltip && w.message == 'Reply',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// No dialog shown — straight navigation to compose.
|
|
||||||
expect(find.text('Reply All'), findsNothing);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('Reply on multi-recipient email shows Reply All dialog',
|
|
||||||
(tester) async {
|
|
||||||
// Email with an extra Cc recipient so the dialog is triggered.
|
|
||||||
final email = Email(
|
|
||||||
id: 'acc-1:42',
|
|
||||||
accountId: 'acc-1',
|
|
||||||
mailboxPath: 'INBOX',
|
|
||||||
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 [EmailAddress(name: 'Carol', email: 'carol@example.com')],
|
|
||||||
isSeen: false,
|
|
||||||
isFlagged: false,
|
|
||||||
hasAttachment: false,
|
|
||||||
);
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
|
||||||
overrides: _overrides(
|
|
||||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
|
||||||
email: email,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(
|
|
||||||
find.byWidgetPredicate(
|
|
||||||
(w) => w is Tooltip && w.message == 'Reply',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Dialog must appear with title 'Reply All'.
|
|
||||||
expect(find.text('Reply All'), findsOneWidget);
|
|
||||||
// Both non-own addresses should be listed in the dialog.
|
|
||||||
expect(find.textContaining('bob@example.com'), findsAtLeastNWidgets(1));
|
|
||||||
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('Mark as spam button is present in app bar', (tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
|
||||||
overrides: _overrides(
|
|
||||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
find.byWidgetPredicate(
|
|
||||||
(w) => w is Tooltip && w.message == 'Mark as spam',
|
|
||||||
),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('Mark as spam shows dialog when no junk folder',
|
|
||||||
(tester) async {
|
|
||||||
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
|
|
||||||
// returns null → dialog shown.
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
|
||||||
overrides: _overrides(
|
|
||||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(
|
|
||||||
find.byWidgetPredicate(
|
|
||||||
(w) => w is Tooltip && w.message == 'Mark as spam',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('No spam folder found'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('Archive button is present in app bar', (tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
|
||||||
overrides: _overrides(
|
|
||||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
find.byWidgetPredicate(
|
|
||||||
(w) => w is Tooltip && w.message == 'Archive',
|
|
||||||
),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('Archive shows dialog when no archive folder', (tester) async {
|
|
||||||
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
|
|
||||||
// returns null → dialog shown.
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
|
||||||
overrides: _overrides(
|
|
||||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(
|
|
||||||
find.byWidgetPredicate(
|
|
||||||
(w) => w is Tooltip && w.message == 'Archive',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('No archive folder found'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('Mark as unread is in popup menu, not a standalone button',
|
|
||||||
(tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
|
||||||
overrides: _overrides(
|
|
||||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// No standalone icon button for mark as unread.
|
|
||||||
expect(
|
|
||||||
find.byWidgetPredicate(
|
|
||||||
(w) => w is Tooltip && w.message == 'Mark as unread',
|
|
||||||
),
|
|
||||||
findsNothing,
|
|
||||||
);
|
|
||||||
|
|
||||||
// It appears in the popup menu.
|
|
||||||
await tester.tap(find.byType(PopupMenuButton<String>));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Mark as unread'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('Show Raw Email dialog shows size of email', (tester) async {
|
testWidgets('Show Raw Email dialog shows size of email', (tester) async {
|
||||||
// 'A' * 2048 → fmtSize(2048) == '2.0 KB'
|
// 'A' * 2048 → fmtSize(2048) == '2.0 KB'
|
||||||
final rawContent = 'A' * 2048;
|
final rawContent = 'A' * 2048;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
|
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/email_list_screen.dart';
|
import 'package:sharedinbox/ui/screens/email_list_screen.dart';
|
||||||
@@ -632,150 +631,5 @@ void main() {
|
|||||||
|
|
||||||
expect(find.text('This is the preview text'), findsOneWidget);
|
expect(find.text('This is the preview text'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('archive with missing folder', () {
|
|
||||||
testWidgets('shows dialog when archive folder is not found', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
final email = testEmail(subject: 'To archive');
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
||||||
overrides: [
|
|
||||||
accountRepositoryProvider.overrideWithValue(
|
|
||||||
FakeAccountRepository([kTestAccount]),
|
|
||||||
),
|
|
||||||
// No archive folder in the repo.
|
|
||||||
mailboxRepositoryProvider.overrideWithValue(
|
|
||||||
FakeMailboxRepository(),
|
|
||||||
),
|
|
||||||
emailRepositoryProvider.overrideWithValue(
|
|
||||||
FakeEmailRepository(emails: [email]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Enter selection mode and tap archive.
|
|
||||||
await tester.longPress(find.text('To archive'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
await tester.tap(find.byIcon(Icons.archive));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('No archive folder found'), findsOneWidget);
|
|
||||||
expect(find.text('Choose existing folder'), findsOneWidget);
|
|
||||||
expect(find.text('Create "Archive"'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('tapping Create creates the folder and moves emails', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
final email = testEmail(subject: 'To archive');
|
|
||||||
final movedTo = <String>[];
|
|
||||||
|
|
||||||
final fakeEmailRepo = _SpyEmailRepository(
|
|
||||||
emails: [email],
|
|
||||||
onMove: (id, path) => movedTo.add(path),
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
||||||
overrides: [
|
|
||||||
accountRepositoryProvider.overrideWithValue(
|
|
||||||
FakeAccountRepository([kTestAccount]),
|
|
||||||
),
|
|
||||||
mailboxRepositoryProvider.overrideWithValue(
|
|
||||||
FakeMailboxRepository(),
|
|
||||||
),
|
|
||||||
emailRepositoryProvider.overrideWithValue(fakeEmailRepo),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.longPress(find.text('To archive'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
await tester.tap(find.byIcon(Icons.archive));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Tap "Create Archive".
|
|
||||||
await tester.tap(find.text('Create "Archive"'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(movedTo, contains('Archive'));
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'tapping Choose existing opens folder picker and moves emails',
|
|
||||||
(tester) async {
|
|
||||||
final email = testEmail(subject: 'To archive');
|
|
||||||
final movedTo = <String>[];
|
|
||||||
|
|
||||||
final fakeEmailRepo = _SpyEmailRepository(
|
|
||||||
emails: [email],
|
|
||||||
onMove: (id, path) => movedTo.add(path),
|
|
||||||
);
|
|
||||||
const archiveFolder = Mailbox(
|
|
||||||
id: 'acc-1:OldArchive',
|
|
||||||
accountId: 'acc-1',
|
|
||||||
path: 'OldArchive',
|
|
||||||
name: 'OldArchive',
|
|
||||||
unreadCount: 0,
|
|
||||||
totalCount: 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
||||||
overrides: [
|
|
||||||
accountRepositoryProvider.overrideWithValue(
|
|
||||||
FakeAccountRepository([kTestAccount]),
|
|
||||||
),
|
|
||||||
// Repo has a folder but it has no 'archive' role.
|
|
||||||
mailboxRepositoryProvider.overrideWithValue(
|
|
||||||
FakeMailboxRepository([archiveFolder]),
|
|
||||||
),
|
|
||||||
emailRepositoryProvider.overrideWithValue(fakeEmailRepo),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.longPress(find.text('To archive'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
await tester.tap(find.byIcon(Icons.archive));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Tap "Choose existing folder".
|
|
||||||
await tester.tap(find.text('Choose existing folder'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Bottom sheet with folder list appears.
|
|
||||||
expect(find.text('OldArchive'), findsOneWidget);
|
|
||||||
|
|
||||||
await tester.tap(find.text('OldArchive'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(movedTo, contains('OldArchive'));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Email repository spy that records [moveEmail] calls.
|
|
||||||
class _SpyEmailRepository extends FakeEmailRepository {
|
|
||||||
_SpyEmailRepository({
|
|
||||||
super.emails,
|
|
||||||
required void Function(String emailId, String path) onMove,
|
|
||||||
}) : _onMove = onMove;
|
|
||||||
|
|
||||||
final void Function(String emailId, String path) _onMove;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> moveEmail(String emailId, String destMailboxPath) async {
|
|
||||||
_onMove(emailId, destMailboxPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -85,13 +85,11 @@ class FakeAccountRepository implements AccountRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FakeShareKeyRepository implements ShareKeyRepository {
|
class FakeShareKeyRepository implements ShareKeyRepository {
|
||||||
FakeShareKeyRepository({ShareKeyMaterial? material}) : _material = material;
|
|
||||||
|
|
||||||
ShareKeyMaterial? _material;
|
ShareKeyMaterial? _material;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ShareKeyMaterial> createKeyPair() async {
|
Future<ShareKeyMaterial> createKeyPair() async {
|
||||||
_material ??= await ShareEncryptionService.generateKeyPair();
|
_material = await ShareEncryptionService.generateKeyPair();
|
||||||
return _material!;
|
return _material!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,28 +162,8 @@ class FakeMailboxRepository implements MailboxRepository {
|
|||||||
@override
|
@override
|
||||||
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
|
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
|
||||||
_mailboxes.where((m) => m.role == role).firstOrNull;
|
_mailboxes.where((m) => m.role == role).firstOrNull;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> clearForResync(String accountId) async {}
|
Future<void> clearForResync(String accountId) async {}
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Mailbox> createMailboxWithRole(
|
|
||||||
String accountId,
|
|
||||||
String name,
|
|
||||||
String role,
|
|
||||||
) async {
|
|
||||||
final mailbox = Mailbox(
|
|
||||||
id: '$accountId:$name',
|
|
||||||
accountId: accountId,
|
|
||||||
path: name,
|
|
||||||
name: name,
|
|
||||||
role: role,
|
|
||||||
unreadCount: 0,
|
|
||||||
totalCount: 0,
|
|
||||||
);
|
|
||||||
_mailboxes.add(mailbox);
|
|
||||||
return mailbox;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeEmailRepository implements EmailRepository {
|
class FakeEmailRepository implements EmailRepository {
|
||||||
@@ -539,7 +517,6 @@ List<Override> baseOverrides({
|
|||||||
List<Mailbox>? mailboxes,
|
List<Mailbox>? mailboxes,
|
||||||
DiscoveryResult? discovery,
|
DiscoveryResult? discovery,
|
||||||
Exception? connectionError,
|
Exception? connectionError,
|
||||||
ShareKeyRepository? shareKeyRepository,
|
|
||||||
bool hasStoredPassword = true,
|
bool hasStoredPassword = true,
|
||||||
}) =>
|
}) =>
|
||||||
[
|
[
|
||||||
@@ -556,9 +533,7 @@ List<Override> baseOverrides({
|
|||||||
connectionTestServiceProvider.overrideWithValue(
|
connectionTestServiceProvider.overrideWithValue(
|
||||||
FakeConnectionTestService(error: connectionError),
|
FakeConnectionTestService(error: connectionError),
|
||||||
),
|
),
|
||||||
shareKeyRepositoryProvider.overrideWithValue(
|
shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()),
|
||||||
shareKeyRepository ?? FakeShareKeyRepository(),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -41,20 +41,6 @@ void main() {
|
|||||||
expect(html, contains('https: http: data: blob:'));
|
expect(html, contains('https: http: data: blob:'));
|
||||||
_expectLightMode(html);
|
_expectLightMode(html);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prevents horizontal overflow so wide HTML emails are not cut off',
|
|
||||||
() {
|
|
||||||
final html =
|
|
||||||
buildEmailHtml('<table width="600"><tr><td>x</td></tr></table>');
|
|
||||||
// Body clips overflow so fixed-width email tables don't escape the viewport.
|
|
||||||
expect(html, contains('overflow-x: hidden'));
|
|
||||||
// Tables are forced to full viewport width so fixed pixel widths don't overflow.
|
|
||||||
expect(html, contains('table { width: 100%'));
|
|
||||||
// All elements are capped at viewport width via max-width.
|
|
||||||
expect(html, contains('max-width: 100%'));
|
|
||||||
// Pre-formatted text wraps instead of stretching the page.
|
|
||||||
expect(html, contains('white-space: pre-wrap'));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// On Linux (the test host) the widget falls back to plain text extracted via
|
// On Linux (the test host) the widget falls back to plain text extracted via
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ The app processes the following data **exclusively on your device**:
|
|||||||
device's secure storage and never transmitted to us.
|
device's secure storage and never transmitted to us.
|
||||||
- **Email messages and attachments** — fetched directly from your email provider's IMAP server and
|
- **Email messages and attachments** — fetched directly from your email provider's IMAP server and
|
||||||
displayed in the app. We never receive, store, or process your emails.
|
displayed in the app. We never receive, store, or process your emails.
|
||||||
- **App settings and configuration** — stored locally on your device. The app will never upload
|
- **App settings and configuration** — stored locally on your device.
|
||||||
this data to sharedinbox.de or any third-party service.
|
|
||||||
|
|
||||||
### Network connections
|
### Network connections
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user