Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e957305a6 | ||
|
|
76f2635700 | ||
|
|
e2bb299300 | ||
|
|
f5abe9132b | ||
|
|
d55b316d4c | ||
|
|
f7fd30da15 | ||
|
|
d92cfac761 | ||
|
|
57b266a82b | ||
|
|
b7a8624c38 | ||
|
|
1e2f124cd0 | ||
|
|
916fc4bc6b | ||
|
|
3928060169 | ||
|
|
a67b707a41 | ||
|
|
4e00e14a92 | ||
|
|
156ccae83b | ||
|
|
b09b43d989 | ||
|
|
9fd30d8f28 | ||
|
|
e22322166c | ||
|
|
e76f7a5af2 | ||
|
|
913f9e8855 | ||
|
|
65173d323c | ||
|
|
72f634dd90 | ||
|
|
4712e768ea | ||
|
|
7985caa9b4 | ||
|
|
e28996cf86 | ||
|
|
d994723a2d | ||
|
|
145346c18a | ||
|
|
f3e1ca13de | ||
|
|
d86ce7766c | ||
|
|
f88d14f362 | ||
|
|
3e2da2bdf8 | ||
|
|
6a60c8d73b | ||
|
|
985bac7022 | ||
|
|
aed0d63703 |
@@ -0,0 +1,20 @@
|
|||||||
|
name: Chaos Monkey
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
chaos-monkey-backend:
|
||||||
|
name: Chaos Monkey (backend)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup Dagger Remote Engine
|
||||||
|
env:
|
||||||
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
- name: Run backend chaos monkey
|
||||||
|
run: task chaos-monkey-backend
|
||||||
@@ -1,11 +1,35 @@
|
|||||||
name: CI
|
name: CI
|
||||||
on: [push, pull_request]
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
name: Full Project Check
|
name: Full Project Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created_at=$(curl -sf \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||||
|
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||||
|
if [ -n "$created_at" ]; then
|
||||||
|
queued_epoch=$(date -d "$created_at" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Setup Dagger Remote Engine
|
- name: Setup Dagger Remote Engine
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -15,6 +15,23 @@ jobs:
|
|||||||
linux: ${{ steps.diff.outputs.linux }}
|
linux: ${{ steps.diff.outputs.linux }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created_at=$(curl -sf \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||||
|
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||||
|
if [ -n "$created_at" ]; then
|
||||||
|
queued_epoch=$(date -d "$created_at" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -141,6 +158,23 @@ jobs:
|
|||||||
if: needs.check-changes.outputs.android == 'true'
|
if: needs.check-changes.outputs.android == 'true'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created_at=$(curl -sf \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||||
|
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||||
|
if [ -n "$created_at" ]; then
|
||||||
|
queued_epoch=$(date -d "$created_at" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 100
|
fetch-depth: 100
|
||||||
@@ -175,6 +209,23 @@ jobs:
|
|||||||
if: needs.check-changes.outputs.android == 'true'
|
if: needs.check-changes.outputs.android == 'true'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created_at=$(curl -sf \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||||
|
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||||
|
if [ -n "$created_at" ]; then
|
||||||
|
queued_epoch=$(date -d "$created_at" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 100
|
fetch-depth: 100
|
||||||
@@ -203,6 +254,23 @@ jobs:
|
|||||||
if: needs.check-changes.outputs.linux == 'true'
|
if: needs.check-changes.outputs.linux == 'true'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created_at=$(curl -sf \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||||
|
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||||
|
if [ -n "$created_at" ]; then
|
||||||
|
queued_epoch=$(date -d "$created_at" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 100
|
fetch-depth: 100
|
||||||
@@ -236,6 +304,23 @@ jobs:
|
|||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created_at=$(curl -sf \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||||
|
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||||
|
if [ -n "$created_at" ]; then
|
||||||
|
queued_epoch=$(date -d "$created_at" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
- name: Set CI/Full-Pass or CI/Full-Fail label on tracking issue
|
- name: Set CI/Full-Pass or CI/Full-Fail label on tracking issue
|
||||||
env:
|
env:
|
||||||
FORGEJO_TOKEN: ${{ github.token }}
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
|||||||
@@ -14,6 +14,23 @@ jobs:
|
|||||||
has_changes: ${{ steps.diff.outputs.has_changes }}
|
has_changes: ${{ steps.diff.outputs.has_changes }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created_at=$(curl -sf \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||||
|
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||||
|
if [ -n "$created_at" ]; then
|
||||||
|
queued_epoch=$(date -d "$created_at" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -50,6 +67,23 @@ jobs:
|
|||||||
if: needs.check-changes.outputs.has_changes == 'true'
|
if: needs.check-changes.outputs.has_changes == 'true'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created_at=$(curl -sf \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||||
|
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||||
|
if [ -n "$created_at" ]; then
|
||||||
|
queued_epoch=$(date -d "$created_at" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|||||||
@@ -18,6 +18,23 @@ jobs:
|
|||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created_at=$(curl -sf \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||||
|
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||||
|
if [ -n "$created_at" ]; then
|
||||||
|
queued_epoch=$(date -d "$created_at" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|||||||
@@ -13,23 +13,27 @@ Automation is handled by [agentloop](https://github.com/guettli/agentloop) runni
|
|||||||
| Label | Trigger | Outcome |
|
| Label | Trigger | Outcome |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
|
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
|
||||||
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` |
|
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue routes to `loop/merge` |
|
||||||
|
| `loop/merge` | Merge agent rebases, waits for CI, and merges the PR | Issue moves to `loop/merge-done` |
|
||||||
|
|
||||||
**State machine:**
|
**State machine:**
|
||||||
|
|
||||||
```
|
```
|
||||||
loop/plan → loop/plan-in-progress → loop/plan-done
|
loop/plan → loop/plan-in-process → loop/plan-done
|
||||||
↘ NeedSupervisor (on failure)
|
↘ NeedSupervisor (on failure)
|
||||||
|
|
||||||
loop/code → loop/code-in-progress → loop/code-done
|
loop/code → loop/code-in-process → loop/merge (via route)
|
||||||
↘ NeedSupervisor (on failure)
|
↘ NeedSupervisor (on failure)
|
||||||
|
|
||||||
|
loop/merge → loop/merge-in-process → loop/merge-done
|
||||||
|
↘ NeedSupervisor (on failure)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Rules:**
|
**Rules:**
|
||||||
|
|
||||||
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
|
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
|
||||||
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
|
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
|
||||||
- The coding agent opens a PR but does NOT close the issue. A human reviews the PR and closes the issue after merging.
|
- The merge agent merges the PR automatically once CI is green. A human still reviews the PR before it merges if branch protection requires a review.
|
||||||
- Planning agents only post a comment — they do NOT write code or open PRs.
|
- Planning agents only post a comment — they do NOT write code or open PRs.
|
||||||
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
|
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
|
||||||
|
|
||||||
@@ -39,9 +43,9 @@ loop/code → loop/code-in-progress → loop/code-done
|
|||||||
1. Create issue
|
1. Create issue
|
||||||
2. Add label loop/plan → agent writes plan as comment
|
2. Add label loop/plan → agent writes plan as comment
|
||||||
3. Review plan, request changes or approve
|
3. Review plan, request changes or approve
|
||||||
4. Add label loop/code → agent implements + opens PR
|
4. Add label loop/code → agent implements + opens PR + hands off to merge
|
||||||
5. Review PR, merge
|
5. (Optional) Review PR before it merges
|
||||||
6. Close issue
|
6. Merge agent waits for CI and merges the PR automatically
|
||||||
```
|
```
|
||||||
|
|
||||||
## Code conventions
|
## Code conventions
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- echo "Setup complete."
|
- echo "Setup complete."
|
||||||
|
|
||||||
|
generate-icons:
|
||||||
|
desc: Rasterise icon.svg → icon.png and regenerate all platform launcher icons
|
||||||
|
deps: [_pub-get]
|
||||||
|
cmds:
|
||||||
|
- rsvg-convert -w 1024 -h 1024 icon.svg -o icon.png
|
||||||
|
- rsvg-convert -w 512 -h 512 icon.svg -o playstore/icon.png
|
||||||
|
- fvm flutter pub run flutter_launcher_icons
|
||||||
|
|
||||||
generate-changelog:
|
generate-changelog:
|
||||||
desc: Generate assets/changelog.txt from git history
|
desc: Generate assets/changelog.txt from git history
|
||||||
cmds:
|
cmds:
|
||||||
@@ -521,13 +529,6 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||||
|
|
||||||
deploy-android-bundle:
|
|
||||||
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
|
||||||
deps: [build-android-bundle-local]
|
|
||||||
dotenv: [".env"]
|
|
||||||
cmds:
|
|
||||||
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
|
|
||||||
|
|
||||||
build-android-bundle-local:
|
build-android-bundle-local:
|
||||||
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
||||||
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
||||||
@@ -542,6 +543,13 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
|
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
|
||||||
|
|
||||||
|
deploy-android-bundle:
|
||||||
|
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
||||||
|
deps: [build-android-bundle-local]
|
||||||
|
dotenv: [".env"]
|
||||||
|
cmds:
|
||||||
|
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
|
||||||
|
|
||||||
deploy-android:
|
deploy-android:
|
||||||
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
||||||
deps: [check, build-android]
|
deps: [check, build-android]
|
||||||
@@ -722,6 +730,11 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- fvm flutter test test/screenshot_automation_test.dart --update-goldens
|
- fvm flutter test test/screenshot_automation_test.dart --update-goldens
|
||||||
|
|
||||||
|
chaos-monkey-backend:
|
||||||
|
desc: Chaos monkey — random IMAP/SMTP ops against Stalwart (via Dagger, headless)
|
||||||
|
cmds:
|
||||||
|
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. chaos-monkey-backend
|
||||||
|
|
||||||
check:
|
check:
|
||||||
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
|
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
|
||||||
deps: [analyze, build-linux, test]
|
deps: [analyze, build-linux, test]
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 25 KiB |
@@ -19,7 +19,7 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.13.2" apply false
|
id("com.android.application") version "9.2.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -539,7 +539,7 @@ func (m *Ci) TestBackend(ctx context.Context) (string, error) {
|
|||||||
return m.WithStalwart(m.setup(m.backendSrc())).
|
return m.WithStalwart(m.setup(m.backendSrc())).
|
||||||
WithExec([]string{"/bin/bash", "-c",
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
`flutter test --concurrency=1 --reporter expanded --no-pub test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
`flutter test --concurrency=1 --reporter expanded --no-pub --exclude-tags=nightly test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
@@ -565,6 +565,16 @@ func (m *Ci) TestSyncReliability(ctx context.Context) (string, error) {
|
|||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChaosMonkeyBackend runs random IMAP/SMTP operations against Stalwart to surface crashes.
|
||||||
|
func (m *Ci) ChaosMonkeyBackend(ctx context.Context) (string, error) {
|
||||||
|
return m.WithStalwart(m.setup(m.backendSrc())).
|
||||||
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
|
`flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub --tags=nightly >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
|
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// Check runs the full check suite.
|
// Check runs the full check suite.
|
||||||
func (m *Ci) Check(ctx context.Context) (string, error) {
|
func (m *Ci) Check(ctx context.Context) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||||
|
|||||||
@@ -48,11 +48,28 @@
|
|||||||
chmod +x $out/bin/fgj
|
chmod +x $out/bin/fgj
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# The dagger/nix flake pins 0.20.8, whose Nix wrapper is a broken self-exec
|
||||||
|
# loop. Fetch 0.21.4 directly so the pre-commit dart-check hook can run.
|
||||||
|
dagger021 = pkgs.stdenv.mkDerivation {
|
||||||
|
pname = "dagger";
|
||||||
|
version = "0.21.4";
|
||||||
|
src = pkgs.fetchurl {
|
||||||
|
url = "https://dl.dagger.io/dagger/releases/0.21.4/dagger_v0.21.4_linux_amd64.tar.gz";
|
||||||
|
sha256 = "0wlnbr4g5069755131yjp2a6alacn64f1c8b27xn0cbynq3zicjd";
|
||||||
|
};
|
||||||
|
sourceRoot = ".";
|
||||||
|
installPhase = ''
|
||||||
|
mkdir -p $out/bin
|
||||||
|
cp dagger $out/bin/dagger
|
||||||
|
chmod +x $out/bin/dagger
|
||||||
|
'';
|
||||||
|
};
|
||||||
in {
|
in {
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
# Dagger CLI
|
# Dagger CLI
|
||||||
dagger.packages.${system}.dagger
|
dagger021
|
||||||
|
|
||||||
# Go compiler — for Dagger development
|
# Go compiler — for Dagger development
|
||||||
go
|
go
|
||||||
@@ -100,12 +117,16 @@
|
|||||||
])) # used by stalwart-dev/start and deploy_playstore.py
|
])) # used by stalwart-dev/start and deploy_playstore.py
|
||||||
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
||||||
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
|
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
|
||||||
|
librsvg # rsvg-convert — SVG→PNG for generate-icons task
|
||||||
]);
|
]);
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
|
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
|
||||||
export IN_NIX_SHELL=1
|
export IN_NIX_SHELL=1
|
||||||
|
|
||||||
|
# Point Dagger client at the running engine socket
|
||||||
|
export DAGGER_HOST=unix:///run/dagger/engine.sock
|
||||||
|
|
||||||
# Disable Flutter telemetry inside dev shell
|
# Disable Flutter telemetry inside dev shell
|
||||||
export FLUTTER_SUPPRESS_ANALYTICS=true
|
export FLUTTER_SUPPRESS_ANALYTICS=true
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
const int dbSchemaVersion = 39;
|
const int dbSchemaVersion = 40;
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ abstract class EmailRepository {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// Searches the local DB across all mailboxes of [accountId] (or all accounts
|
/// Searches the local DB across all mailboxes of [accountId] (or all accounts
|
||||||
/// if null) by subject and preview. Fast, works offline.
|
/// if null) by subject, preview, and notes. Fast, works offline.
|
||||||
Future<List<Email>> searchEmailsGlobal(String? accountId, String query);
|
Future<List<Email>> searchEmailsGlobal(String? accountId, String query);
|
||||||
|
|
||||||
/// Returns all locally cached emails in any mailbox of [accountId] (or all
|
/// Returns all locally cached emails in any mailbox of [accountId] (or all
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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';
|
import 'package:sharedinbox/core/db_schema_version.dart';
|
||||||
|
import 'package:sqlite3/sqlite3.dart' show Database;
|
||||||
|
|
||||||
part 'database.g.dart';
|
part 'database.g.dart';
|
||||||
|
|
||||||
@@ -338,6 +339,17 @@ class EmailNotes extends Table {
|
|||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records the first time the user ran each app version (identified by GIT_HASH).
|
||||||
|
/// Added in schema v40.
|
||||||
|
@DataClassName('InstalledVersionRow')
|
||||||
|
class InstalledVersions extends Table {
|
||||||
|
TextColumn get gitHash => text()();
|
||||||
|
DateTimeColumn get installedAt => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {gitHash};
|
||||||
|
}
|
||||||
|
|
||||||
/// App-wide user preferences, stored as a singleton row (id always 1).
|
/// App-wide user preferences, stored as a singleton row (id always 1).
|
||||||
@DataClassName('UserPreferencesRow')
|
@DataClassName('UserPreferencesRow')
|
||||||
class UserPreferences extends Table {
|
class UserPreferences extends Table {
|
||||||
@@ -384,6 +396,7 @@ class UserPreferences extends Table {
|
|||||||
UserPreferences,
|
UserPreferences,
|
||||||
ImageTrustedSenders,
|
ImageTrustedSenders,
|
||||||
EmailNotes,
|
EmailNotes,
|
||||||
|
InstalledVersions,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
@@ -663,8 +676,30 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
if (from < 39) {
|
if (from < 39) {
|
||||||
await m.createTable(emailNotes);
|
await m.createTable(emailNotes);
|
||||||
}
|
}
|
||||||
|
if (from < 40) {
|
||||||
|
await m.createTable(installedVersions);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Inserts a row for [gitHash] the first time that version is seen.
|
||||||
|
/// Subsequent calls for the same hash are silently ignored so the original
|
||||||
|
/// install timestamp is preserved.
|
||||||
|
Future<void> recordInstalledVersionIfNew(String gitHash) async {
|
||||||
|
if (gitHash.isEmpty) return;
|
||||||
|
await into(installedVersions).insert(
|
||||||
|
InstalledVersionsCompanion.insert(
|
||||||
|
gitHash: gitHash,
|
||||||
|
installedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
mode: InsertMode.insertOrIgnore,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, DateTime>> loadInstalledVersions() async {
|
||||||
|
final rows = await select(installedVersions).get();
|
||||||
|
return {for (final r in rows) r.gitHash: r.installedAt};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolved once in main() via initDatabasePath() before runApp().
|
// Resolved once in main() via initDatabasePath() before runApp().
|
||||||
@@ -759,18 +794,34 @@ Future<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
|
|||||||
void resetDatabasePathForTesting() => _dbPath = null;
|
void resetDatabasePathForTesting() => _dbPath = null;
|
||||||
Future<String?> androidFallbackPathForTesting() => _androidFallbackPath();
|
Future<String?> androidFallbackPathForTesting() => _androidFallbackPath();
|
||||||
|
|
||||||
|
/// Configures PRAGMAs on a newly opened SQLite connection.
|
||||||
|
///
|
||||||
|
/// busy_timeout must come first so subsequent statements retry on SQLITE_BUSY
|
||||||
|
/// instead of immediately failing.
|
||||||
|
///
|
||||||
|
/// journal_mode = WAL is wrapped in a try/catch because a concurrent
|
||||||
|
/// WorkManager background task may already have the DB open when the app
|
||||||
|
/// starts. SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5) is
|
||||||
|
/// returned in that situation; it only occurs when the DB is already in WAL
|
||||||
|
/// mode, so the pragma would be a no-op anyway and it is safe to continue.
|
||||||
|
void _setupPragmas(Database db) {
|
||||||
|
db.execute('PRAGMA busy_timeout = 5000;');
|
||||||
|
try {
|
||||||
|
db.execute('PRAGMA journal_mode = WAL;');
|
||||||
|
} on SqliteException catch (e) {
|
||||||
|
// resultCode strips the extended bits: both SQLITE_BUSY (5) and
|
||||||
|
// SQLITE_BUSY_SNAPSHOT (261) reduce to 5. Re-throw anything else.
|
||||||
|
if (e.resultCode != 5) rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LazyDatabase _openConnection() {
|
LazyDatabase _openConnection() {
|
||||||
return LazyDatabase(() async {
|
return LazyDatabase(() async {
|
||||||
final file = File(await _resolveDatabasePath());
|
final file = File(await _resolveDatabasePath());
|
||||||
return NativeDatabase.createInBackground(
|
return NativeDatabase.createInBackground(file, setup: _setupPragmas);
|
||||||
file,
|
|
||||||
setup: (db) {
|
|
||||||
// WAL lets readers and writers proceed concurrently (different account
|
|
||||||
// sync loops share the same DB). busy_timeout makes SQLite retry for
|
|
||||||
// up to 5 s instead of immediately returning SQLITE_BUSY.
|
|
||||||
db.execute('PRAGMA journal_mode = WAL;');
|
|
||||||
db.execute('PRAGMA busy_timeout = 5000;');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exposed so tests can run the exact production setup logic on a raw
|
||||||
|
// sqlite3 connection (same pattern as resolveDatabasePathForTesting).
|
||||||
|
void setupPragmasForTesting(Database db) => _setupPragmas(db);
|
||||||
|
|||||||
@@ -2922,9 +2922,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
|
|
||||||
final sql = accountId != null
|
final sql = accountId != null
|
||||||
? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||||
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank LIMIT 50'
|
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY e.received_at DESC LIMIT 50'
|
||||||
: 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
: 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||||
' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50';
|
' WHERE email_fts MATCH ? ORDER BY e.received_at DESC LIMIT 50';
|
||||||
final variables = accountId != null
|
final variables = accountId != null
|
||||||
? [Variable<String>(ftsQuery), Variable<String>(accountId)]
|
? [Variable<String>(ftsQuery), Variable<String>(accountId)]
|
||||||
: [Variable<String>(ftsQuery)];
|
: [Variable<String>(ftsQuery)];
|
||||||
@@ -2934,6 +2934,56 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final emailRows = await Future.wait(
|
final emailRows = await Future.wait(
|
||||||
queryRows.map((r) => _db.emails.mapFromRow(r)),
|
queryRows.map((r) => _db.emails.mapFromRow(r)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final noteRows = await _searchEmailsByNotes(accountId, null, query);
|
||||||
|
|
||||||
|
final seen = <String>{};
|
||||||
|
final merged = <model.Email>[];
|
||||||
|
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
|
||||||
|
if (seen.add(e.id)) merged.add(e);
|
||||||
|
}
|
||||||
|
merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns emails whose associated notes contain all words from [query].
|
||||||
|
/// Optionally filtered by [accountId] and [mailboxPath].
|
||||||
|
Future<List<model.Email>> _searchEmailsByNotes(
|
||||||
|
String? accountId,
|
||||||
|
String? mailboxPath,
|
||||||
|
String query,
|
||||||
|
) async {
|
||||||
|
final words =
|
||||||
|
query.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();
|
||||||
|
if (words.isEmpty) return [];
|
||||||
|
|
||||||
|
final noteConditions = words.map((_) => 'n.note_text LIKE ?').join(' AND ');
|
||||||
|
final likeVars = words.map((w) => Variable<String>('%$w%')).toList();
|
||||||
|
|
||||||
|
final extraConditions = StringBuffer();
|
||||||
|
final extraVars = <Variable<String>>[];
|
||||||
|
if (accountId != null) {
|
||||||
|
extraConditions.write(' AND e.account_id = ?');
|
||||||
|
extraVars.add(Variable<String>(accountId));
|
||||||
|
}
|
||||||
|
if (mailboxPath != null) {
|
||||||
|
extraConditions.write(' AND e.mailbox_path = ?');
|
||||||
|
extraVars.add(Variable<String>(mailboxPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
final sql = 'SELECT DISTINCT e.* FROM emails e'
|
||||||
|
' JOIN email_notes n ON n.message_id = e.message_id'
|
||||||
|
' AND n.account_id = e.account_id'
|
||||||
|
' WHERE $noteConditions$extraConditions'
|
||||||
|
' ORDER BY e.received_at DESC LIMIT 50';
|
||||||
|
|
||||||
|
final rows = await _db.customSelect(
|
||||||
|
sql,
|
||||||
|
variables: [...likeVars, ...extraVars],
|
||||||
|
readsFrom: {_db.emails, _db.emailNotes},
|
||||||
|
).get();
|
||||||
|
final emailRows =
|
||||||
|
await Future.wait(rows.map((r) => _db.emails.mapFromRow(r)));
|
||||||
return emailRows.map(_toModel).toList();
|
return emailRows.map(_toModel).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2943,9 +2993,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
static String _toFtsQuery(String query) {
|
static String _toFtsQuery(String query) {
|
||||||
final words = query
|
final words = query
|
||||||
.trim()
|
.trim()
|
||||||
.split(RegExp(r'\s+'))
|
.split(RegExp(r'[^\w]+'))
|
||||||
.where((w) => w.isNotEmpty)
|
|
||||||
.map((w) => w.replaceAll(RegExp(r'[^\w]'), ''))
|
|
||||||
.where((w) => w.isNotEmpty)
|
.where((w) => w.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
if (words.isEmpty) return '';
|
if (words.isEmpty) return '';
|
||||||
@@ -3047,68 +3095,42 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
// Results are limited to emails already synced into the local SQLite FTS5
|
||||||
|
// index; call syncEmails first to ensure the index is up-to-date.
|
||||||
Future<List<model.Email>> searchEmails(
|
Future<List<model.Email>> searchEmails(
|
||||||
String accountId,
|
String accountId,
|
||||||
String mailboxPath,
|
String mailboxPath,
|
||||||
String query,
|
String query,
|
||||||
) async {
|
) async {
|
||||||
final account = (await _accounts.getAccount(accountId))!;
|
final ftsQuery = _toFtsQuery(query);
|
||||||
final password = await _accounts.getPassword(accountId);
|
if (ftsQuery.isEmpty) return [];
|
||||||
final client = await _imapConnect(
|
|
||||||
account,
|
const sql = 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||||
_effectiveUsername(account),
|
' WHERE email_fts MATCH ? AND e.account_id = ? AND e.mailbox_path = ?'
|
||||||
password,
|
' ORDER BY e.received_at DESC LIMIT 50';
|
||||||
|
final variables = [
|
||||||
|
Variable<String>(ftsQuery),
|
||||||
|
Variable<String>(accountId),
|
||||||
|
Variable<String>(mailboxPath),
|
||||||
|
];
|
||||||
|
|
||||||
|
final queryRows = await _db
|
||||||
|
.customSelect(sql, variables: variables, readsFrom: {_db.emails}).get();
|
||||||
|
final emailRows = await Future.wait(
|
||||||
|
queryRows.map((r) => _db.emails.mapFromRow(r)),
|
||||||
);
|
);
|
||||||
try {
|
|
||||||
await client.selectMailboxByPath(mailboxPath);
|
|
||||||
final terms =
|
|
||||||
query.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
|
|
||||||
final searchCriteria = terms.map((term) {
|
|
||||||
final escaped = term.replaceAll('"', '\\"');
|
|
||||||
return 'OR SUBJECT "$escaped" TEXT "$escaped"';
|
|
||||||
}).join(' ');
|
|
||||||
final result = await client.uidSearchMessages(
|
|
||||||
searchCriteria: searchCriteria,
|
|
||||||
);
|
|
||||||
final uids = result.matchingSequence?.toList() ?? [];
|
|
||||||
if (uids.isEmpty) return [];
|
|
||||||
|
|
||||||
final fetch = await client.uidFetchMessages(
|
final noteRows = await _searchEmailsByNotes(accountId, mailboxPath, query);
|
||||||
imap.MessageSequence.fromIds(uids, isUid: true),
|
|
||||||
'(UID FLAGS ENVELOPE)',
|
final seen = <String>{};
|
||||||
);
|
final merged = <model.Email>[];
|
||||||
return fetch.messages
|
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
|
||||||
.where((msg) => msg.uid != null && msg.envelope != null)
|
if (seen.add(e.id)) merged.add(e);
|
||||||
.map((msg) {
|
|
||||||
final envelope = msg.envelope!;
|
|
||||||
final uid = msg.uid!;
|
|
||||||
final emailId = '$accountId:$uid';
|
|
||||||
return model.Email(
|
|
||||||
id: emailId,
|
|
||||||
accountId: accountId,
|
|
||||||
mailboxPath: mailboxPath,
|
|
||||||
uid: uid,
|
|
||||||
subject: envelope.subject,
|
|
||||||
sentAt: envelope.date,
|
|
||||||
receivedAt: envelope.date ?? DateTime.now(),
|
|
||||||
from: _toAddressList(envelope.from),
|
|
||||||
to: _toAddressList(envelope.to),
|
|
||||||
cc: _toAddressList(envelope.cc),
|
|
||||||
isSeen: msg.flags?.contains(r'\Seen') ?? false,
|
|
||||||
isFlagged: msg.flags?.contains(r'\Flagged') ?? false,
|
|
||||||
hasAttachment: msg.hasAttachments(),
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
} finally {
|
|
||||||
await client.logout();
|
|
||||||
}
|
}
|
||||||
|
merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<model.EmailAddress> _toAddressList(List<imap.MailAddress>? addresses) =>
|
|
||||||
(addresses ?? const [])
|
|
||||||
.map((a) => model.EmailAddress(name: a.personalName, email: a.email))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Computes a stable threadId from RFC 2822 headers.
|
/// Computes a stable threadId from RFC 2822 headers.
|
||||||
|
|||||||
@@ -294,6 +294,10 @@ final noteRepositoryProvider = Provider<NoteRepository>((ref) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final installedVersionsProvider = FutureProvider<Map<String, DateTime>>((ref) {
|
||||||
|
return ref.watch(dbProvider).loadInstalledVersions();
|
||||||
|
});
|
||||||
|
|
||||||
/// Stream of notes for a specific email, identified by (accountId, messageId).
|
/// Stream of notes for a specific email, identified by (accountId, messageId).
|
||||||
final notesProvider =
|
final notesProvider =
|
||||||
StreamProvider.autoDispose.family<List<EmailNote>, (String, String)>(
|
StreamProvider.autoDispose.family<List<EmailNote>, (String, String)>(
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ class SharedInboxApp extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState();
|
ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _kGitHash = String.fromEnvironment('GIT_HASH');
|
||||||
|
|
||||||
class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -93,6 +95,11 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
|||||||
// Start background IMAP sync once — runs for the lifetime of the app.
|
// Start background IMAP sync once — runs for the lifetime of the app.
|
||||||
ref.read(syncManagerProvider).start();
|
ref.read(syncManagerProvider).start();
|
||||||
ref.read(reliabilityRunnerProvider).start();
|
ref.read(reliabilityRunnerProvider).start();
|
||||||
|
if (_kGitHash.isNotEmpty) {
|
||||||
|
unawaited(
|
||||||
|
ref.read(dbProvider).recordInstalledVersionIfNew(_kGitHash),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -102,6 +109,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
|||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
),
|
),
|
||||||
darkTheme: ThemeData(
|
darkTheme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
@@ -109,6 +117,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
|||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
),
|
),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
),
|
),
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/sieve_script.dart';
|
import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||||
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/ui/screens/about_screen.dart';
|
import 'package:sharedinbox/ui/screens/about_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
||||||
@@ -22,6 +23,7 @@ import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
|
|||||||
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
|
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/trusted_image_senders_screen.dart';
|
import 'package:sharedinbox/ui/screens/trusted_image_senders_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/undo_log_detail_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
|
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
||||||
@@ -55,6 +57,14 @@ final router = GoRouter(
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'undo-log',
|
path: 'undo-log',
|
||||||
builder: (ctx, state) => const UndoLogScreen(),
|
builder: (ctx, state) => const UndoLogScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: ':actionId',
|
||||||
|
builder: (ctx, state) => UndoLogDetailScreen(
|
||||||
|
action: state.extra as UndoAction,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'changelog',
|
path: 'changelog',
|
||||||
|
|||||||
@@ -2,21 +2,90 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class ChangeLogScreen extends StatelessWidget {
|
class ChangeLogScreen extends ConsumerWidget {
|
||||||
const ChangeLogScreen({super.key});
|
const ChangeLogScreen({super.key});
|
||||||
|
|
||||||
|
static const _months = [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
|
];
|
||||||
|
|
||||||
|
static String _formatInstallDate(DateTime dt) {
|
||||||
|
final h = dt.hour.toString().padLeft(2, '0');
|
||||||
|
final m = dt.minute.toString().padLeft(2, '0');
|
||||||
|
final month = _months[dt.month - 1];
|
||||||
|
return '$h:$m, ${dt.day} $month ${dt.year}';
|
||||||
|
}
|
||||||
|
|
||||||
|
static const _repoUrl = 'https://codeberg.org/guettli/sharedinbox';
|
||||||
|
|
||||||
|
static final _issueRefPattern = RegExp(r'#(\d+)');
|
||||||
|
|
||||||
|
static String _linkifyIssueRefs(String text) {
|
||||||
|
return text.replaceAllMapped(
|
||||||
|
_issueRefPattern,
|
||||||
|
(m) => '[#${m[1]}]($_repoUrl/issues/${m[1]})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changelog lines have the form:
|
||||||
|
// * 2026-06-05 [abc1234](https://...): subject
|
||||||
|
// This pattern captures the short hash inside the markdown link.
|
||||||
|
static final _hashPattern = RegExp(r'\[([0-9a-f]{6,12})\]\(');
|
||||||
|
|
||||||
|
static String _injectInstallMarkers(
|
||||||
|
String changelog,
|
||||||
|
Map<String, DateTime> versions,
|
||||||
|
) {
|
||||||
|
if (versions.isEmpty) return changelog;
|
||||||
|
final lines = changelog.split('\n');
|
||||||
|
final buf = StringBuffer();
|
||||||
|
for (final line in lines) {
|
||||||
|
final match = _hashPattern.firstMatch(line);
|
||||||
|
if (match != null) {
|
||||||
|
final lineHash = match.group(1)!;
|
||||||
|
for (final entry in versions.entries) {
|
||||||
|
final stored = entry.key;
|
||||||
|
final matches = stored == lineHash ||
|
||||||
|
stored.startsWith(lineHash) ||
|
||||||
|
lineHash.startsWith(stored);
|
||||||
|
if (!matches) continue;
|
||||||
|
buf.write(
|
||||||
|
'\n---\n\n**Installed: ${_formatInstallDate(entry.value)}**\n\n',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf.writeln(line);
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final installedVersions = ref.watch(installedVersionsProvider);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('ChangeLog')),
|
appBar: AppBar(title: const Text('ChangeLog')),
|
||||||
body: FutureBuilder<String>(
|
body: FutureBuilder<String>(
|
||||||
future: DefaultAssetBundle.of(
|
future:
|
||||||
context,
|
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
|
||||||
).loadString('assets/changelog.txt'),
|
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting ||
|
||||||
|
installedVersions.isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
if (snapshot.hasError) {
|
if (snapshot.hasError) {
|
||||||
@@ -24,9 +93,12 @@ class ChangeLogScreen extends StatelessWidget {
|
|||||||
child: Text('Error loading changelog: ${snapshot.error}'),
|
child: Text('Error loading changelog: ${snapshot.error}'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final content = snapshot.data ?? 'No changelog entries found.';
|
final raw = snapshot.data ?? 'No changelog entries found.';
|
||||||
|
final content = _linkifyIssueRefs(raw);
|
||||||
|
final versions = installedVersions.value ?? {};
|
||||||
|
final annotated = _injectInstallMarkers(content, versions);
|
||||||
return Markdown(
|
return Markdown(
|
||||||
data: content,
|
data: annotated,
|
||||||
onTapLink: (text, href, title) {
|
onTapLink: (text, href, title) {
|
||||||
if (href != null) {
|
if (href != null) {
|
||||||
unawaited(
|
unawaited(
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class CrashScreen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
|
theme: ThemeData(splashFactory: NoSplash.splashFactory),
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Something went wrong'),
|
title: const Text('Something went wrong'),
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
// Pagination: number of threads currently requested from the DB.
|
// Pagination: number of threads currently requested from the DB.
|
||||||
static const _pageSize = 50;
|
static const _pageSize = 50;
|
||||||
int _limit = _pageSize;
|
int _limit = _pageSize;
|
||||||
|
|
||||||
|
// Incremented on every search start; stale completions are ignored when the
|
||||||
|
// generation has advanced (prevents out-of-order IMAP responses from
|
||||||
|
// overwriting fresh results with results for an older query).
|
||||||
|
int _searchGeneration = 0;
|
||||||
|
// The query whose results are currently settled in _searchResults.
|
||||||
|
// Used to skip redundant re-runs when the user presses Enter on an
|
||||||
|
// already-settled search (issue #473).
|
||||||
|
String? _lastSettledQuery;
|
||||||
bool get _selecting =>
|
bool get _selecting =>
|
||||||
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
||||||
|
|
||||||
@@ -61,6 +70,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_searchResults = null;
|
_searchResults = null;
|
||||||
_searchLoading = false;
|
_searchLoading = false;
|
||||||
|
_lastSettledQuery = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -117,18 +127,35 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runSearch(String query) async {
|
Future<void> _runSearch(String query) async {
|
||||||
if (query.trim().isEmpty) {
|
final q = query.trim();
|
||||||
setState(() => _searchResults = null);
|
if (q.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_searchResults = null;
|
||||||
|
_lastSettledQuery = null;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Skip if results are already settled for this exact query — prevents the
|
||||||
|
// Enter key from re-triggering a search that already completed.
|
||||||
|
if (_searchResults != null && !_searchLoading && q == _lastSettledQuery) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final generation = ++_searchGeneration;
|
||||||
setState(() => _searchLoading = true);
|
setState(() => _searchLoading = true);
|
||||||
try {
|
try {
|
||||||
final results = await ref
|
final results = await ref
|
||||||
.read(emailRepositoryProvider)
|
.read(emailRepositoryProvider)
|
||||||
.searchEmails(widget.accountId, widget.mailboxPath, query.trim());
|
.searchEmails(widget.accountId, widget.mailboxPath, q);
|
||||||
if (mounted) setState(() => _searchResults = results);
|
if (mounted && generation == _searchGeneration) {
|
||||||
|
setState(() {
|
||||||
|
_searchResults = results;
|
||||||
|
_lastSettledQuery = q;
|
||||||
|
});
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _searchLoading = false);
|
if (mounted && generation == _searchGeneration) {
|
||||||
|
setState(() => _searchLoading = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,8 +568,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
|
|
||||||
if (wasSearching && mounted) {
|
if (wasSearching && mounted) {
|
||||||
// Filter deleted emails out of the local results immediately.
|
// Filter deleted emails out of the local results immediately.
|
||||||
// Calling searchEmails here would hit the IMAP server, which still has
|
// Calling searchEmails here would still return deleted rows because the
|
||||||
// the emails because the delete is only enqueued — not yet applied.
|
// delete is only enqueued — not yet applied to the local DB.
|
||||||
final deletedIds = ids.toSet();
|
final deletedIds = ids.toSet();
|
||||||
final remaining = (_searchResults ?? [])
|
final remaining = (_searchResults ?? [])
|
||||||
.where((e) => !deletedIds.contains(e.id))
|
.where((e) => !deletedIds.contains(e.id))
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
|
||||||
|
final _dateTimeFmt = DateFormat('yyyy-MM-dd HH:mm:ss');
|
||||||
|
|
||||||
|
class UndoLogDetailScreen extends ConsumerWidget {
|
||||||
|
const UndoLogDetailScreen({super.key, required this.action});
|
||||||
|
|
||||||
|
final UndoAction action;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Undo Log Detail'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await ref
|
||||||
|
.read(undoServiceProvider.notifier)
|
||||||
|
.undo(actionId: action.id);
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text('Action undone.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Undo'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
children: [
|
||||||
|
_SectionHeader(text: 'Transaction', theme: theme),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.account_circle),
|
||||||
|
title: const Text('Account'),
|
||||||
|
subtitle: Text(action.accountId),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
action.type == UndoType.delete
|
||||||
|
? Icons.delete_outline
|
||||||
|
: (action.type == UndoType.snooze
|
||||||
|
? Icons.access_time
|
||||||
|
: Icons.move_to_inbox),
|
||||||
|
color: action.type == UndoType.delete
|
||||||
|
? Colors.redAccent
|
||||||
|
: (action.type == UndoType.snooze
|
||||||
|
? Colors.orangeAccent
|
||||||
|
: Colors.blueAccent),
|
||||||
|
),
|
||||||
|
title: const Text('Action'),
|
||||||
|
subtitle: Text(action.type.name.toUpperCase()),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.schedule),
|
||||||
|
title: const Text('Timestamp'),
|
||||||
|
subtitle: Text(_dateTimeFmt.format(action.timestamp.toLocal())),
|
||||||
|
),
|
||||||
|
_SectionHeader(text: 'Folders', theme: theme),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.folder_open),
|
||||||
|
title: const Text('Source'),
|
||||||
|
subtitle: Text(action.sourceMailboxPath),
|
||||||
|
),
|
||||||
|
if (action.type == UndoType.move &&
|
||||||
|
action.destinationMailboxPath != null)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.drive_file_move),
|
||||||
|
title: const Text('Destination'),
|
||||||
|
subtitle: Text(action.destinationMailboxPath!),
|
||||||
|
),
|
||||||
|
_SectionHeader(
|
||||||
|
text: 'Emails (${action.emailIds.length})',
|
||||||
|
theme: theme,
|
||||||
|
),
|
||||||
|
if (action.originalEmails.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
'${action.emailIds.length} email(s) — details not available',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...action.originalEmails.map((email) => _EmailTile(email: email)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SectionHeader extends StatelessWidget {
|
||||||
|
const _SectionHeader({required this.text, required this.theme});
|
||||||
|
|
||||||
|
final String text;
|
||||||
|
final ThemeData theme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmailTile extends StatelessWidget {
|
||||||
|
const _EmailTile({required this.email});
|
||||||
|
|
||||||
|
final Email email;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final sender = email.from.isNotEmpty
|
||||||
|
? (email.from.first.name ?? email.from.first.email)
|
||||||
|
: '(Unknown Sender)';
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.email_outlined),
|
||||||
|
title: Text(email.subject ?? '(No Subject)'),
|
||||||
|
subtitle: Text(sender, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
@@ -55,6 +56,10 @@ class _UndoActionTile extends ConsumerWidget {
|
|||||||
final extraCount = count > 1 ? ' (+${count - 1} more)' : '';
|
final extraCount = count > 1 ? ' (+${count - 1} more)' : '';
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
|
onTap: () => context.go(
|
||||||
|
'/accounts/undo-log/${action.id}',
|
||||||
|
extra: action,
|
||||||
|
),
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
action.type == UndoType.delete
|
action.type == UndoType.delete
|
||||||
? Icons.delete_outline
|
? Icons.delete_outline
|
||||||
|
|||||||
@@ -102,3 +102,7 @@ if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
|||||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||||
COMPONENT Runtime)
|
COMPONENT Runtime)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/sharedinbox.png"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||||
|
COMPONENT Runtime)
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ static void my_application_activate(GApplication* application) {
|
|||||||
|
|
||||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||||
|
|
||||||
|
gtk_window_set_icon_from_file(window, "sharedinbox.png", nullptr);
|
||||||
|
|
||||||
// Show AFTER adding FlView so GTK's first layout pass allocates the full
|
// Show AFTER adding FlView so GTK's first layout pass allocates the full
|
||||||
// window content area (1280×800) to FlView, not the default 1×1.
|
// window content area (1280×800) to FlView, not the default 1×1.
|
||||||
gtk_widget_show_all(GTK_WIDGET(window));
|
gtk_widget_show_all(GTK_WIDGET(window));
|
||||||
|
|||||||
|
After Width: | Height: | Size: 78 KiB |
@@ -371,6 +371,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_launcher_icons:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: flutter_launcher_icons
|
||||||
|
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.14.4"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -562,6 +570,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image
|
||||||
|
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.8.0"
|
||||||
integration_test:
|
integration_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -659,10 +675,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.18.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1088,26 +1104,26 @@ packages:
|
|||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.30.0"
|
version: "1.31.0"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.11"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.16"
|
version: "0.6.17"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ dependencies:
|
|||||||
|
|
||||||
# Local persistence (offline-first)
|
# Local persistence (offline-first)
|
||||||
drift: ^2.20.3
|
drift: ^2.20.3
|
||||||
|
sqlite3: ^3.1.5 # used directly in lib/data/db/database.dart (_setupPragmas)
|
||||||
sqlite3_flutter_libs: ^0.6.0+eol
|
sqlite3_flutter_libs: ^0.6.0+eol
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
@@ -78,9 +79,17 @@ dev_dependencies:
|
|||||||
mockito: ^5.4.4
|
mockito: ^5.4.4
|
||||||
fake_async: ^1.3.1
|
fake_async: ^1.3.1
|
||||||
path_provider_platform_interface: ^2.1.2
|
path_provider_platform_interface: ^2.1.2
|
||||||
sqlite3: ^3.1.5 # used directly in test/unit/db_test_helper.dart; 3.x required for Database.close()
|
|
||||||
url_launcher_platform_interface: ^2.3.2
|
url_launcher_platform_interface: ^2.3.2
|
||||||
plugin_platform_interface: ^2.1.8
|
plugin_platform_interface: ^2.1.8
|
||||||
|
flutter_launcher_icons: ^0.14.0
|
||||||
|
|
||||||
|
flutter_icons:
|
||||||
|
android: "ic_launcher"
|
||||||
|
ios: false
|
||||||
|
image_path: "icon.png"
|
||||||
|
linux:
|
||||||
|
generate: true
|
||||||
|
image_path: "icon.png"
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|||||||
@@ -19,6 +19,14 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"customManagers": [
|
"customManagers": [
|
||||||
|
{
|
||||||
|
"customType": "regex",
|
||||||
|
"fileMatch": ["^\\.fvmrc$"],
|
||||||
|
"matchStrings": ["\"flutter\":\\s*\"(?<currentValue>[^\"]+)\""],
|
||||||
|
"depNameTemplate": "ghcr.io/cirruslabs/flutter",
|
||||||
|
"datasourceTemplate": "docker",
|
||||||
|
"versioningTemplate": "semver"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"customType": "regex",
|
"customType": "regex",
|
||||||
"fileMatch": ["^\\.forgejo/Dockerfile$"],
|
"fileMatch": ["^\\.forgejo/Dockerfile$"],
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const _excluded = {
|
|||||||
'lib/ui/screens/sieve_scripts_screen.dart',
|
'lib/ui/screens/sieve_scripts_screen.dart',
|
||||||
'lib/ui/screens/sync_log_screen.dart',
|
'lib/ui/screens/sync_log_screen.dart',
|
||||||
'lib/ui/screens/thread_detail_screen.dart',
|
'lib/ui/screens/thread_detail_screen.dart',
|
||||||
|
'lib/ui/screens/undo_log_detail_screen.dart',
|
||||||
'lib/ui/screens/undo_log_screen.dart',
|
'lib/ui/screens/undo_log_screen.dart',
|
||||||
'lib/ui/widgets/folder_drawer.dart',
|
'lib/ui/widgets/folder_drawer.dart',
|
||||||
'lib/ui/widgets/secure_email_webview.dart',
|
'lib/ui/widgets/secure_email_webview.dart',
|
||||||
|
|||||||
@@ -17,12 +17,25 @@ sops --decrypt --output-type json secrets.enc.yaml > "$SECRETS_JSON"
|
|||||||
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
|
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
|
||||||
DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON")
|
DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON")
|
||||||
|
|
||||||
|
# Register inline secrets for log redaction. Multiline values (e.g. SSH keys)
|
||||||
|
# must be masked line-by-line because ::add-mask:: covers one line at a time.
|
||||||
|
printf '::add-mask::%s\n' "$DAGGER_ENGINE_HOST"
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -n "$line" ] && printf '::add-mask::%s\n' "$line"
|
||||||
|
done <<< "$DAGGER_SSH_KEY"
|
||||||
|
|
||||||
# Export all CI secrets to the GitHub Actions environment so subsequent steps
|
# Export all CI secrets to the GitHub Actions environment so subsequent steps
|
||||||
# can use them without referencing Forgejo secrets directly.
|
# can use them without referencing Forgejo secrets directly.
|
||||||
export_secret() {
|
export_secret() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
local value
|
local value
|
||||||
value=$(jq -r --arg k "$name" '.[$k] // empty' "$SECRETS_JSON")
|
value=$(jq -r --arg k "$name" '.[$k] // empty' "$SECRETS_JSON")
|
||||||
|
# Register each non-empty line for log redaction in the Actions runner.
|
||||||
|
if [ -n "$value" ] && [ -n "${GITHUB_ENV:-}" ]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -n "$line" ] && printf '::add-mask::%s\n' "$line"
|
||||||
|
done <<< "$value"
|
||||||
|
fi
|
||||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||||
# Use heredoc syntax for multiline-safe export.
|
# Use heredoc syntax for multiline-safe export.
|
||||||
# Avoid adding a second trailing newline for values that already end with one
|
# Avoid adding a second trailing newline for values that already end with one
|
||||||
@@ -63,11 +76,12 @@ if [ "$_elapsed" -gt 10 ]; then
|
|||||||
echo "::warning::ssh-keyscan took ${_elapsed}s — Dagger engine host may be slow to respond"
|
echo "::warning::ssh-keyscan took ${_elapsed}s — Dagger engine host may be slow to respond"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create a background SSH tunnel to the Dagger engine.
|
# Create a background SSH tunnel to the Dagger engine Unix socket.
|
||||||
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
|
# Forwards local TCP port 8080 directly to /run/dagger/engine.sock on the remote host,
|
||||||
|
# eliminating the need for a socat bridge on the server side.
|
||||||
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
|
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
|
||||||
_t0=$SECONDS
|
_t0=$SECONDS
|
||||||
timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
|
timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:/run/dagger/engine.sock "dagger@$DAGGER_ENGINE_HOST"
|
||||||
_elapsed=$(( SECONDS - _t0 ))
|
_elapsed=$(( SECONDS - _t0 ))
|
||||||
if [ "$_elapsed" -gt 10 ]; then
|
if [ "$_elapsed" -gt 10 ]; then
|
||||||
echo "::warning::SSH tunnel setup took ${_elapsed}s"
|
echo "::warning::SSH tunnel setup took ${_elapsed}s"
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
// Chaos monkey test — drives the email repository through random operations
|
||||||
|
// against a live Stalwart instance to surface crashes and data-corruption bugs.
|
||||||
|
//
|
||||||
|
// Run via: stalwart-dev/test.sh
|
||||||
|
//
|
||||||
|
// Environment variables:
|
||||||
|
// STALWART_IMAP_HOST, STALWART_IMAP_PORT
|
||||||
|
// STALWART_SMTP_HOST, STALWART_SMTP_PORT
|
||||||
|
// STALWART_USER_B / STALWART_PASS_B (alice@example.com)
|
||||||
|
// CHAOS_ROUNDS (default: 30) — number of random operations to perform
|
||||||
|
// CHAOS_SEED (default: current epoch ms) — seed for reproducibility
|
||||||
|
|
||||||
|
@Tags(['nightly'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:enough_mail/enough_mail.dart';
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/models/email.dart' as email_model;
|
||||||
|
import 'package:sharedinbox/data/db/database.dart' hide Account;
|
||||||
|
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../unit/account_repository_impl_test.dart' show MapSecureStorage;
|
||||||
|
import '../unit/db_test_helper.dart';
|
||||||
|
|
||||||
|
String _env(String key, [String fallback = '']) =>
|
||||||
|
Platform.environment[key] ?? fallback;
|
||||||
|
|
||||||
|
Future<ImapClient> _imapConnectPlain(
|
||||||
|
Account account,
|
||||||
|
String username,
|
||||||
|
String password,
|
||||||
|
) async {
|
||||||
|
final client =
|
||||||
|
ImapClient(defaultResponseTimeout: const Duration(seconds: 20));
|
||||||
|
await client.connectToServer(
|
||||||
|
account.imapHost,
|
||||||
|
account.imapPort,
|
||||||
|
isSecure: false,
|
||||||
|
);
|
||||||
|
await client.login(username, password);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SmtpClient> _smtpConnectPlain(
|
||||||
|
Account account,
|
||||||
|
String username,
|
||||||
|
String password,
|
||||||
|
) async {
|
||||||
|
final atIndex = account.email.lastIndexOf('@');
|
||||||
|
final domain =
|
||||||
|
atIndex != -1 ? account.email.substring(atIndex + 1) : account.smtpHost;
|
||||||
|
final client = SmtpClient(domain);
|
||||||
|
await client.connectToServer(
|
||||||
|
account.smtpHost,
|
||||||
|
account.smtpPort,
|
||||||
|
isSecure: false,
|
||||||
|
);
|
||||||
|
await client.ehlo();
|
||||||
|
await client.authenticate(username, password);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearMailbox(
|
||||||
|
Account account,
|
||||||
|
String userEmail,
|
||||||
|
String userPass,
|
||||||
|
String mailboxPath,
|
||||||
|
) async {
|
||||||
|
final client = await _imapConnectPlain(account, userEmail, userPass);
|
||||||
|
try {
|
||||||
|
final box = await client.selectMailboxByPath(mailboxPath);
|
||||||
|
if (box.messagesExists == 0) return;
|
||||||
|
final result = await client.uidSearchMessages(searchCriteria: 'ALL');
|
||||||
|
final uids = result.matchingSequence?.toList() ?? [];
|
||||||
|
if (uids.isEmpty) return;
|
||||||
|
final seq = MessageSequence.fromIds(uids, isUid: true);
|
||||||
|
await client.uidMarkDeleted(seq);
|
||||||
|
await client.uidExpunge(seq);
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late String imapHost;
|
||||||
|
late int imapPort;
|
||||||
|
late String smtpHost;
|
||||||
|
late int smtpPort;
|
||||||
|
late String userEmail;
|
||||||
|
late String userPass;
|
||||||
|
late Account account;
|
||||||
|
late AppDatabase db;
|
||||||
|
late EmailRepositoryImpl emails;
|
||||||
|
|
||||||
|
setUpAll(configureSqliteForTests);
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
imapHost = _env('STALWART_IMAP_HOST', '127.0.0.1');
|
||||||
|
imapPort = int.parse(_env('STALWART_IMAP_PORT', '1430'));
|
||||||
|
smtpHost = _env('STALWART_SMTP_HOST', '127.0.0.1');
|
||||||
|
smtpPort = int.parse(_env('STALWART_SMTP_PORT', '1025'));
|
||||||
|
userEmail = _env('STALWART_USER_B', 'alice@example.com');
|
||||||
|
userPass = _env('STALWART_PASS_B', 'secret');
|
||||||
|
|
||||||
|
account = Account(
|
||||||
|
id: 'chaos',
|
||||||
|
displayName: 'Chaos',
|
||||||
|
email: userEmail,
|
||||||
|
imapHost: imapHost,
|
||||||
|
imapPort: imapPort,
|
||||||
|
imapSsl: false,
|
||||||
|
smtpHost: smtpHost,
|
||||||
|
smtpPort: smtpPort,
|
||||||
|
);
|
||||||
|
|
||||||
|
db = openTestDatabase();
|
||||||
|
final secureStorage = MapSecureStorage();
|
||||||
|
final accounts = AccountRepositoryImpl(db, secureStorage);
|
||||||
|
await accounts.addAccount(account, userPass);
|
||||||
|
emails = EmailRepositoryImpl(
|
||||||
|
db,
|
||||||
|
accounts,
|
||||||
|
imapConnect: _imapConnectPlain,
|
||||||
|
smtpConnect: _smtpConnectPlain,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _clearMailbox(account, userEmail, userPass, 'INBOX');
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => db.close());
|
||||||
|
|
||||||
|
test('chaos monkey — random operations do not crash the repository',
|
||||||
|
timeout: Timeout.none, () async {
|
||||||
|
final seedStr = _env('CHAOS_SEED');
|
||||||
|
final seed = seedStr.isEmpty
|
||||||
|
? DateTime.now().millisecondsSinceEpoch
|
||||||
|
: int.parse(seedStr);
|
||||||
|
final rounds = int.parse(_env('CHAOS_ROUNDS', '30'));
|
||||||
|
final rng = Random(seed);
|
||||||
|
|
||||||
|
stdout.writeln('chaos-monkey: seed=$seed rounds=$rounds');
|
||||||
|
|
||||||
|
// Seed INBOX with a few messages so early rounds have something to act on.
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
await emails.sendEmail(
|
||||||
|
account.id,
|
||||||
|
email_model.EmailDraft(
|
||||||
|
from: email_model.EmailAddress(name: 'Chaos', email: userEmail),
|
||||||
|
to: [email_model.EmailAddress(email: userEmail)],
|
||||||
|
cc: [],
|
||||||
|
subject: 'seed-$i',
|
||||||
|
body: 'Seed email $i.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await emails.syncEmails(account.id, 'INBOX');
|
||||||
|
|
||||||
|
for (var round = 0; round < rounds; round++) {
|
||||||
|
final action = rng.nextInt(8);
|
||||||
|
stdout.writeln('chaos-monkey: round=$round action=$action');
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 0: // sync INBOX
|
||||||
|
await emails.syncEmails(account.id, 'INBOX');
|
||||||
|
|
||||||
|
case 1: // sync Sent
|
||||||
|
await emails.syncEmails(account.id, 'Sent');
|
||||||
|
|
||||||
|
case 2: // send email to self
|
||||||
|
final subject = 'chaos-$round-${rng.nextInt(9999)}';
|
||||||
|
await emails.sendEmail(
|
||||||
|
account.id,
|
||||||
|
email_model.EmailDraft(
|
||||||
|
from: email_model.EmailAddress(name: 'Chaos', email: userEmail),
|
||||||
|
to: [email_model.EmailAddress(email: userEmail)],
|
||||||
|
cc: [],
|
||||||
|
subject: subject,
|
||||||
|
body: 'Round $round. Value: ${rng.nextInt(1000000)}.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
case 3: // mark random email seen
|
||||||
|
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
|
||||||
|
if (inbox.isEmpty) break;
|
||||||
|
final e = inbox[rng.nextInt(inbox.length)];
|
||||||
|
await emails.setFlag(e.id, seen: true);
|
||||||
|
|
||||||
|
case 4: // mark random email unseen
|
||||||
|
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
|
||||||
|
if (inbox.isEmpty) break;
|
||||||
|
final e = inbox[rng.nextInt(inbox.length)];
|
||||||
|
await emails.setFlag(e.id, seen: false);
|
||||||
|
|
||||||
|
case 5: // toggle flagged on random email
|
||||||
|
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
|
||||||
|
if (inbox.isEmpty) break;
|
||||||
|
final e = inbox[rng.nextInt(inbox.length)];
|
||||||
|
await emails.setFlag(e.id, flagged: !e.isFlagged);
|
||||||
|
|
||||||
|
case 6: // flush pending changes to server
|
||||||
|
final flushed =
|
||||||
|
await emails.flushPendingChanges(account.id, userPass);
|
||||||
|
stdout.writeln('chaos-monkey: flushed $flushed pending changes');
|
||||||
|
|
||||||
|
case 7: // delete random email
|
||||||
|
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
|
||||||
|
if (inbox.isEmpty) break;
|
||||||
|
final e = inbox[rng.nextInt(inbox.length)];
|
||||||
|
await emails.deleteEmail(e.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final flush and sync to confirm the server is in a consistent state.
|
||||||
|
final flushed = await emails.flushPendingChanges(account.id, userPass);
|
||||||
|
stdout.writeln('chaos-monkey: final flush flushed=$flushed');
|
||||||
|
final result = await emails.syncEmails(account.id, 'INBOX');
|
||||||
|
stdout.writeln('chaos-monkey: final sync fetched=${result.fetched}');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -421,6 +421,7 @@ void main() {
|
|||||||
|
|
||||||
final r = makeRepo();
|
final r = makeRepo();
|
||||||
await r.accounts.addAccount(account, userPass);
|
await r.accounts.addAccount(account, userPass);
|
||||||
|
await r.emails.syncEmails('test', 'INBOX');
|
||||||
|
|
||||||
final results = await r.emails.searchEmails('test', 'INBOX', uniqueWord);
|
final results = await r.emails.searchEmails('test', 'INBOX', uniqueWord);
|
||||||
expect(results, hasLength(1));
|
expect(results, hasLength(1));
|
||||||
@@ -432,6 +433,7 @@ void main() {
|
|||||||
|
|
||||||
final r = makeRepo();
|
final r = makeRepo();
|
||||||
await r.accounts.addAccount(account, userPass);
|
await r.accounts.addAccount(account, userPass);
|
||||||
|
await r.emails.syncEmails('test', 'INBOX');
|
||||||
|
|
||||||
final results = await r.emails.searchEmails(
|
final results = await r.emails.searchEmails(
|
||||||
'test',
|
'test',
|
||||||
|
|||||||
@@ -453,6 +453,191 @@ void main() {
|
|||||||
expect(results.first.subject, 'foobar baz');
|
expect(results.first.subject, 'foobar baz');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('searchEmails filters by mailboxPath using local FTS5', () async {
|
||||||
|
final r = _makeRepos();
|
||||||
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
|
|
||||||
|
// Insert matching email in INBOX.
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 1,
|
||||||
|
subject: const Value('Meeting agenda'),
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Insert matching email in a different mailbox — must not appear.
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:2',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'Sent',
|
||||||
|
uid: 2,
|
||||||
|
subject: const Value('Meeting follow-up'),
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting');
|
||||||
|
expect(results, hasLength(1));
|
||||||
|
expect(results.first.subject, 'Meeting agenda');
|
||||||
|
expect(results.first.mailboxPath, 'INBOX');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchEmailsGlobal includes emails matched by note text', () async {
|
||||||
|
final r = _makeRepos();
|
||||||
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
|
|
||||||
|
// Email whose subject does NOT match — but its note does.
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 1,
|
||||||
|
messageId: const Value('<msg1@example.com>'),
|
||||||
|
subject: const Value('Weekly report'),
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Add a note referencing the email's messageId.
|
||||||
|
await r.db.into(r.db.emailNotes).insert(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: 'note-1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
messageId: '<msg1@example.com>',
|
||||||
|
noteText: 'Urgent follow-up needed',
|
||||||
|
serverId: '42',
|
||||||
|
createdAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final results = await r.emails.searchEmailsGlobal(null, 'urgent');
|
||||||
|
expect(results, hasLength(1));
|
||||||
|
expect(results.first.subject, 'Weekly report');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchEmails includes emails matched by note text in mailbox',
|
||||||
|
() async {
|
||||||
|
final r = _makeRepos();
|
||||||
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
|
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 1,
|
||||||
|
messageId: const Value('<msg1@example.com>'),
|
||||||
|
subject: const Value('Project update'),
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Email in a different mailbox — its note must not appear in INBOX search.
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:2',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'Sent',
|
||||||
|
uid: 2,
|
||||||
|
messageId: const Value('<msg2@example.com>'),
|
||||||
|
subject: const Value('Other email'),
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await r.db.into(r.db.emailNotes).insert(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: 'note-1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
messageId: '<msg1@example.com>',
|
||||||
|
noteText: 'remember to call client',
|
||||||
|
serverId: '42',
|
||||||
|
createdAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await r.db.into(r.db.emailNotes).insert(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: 'note-2',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
messageId: '<msg2@example.com>',
|
||||||
|
noteText: 'remember to call client',
|
||||||
|
serverId: '43',
|
||||||
|
createdAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'client');
|
||||||
|
expect(results, hasLength(1));
|
||||||
|
expect(results.first.subject, 'Project update');
|
||||||
|
expect(results.first.mailboxPath, 'INBOX');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchEmailsGlobal returns results sorted by receivedAt descending',
|
||||||
|
() async {
|
||||||
|
final r = _makeRepos();
|
||||||
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
|
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 1,
|
||||||
|
subject: const Value('Older report'),
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:2',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 2,
|
||||||
|
subject: const Value('Newer report'),
|
||||||
|
receivedAt: DateTime(2024, 6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final results = await r.emails.searchEmailsGlobal(null, 'report');
|
||||||
|
expect(results, hasLength(2));
|
||||||
|
expect(results[0].subject, 'Newer report');
|
||||||
|
expect(results[1].subject, 'Older report');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchEmails returns results sorted by receivedAt descending',
|
||||||
|
() async {
|
||||||
|
final r = _makeRepos();
|
||||||
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
|
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 1,
|
||||||
|
subject: const Value('Older meeting'),
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:2',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 2,
|
||||||
|
subject: const Value('Newer meeting'),
|
||||||
|
receivedAt: DateTime(2024, 6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting');
|
||||||
|
expect(results, hasLength(2));
|
||||||
|
expect(results[0].subject, 'Newer meeting');
|
||||||
|
expect(results[1].subject, 'Older meeting');
|
||||||
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'searchAddresses returns results sorted by most recently used',
|
'searchAddresses returns results sorted by most recently used',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ void main() {
|
|||||||
group('Migration', () {
|
group('Migration', () {
|
||||||
test('schemaVersion matches expected value', () async {
|
test('schemaVersion matches expected value', () async {
|
||||||
final db = AppDatabase(NativeDatabase.memory());
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
expect(db.schemaVersion, 39);
|
expect(db.schemaVersion, 40);
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -427,12 +427,15 @@ void main() {
|
|||||||
// v39: email_notes table.
|
// v39: email_notes table.
|
||||||
await db.customSelect('SELECT count(*) FROM email_notes').get();
|
await db.customSelect('SELECT count(*) FROM email_notes').get();
|
||||||
|
|
||||||
|
// v40: installed_versions table.
|
||||||
|
await db.customSelect('SELECT count(*) FROM installed_versions').get();
|
||||||
|
|
||||||
await db.close();
|
await db.close();
|
||||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test('fresh install creates all tables at schemaVersion 39', () async {
|
test('fresh install creates all tables at schemaVersion 40', () async {
|
||||||
final db = AppDatabase(NativeDatabase.memory());
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
await db.select(db.accounts).get();
|
await db.select(db.accounts).get();
|
||||||
|
|
||||||
@@ -462,6 +465,7 @@ void main() {
|
|||||||
'user_preferences', // v34
|
'user_preferences', // v34
|
||||||
'image_trusted_senders', // v37
|
'image_trusted_senders', // v37
|
||||||
'email_notes', // v39
|
'email_notes', // v39
|
||||||
|
'installed_versions', // v40
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -500,7 +504,46 @@ void main() {
|
|||||||
// v39: email_notes table.
|
// v39: email_notes table.
|
||||||
await db.customSelect('SELECT count(*) FROM email_notes').get();
|
await db.customSelect('SELECT count(*) FROM email_notes').get();
|
||||||
|
|
||||||
|
// v40: installed_versions table.
|
||||||
|
await db.customSelect('SELECT count(*) FROM installed_versions').get();
|
||||||
|
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression test for https://codeberg.org/guettli/sharedinbox/issues/508:
|
||||||
|
// _openConnection's setup callback must not crash when PRAGMA journal_mode =
|
||||||
|
// WAL fails with SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5)
|
||||||
|
// because a WorkManager background task already has the DB open in WAL mode.
|
||||||
|
group('WAL setup (#508)', () {
|
||||||
|
test(
|
||||||
|
'setupPragmasForTesting does not throw when WAL is already active and '
|
||||||
|
'another connection holds an open read transaction',
|
||||||
|
() {
|
||||||
|
final dbFile = File('test_wal_busy_508.db');
|
||||||
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
|
addTearDown(() {
|
||||||
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
// conn1: enable WAL and keep a read transaction open — simulates a
|
||||||
|
// WorkManager background task that opened the DB before the foreground
|
||||||
|
// app starts.
|
||||||
|
final conn1 = sqlite.sqlite3.open(dbFile.path);
|
||||||
|
conn1.execute('PRAGMA journal_mode = WAL;');
|
||||||
|
conn1.execute('BEGIN;');
|
||||||
|
conn1.select('SELECT 1;');
|
||||||
|
|
||||||
|
// conn2: run the exact production setup through setupPragmasForTesting.
|
||||||
|
// This must not throw even though conn1 holds an open transaction and
|
||||||
|
// the DB is already in WAL mode.
|
||||||
|
final conn2 = sqlite.sqlite3.open(dbFile.path);
|
||||||
|
expect(() => setupPragmasForTesting(conn2), returnsNormally);
|
||||||
|
|
||||||
|
conn1.execute('ROLLBACK;');
|
||||||
|
conn1.close();
|
||||||
|
conn2.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ Widget _buildScreen({List<Account> accounts = const []}) {
|
|||||||
FakeAccountRepository(accounts),
|
FakeAccountRepository(accounts),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: const MaterialApp(home: AboutScreen()),
|
child: MaterialApp(
|
||||||
|
theme: ThemeData(splashFactory: NoSplash.splashFactory),
|
||||||
|
home: const AboutScreen(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
||||||
|
|
||||||
class _FakeAssetBundle extends CachingAssetBundle {
|
class _FakeAssetBundle extends CachingAssetBundle {
|
||||||
@@ -19,16 +23,33 @@ class _FakeAssetBundle extends CachingAssetBundle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildScreen({
|
||||||
|
required Map<String, String> assets,
|
||||||
|
Map<String, DateTime> installedVersions = const {},
|
||||||
|
}) {
|
||||||
|
return ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
dbProvider.overrideWith((ref) {
|
||||||
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
|
ref.onDispose(db.close);
|
||||||
|
return db;
|
||||||
|
}),
|
||||||
|
installedVersionsProvider.overrideWith((ref) async => installedVersions),
|
||||||
|
],
|
||||||
|
child: DefaultAssetBundle(
|
||||||
|
bundle: _FakeAssetBundle(assets),
|
||||||
|
child: const MaterialApp(home: ChangeLogScreen()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const _fakeChangelog =
|
const _fakeChangelog =
|
||||||
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
|
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
|
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
DefaultAssetBundle(
|
_buildScreen(assets: {'assets/changelog.txt': _fakeChangelog}),
|
||||||
bundle: _FakeAssetBundle({'assets/changelog.txt': _fakeChangelog}),
|
|
||||||
child: const MaterialApp(home: ChangeLogScreen()),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
@@ -41,14 +62,58 @@ void main() {
|
|||||||
testWidgets('ChangeLogScreen shows error when asset is missing', (
|
testWidgets('ChangeLogScreen shows error when asset is missing', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(_buildScreen(assets: {}));
|
||||||
DefaultAssetBundle(
|
|
||||||
bundle: _FakeAssetBundle({}),
|
|
||||||
child: const MaterialApp(home: ChangeLogScreen()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.textContaining('Error loading changelog'), findsOneWidget);
|
expect(find.textContaining('Error loading changelog'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('ChangeLogScreen injects install marker for a known hash', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
const changelog =
|
||||||
|
'* 2024-01-01 [abc1234](https://example.com/abc1234): feat: initial release\n';
|
||||||
|
final installedAt = DateTime(2024, 6, 15, 14, 32);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildScreen(
|
||||||
|
assets: {'assets/changelog.txt': changelog},
|
||||||
|
installedVersions: {'abc1234': installedAt},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('Installed: 14:32'), findsOneWidget);
|
||||||
|
expect(find.textContaining('15 Jun 2024'), findsOneWidget);
|
||||||
|
expect(find.textContaining('initial release'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ChangeLogScreen shows no markers when no version recorded', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
const changelog =
|
||||||
|
'* 2024-01-01 [abc1234](https://example.com/abc1234): feat: initial release\n';
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildScreen(assets: {'assets/changelog.txt': changelog}),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('Installed:'), findsNothing);
|
||||||
|
expect(find.textContaining('initial release'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ChangeLogScreen renders #NNN as a tappable link', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
const changelog = '* 2024-03-01 fix: resolve crash, see #42\n';
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildScreen(assets: {'assets/changelog.txt': changelog}),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The link text "#42" must be visible in the rendered output.
|
||||||
|
expect(find.textContaining('#42'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
@@ -102,30 +104,6 @@ void main() {
|
|||||||
expect(find.byIcon(Icons.star), findsOneWidget);
|
expect(find.byIcon(Icons.star), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('tapping search icon shows search bar', (tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
||||||
overrides: [
|
|
||||||
accountRepositoryProvider.overrideWithValue(
|
|
||||||
FakeAccountRepository([kTestAccount]),
|
|
||||||
),
|
|
||||||
mailboxRepositoryProvider.overrideWithValue(
|
|
||||||
FakeMailboxRepository(),
|
|
||||||
),
|
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.search));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.byType(TextField), findsOneWidget);
|
|
||||||
expect(find.text('Search…'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('submitting a search query shows "No results" when empty', (
|
testWidgets('submitting a search query shows "No results" when empty', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -430,6 +408,230 @@ void main() {
|
|||||||
expect(find.text('Result email'), findsWidgets);
|
expect(find.text('Result email'), findsWidgets);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'tapping first of multiple search results opens the first email',
|
||||||
|
(tester) async {
|
||||||
|
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match');
|
||||||
|
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match');
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
|
overrides: [
|
||||||
|
accountRepositoryProvider.overrideWithValue(
|
||||||
|
FakeAccountRepository([kTestAccount]),
|
||||||
|
),
|
||||||
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(
|
||||||
|
searchResults: [email1, email2],
|
||||||
|
emailBody: const EmailBody(emailId: '', attachments: []),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'Match');
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Alpha Match'), findsOneWidget);
|
||||||
|
expect(find.text('Beta Match'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap the first result.
|
||||||
|
await tester.tap(find.text('Alpha Match'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(EmailDetailScreen), findsOneWidget);
|
||||||
|
// The detail AppBar title shows the first email's subject.
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(AppBar),
|
||||||
|
matching: find.text('Alpha Match'),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
// The second email's subject must not appear in the detail view.
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(EmailDetailScreen),
|
||||||
|
matching: find.text('Beta Match'),
|
||||||
|
),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'stale search results from a slower concurrent search are discarded',
|
||||||
|
(tester) async {
|
||||||
|
// Reproduces: user types quickly, triggering multiple concurrent IMAP
|
||||||
|
// searches. An older, slower search must not overwrite the results for
|
||||||
|
// the user's current query (issue #467).
|
||||||
|
final staleEmail = testEmail(id: 'acc-1:1', subject: 'Stale Result');
|
||||||
|
final freshEmail = testEmail(id: 'acc-1:2', subject: 'Fresh Result');
|
||||||
|
|
||||||
|
// The first search call is held open by a Completer; all subsequent
|
||||||
|
// calls resolve immediately with freshEmail.
|
||||||
|
final staleCompleter = Completer<List<Email>>();
|
||||||
|
var firstCall = true;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
|
overrides: [
|
||||||
|
accountRepositoryProvider.overrideWithValue(
|
||||||
|
FakeAccountRepository([kTestAccount]),
|
||||||
|
),
|
||||||
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(
|
||||||
|
onSearch: (_) {
|
||||||
|
if (firstCall) {
|
||||||
|
firstCall = false;
|
||||||
|
return staleCompleter.future;
|
||||||
|
}
|
||||||
|
return Future.value([freshEmail]);
|
||||||
|
},
|
||||||
|
emailBody: const EmailBody(emailId: '', attachments: []),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Trigger the first (slow) search.
|
||||||
|
await tester.enterText(find.byType(TextField), 'slow');
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||||
|
// Do not pumpAndSettle yet — the slow search is still in flight.
|
||||||
|
|
||||||
|
// Trigger the second (fast) search by changing the query.
|
||||||
|
await tester.enterText(find.byType(TextField), 'fast');
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||||
|
await tester.pumpAndSettle(); // fast searches settle immediately
|
||||||
|
|
||||||
|
// The fresh results must be shown.
|
||||||
|
expect(find.text('Fresh Result'), findsOneWidget);
|
||||||
|
expect(find.text('Stale Result'), findsNothing);
|
||||||
|
|
||||||
|
// Now let the stale search complete.
|
||||||
|
staleCompleter.complete([staleEmail]);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The stale results must NOT replace the fresh ones.
|
||||||
|
expect(find.text('Fresh Result'), findsOneWidget);
|
||||||
|
expect(find.text('Stale Result'), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'pressing Enter on already-settled search does not re-run search (issue #473)',
|
||||||
|
(tester) async {
|
||||||
|
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match');
|
||||||
|
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match');
|
||||||
|
|
||||||
|
var searchCallCount = 0;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
|
overrides: [
|
||||||
|
accountRepositoryProvider.overrideWithValue(
|
||||||
|
FakeAccountRepository([kTestAccount]),
|
||||||
|
),
|
||||||
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(
|
||||||
|
onSearch: (_) async {
|
||||||
|
searchCallCount++;
|
||||||
|
return [email1, email2];
|
||||||
|
},
|
||||||
|
emailBody: const EmailBody(emailId: '', attachments: []),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Run the initial search.
|
||||||
|
await tester.enterText(find.byType(TextField), 'Match');
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Alpha Match'), findsOneWidget);
|
||||||
|
expect(find.text('Beta Match'), findsOneWidget);
|
||||||
|
|
||||||
|
final countAfterFirstSearch = searchCallCount;
|
||||||
|
|
||||||
|
// Re-focus the search bar (simulates user tapping back into the field
|
||||||
|
// with the keyboard still visible) and press Enter again on the same,
|
||||||
|
// already-settled query.
|
||||||
|
await tester.tap(find.byType(TextField));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The search must NOT re-run; call count must not increase.
|
||||||
|
expect(
|
||||||
|
searchCallCount,
|
||||||
|
countAfterFirstSearch,
|
||||||
|
reason:
|
||||||
|
'Enter on settled results must not re-run the search (issue #473)',
|
||||||
|
);
|
||||||
|
// Results must still be visible — no loading spinner.
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||||
|
expect(find.text('Alpha Match'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'folder search returns results from local cache without any network call',
|
||||||
|
(tester) async {
|
||||||
|
// Verifies that searchEmails is backed by local SQLite (not IMAP).
|
||||||
|
// The repository throws if a network call is attempted, yet search
|
||||||
|
// must still return results.
|
||||||
|
final email = testEmail(subject: 'Cached subject');
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
|
overrides: [
|
||||||
|
accountRepositoryProvider.overrideWithValue(
|
||||||
|
FakeAccountRepository([kTestAccount]),
|
||||||
|
),
|
||||||
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(
|
||||||
|
onSearch: (_) async {
|
||||||
|
// Local DB: return cached results immediately.
|
||||||
|
return [email];
|
||||||
|
},
|
||||||
|
emailBody: const EmailBody(emailId: '', attachments: []),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'Cached');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Cached subject'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('deleting all search results pops back to previous screen', (
|
testWidgets('deleting all search results pops back to previous screen', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -216,12 +216,17 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
|
|
||||||
final List<Email> _searchResults;
|
final List<Email> _searchResults;
|
||||||
|
|
||||||
|
/// Optional override: when set, [searchEmails] calls this instead of
|
||||||
|
/// returning [_searchResults]. Useful for testing race-condition fixes.
|
||||||
|
final Future<List<Email>> Function(String query)? onSearch;
|
||||||
|
|
||||||
FakeEmailRepository({
|
FakeEmailRepository({
|
||||||
List<Email>? emails,
|
List<Email>? emails,
|
||||||
Email? emailDetail,
|
Email? emailDetail,
|
||||||
EmailBody? emailBody,
|
EmailBody? emailBody,
|
||||||
List<Email>? searchResults,
|
List<Email>? searchResults,
|
||||||
String rawRfc822 = '',
|
String rawRfc822 = '',
|
||||||
|
this.onSearch,
|
||||||
}) : _emails = emails ?? [],
|
}) : _emails = emails ?? [],
|
||||||
_emailDetail = emailDetail,
|
_emailDetail = emailDetail,
|
||||||
_searchResults = searchResults ?? [],
|
_searchResults = searchResults ?? [],
|
||||||
@@ -274,7 +279,15 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
|
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Email?> getEmail(String emailId) async => _emailDetail;
|
Future<Email?> getEmail(String emailId) async {
|
||||||
|
for (final e in _searchResults) {
|
||||||
|
if (e.id == emailId) return e;
|
||||||
|
}
|
||||||
|
for (final e in _emails) {
|
||||||
|
if (e.id == emailId) return e;
|
||||||
|
}
|
||||||
|
return _emailDetail;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<EmailBody> getEmailBody(String emailId) async => _emailBody;
|
Future<EmailBody> getEmailBody(String emailId) async => _emailBody;
|
||||||
@@ -340,8 +353,10 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
String accountId,
|
String accountId,
|
||||||
String mailboxPath,
|
String mailboxPath,
|
||||||
String query,
|
String query,
|
||||||
) async =>
|
) async {
|
||||||
_searchResults;
|
if (onSearch != null) return onSearch!(query);
|
||||||
|
return _searchResults;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Email>> searchEmailsGlobal(
|
Future<List<Email>> searchEmailsGlobal(
|
||||||
@@ -565,6 +580,7 @@ Widget buildApp({
|
|||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
),
|
),
|
||||||
darkTheme: ThemeData(
|
darkTheme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
@@ -572,6 +588,7 @@ Widget buildApp({
|
|||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
),
|
),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||