Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e76851c893 | ||
|
|
2ceabcacf0 | ||
|
|
c04764b565 | ||
|
|
a56eca0851 | ||
|
|
85c9df604b | ||
|
|
68950e6888 | ||
|
|
59a9ed9109 | ||
|
|
3d2288ab9f | ||
|
|
4ef441ab1b | ||
|
|
f28630fd7e | ||
|
|
6177605f22 | ||
|
|
ccfdfdb92e | ||
|
|
b631bdae24 | ||
|
|
4a07a175b9 | ||
|
|
2137d25d6d | ||
|
|
d03ee8b555 | ||
|
|
a82927cae8 | ||
|
|
6b1627b4c9 | ||
|
|
ef3255cd2b | ||
|
|
1aa2926f30 | ||
|
|
771ac691d9 | ||
|
|
65ac023622 | ||
|
|
838eee66bd | ||
|
|
6b4c2939ab | ||
|
|
0195f6e75c | ||
|
|
cd8c930000 | ||
|
|
b0354c7423 | ||
|
|
582f6764eb | ||
|
|
674d402ff9 | ||
|
|
09e20dd85f | ||
|
|
c1d314a621 | ||
|
|
fa5938c7bd | ||
|
|
f92f3debd7 | ||
|
|
692fa14d4d | ||
|
|
5e029a1365 | ||
|
|
87244de7da | ||
|
|
6d1df2d213 | ||
|
|
29c2c7e96c | ||
|
|
6a097976d3 | ||
|
|
d847d40ab0 | ||
|
|
761378f583 | ||
|
|
63da36c18a | ||
|
|
d3bd8dba92 | ||
|
|
9605c5e3b7 | ||
|
|
1681fb9202 | ||
|
|
d7a9c2b4f8 | ||
|
|
2747c4e63d |
@@ -4,8 +4,18 @@
|
|||||||
# In systemd service:
|
# In systemd service:
|
||||||
# ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner
|
# ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner
|
||||||
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
|
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
|
||||||
|
|
||||||
FROM ghcr.io/catthehacker/ubuntu:go-24.04
|
FROM ghcr.io/catthehacker/ubuntu:go-24.04
|
||||||
|
|
||||||
|
# Infrastructure tools required by CI workflows
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
jq \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# SOPS
|
||||||
|
RUN curl -fsSL -o /usr/local/bin/sops https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 \
|
||||||
|
&& chmod +x /usr/local/bin/sops
|
||||||
|
|
||||||
# Dagger CLI — pinned to match the engine version on the runner host
|
# Dagger CLI — pinned to match the engine version on the runner host
|
||||||
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
||||||
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
||||||
|
|||||||
@@ -34,14 +34,17 @@ jobs:
|
|||||||
|
|
||||||
HEAD_SHA=$(git rev-parse HEAD)
|
HEAD_SHA=$(git rev-parse HEAD)
|
||||||
|
|
||||||
# Skip if this exact commit was already successfully deployed (prevents
|
# Find the most recent workflow run where deploy-playstore actually succeeded
|
||||||
# hourly schedule from redeploying the same commit on every tick).
|
# (not merely skipped). Bug fix: previous code used commit_sha (always None in
|
||||||
|
# Forgejo's API) instead of head_sha, causing LAST_DEPLOYED_SHA to be empty on
|
||||||
|
# every run and the fallback diff to only cover HEAD~1..HEAD.
|
||||||
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
|
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
|
||||||
import json, os, sys, urllib.request
|
import json, os, sys, urllib.request
|
||||||
token = os.environ.get("FORGEJO_TOKEN", "")
|
token = os.environ.get("FORGEJO_TOKEN", "")
|
||||||
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||||
url = f"{server}/api/v1/repos/{repo}/actions/runs?workflow_id=deploy.yml&status=success&limit=5"
|
base_api = f"{server}/api/v1/repos/{repo}/actions"
|
||||||
|
url = f"{base_api}/runs?workflow_id=deploy.yml&status=success&limit=10"
|
||||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req) as r:
|
with urllib.request.urlopen(req) as r:
|
||||||
@@ -50,30 +53,58 @@ jobs:
|
|||||||
r for r in data.get("workflow_runs", [])
|
r for r in data.get("workflow_runs", [])
|
||||||
if r.get("status") == "success"
|
if r.get("status") == "success"
|
||||||
]
|
]
|
||||||
print(runs[0].get("commit_sha") or "")
|
# Walk runs newest-first; pick the first one where deploy-playstore
|
||||||
|
# actually ran (conclusion=success), not just skipped.
|
||||||
|
for run in runs:
|
||||||
|
run_id = run.get("id")
|
||||||
|
jobs_url = f"{base_api}/runs/{run_id}/jobs"
|
||||||
|
jobs_req = urllib.request.Request(jobs_url, headers={"Authorization": f"token {token}"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(jobs_req) as jr:
|
||||||
|
jobs_data = json.loads(jr.read())
|
||||||
|
for job in jobs_data.get("workflow_jobs", []):
|
||||||
|
if "Deploy to Play Store" in job.get("name", "") and (
|
||||||
|
job.get("conclusion") == "success" or
|
||||||
|
job.get("status") == "success"
|
||||||
|
):
|
||||||
|
print(run.get("head_sha") or "")
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception:
|
||||||
|
pass # skip this run if jobs API fails
|
||||||
|
print("")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"API check failed: {e}", file=sys.stderr)
|
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
|
||||||
print("")
|
print("")
|
||||||
PYEOF
|
PYEOF
|
||||||
)
|
)
|
||||||
|
|
||||||
if [ -n "$LAST_DEPLOYED_SHA" ] && [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
|
if [ -z "$LAST_DEPLOYED_SHA" ]; then
|
||||||
echo "HEAD $HEAD_SHA already successfully deployed — skipping"
|
echo "::warning::Could not determine last successfully deployed SHA — deploying all targets as a precaution"
|
||||||
|
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
|
||||||
|
echo "::notice::All deploys SKIPPED — HEAD $HEAD_SHA was already successfully deployed"
|
||||||
echo "android=false" >> "$GITHUB_OUTPUT"
|
echo "android=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "linux=false" >> "$GITHUB_OUTPUT"
|
echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "skip_reason=commit $HEAD_SHA was already successfully deployed" >> "$GITHUB_OUTPUT"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Diff from the last successfully deployed commit to catch all changes since
|
# Diff from the last successfully deployed commit to catch all changes since
|
||||||
# that deploy, not just the most recent commit. Falls back to HEAD~1 when
|
# that deploy, not just the most recent commit. Deploy all targets when the
|
||||||
# LAST_DEPLOYED_SHA is unknown or not in local history.
|
# SHA is not in local history (shallow clone or very old deploy).
|
||||||
if [ -n "$LAST_DEPLOYED_SHA" ] && git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
|
if git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
|
||||||
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
|
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
|
||||||
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|
||||||
|| git show --name-only --format= HEAD)
|
|| git show --name-only --format= HEAD)
|
||||||
else
|
else
|
||||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
echo "::warning::Last deployed SHA $LAST_DEPLOYED_SHA not in local history — deploying all targets as a precaution"
|
||||||
|| git show --name-only --format= HEAD)
|
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Changed files:"
|
echo "Changed files:"
|
||||||
@@ -82,13 +113,25 @@ jobs:
|
|||||||
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
|
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
|
||||||
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
||||||
|
|
||||||
echo "$CHANGED" | grep -qE "$android_re" \
|
if echo "$CHANGED" | grep -qE "$android_re"; then
|
||||||
&& echo "android=true" >> "$GITHUB_OUTPUT" \
|
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||||
|| echo "android=false" >> "$GITHUB_OUTPUT"
|
echo "Android deploy: TRIGGERED (android-relevant files changed)"
|
||||||
|
echo "::notice::Android deploy TRIGGERED — android-relevant files changed since $LAST_DEPLOYED_SHA"
|
||||||
|
else
|
||||||
|
echo "android=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Android deploy: SKIPPED (no android-relevant files changed)"
|
||||||
|
echo "::notice::Android deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no android-relevant changes"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "$CHANGED" | grep -qE "$linux_re" \
|
if echo "$CHANGED" | grep -qE "$linux_re"; then
|
||||||
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||||
|| echo "linux=false" >> "$GITHUB_OUTPUT"
|
echo "Linux deploy: TRIGGERED (linux-relevant files changed)"
|
||||||
|
echo "::notice::Linux deploy TRIGGERED — linux-relevant files changed since $LAST_DEPLOYED_SHA"
|
||||||
|
else
|
||||||
|
echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Linux deploy: SKIPPED (no linux-relevant files changed)"
|
||||||
|
echo "::notice::Linux deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no linux-relevant changes"
|
||||||
|
fi
|
||||||
|
|
||||||
deploy-playstore:
|
deploy-playstore:
|
||||||
name: Build & Deploy to Play Store
|
name: Build & Deploy to Play Store
|
||||||
@@ -113,14 +156,16 @@ jobs:
|
|||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Publish Android to Play Store
|
- name: Publish Android to Play Store
|
||||||
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
|
|
||||||
env:
|
env:
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task publish-android
|
run: task publish-android
|
||||||
|
|
||||||
|
- name: Verify Play Store deployment
|
||||||
|
run: |
|
||||||
|
python3 -m venv /tmp/playstore-venv
|
||||||
|
/tmp/playstore-venv/bin/pip install google-auth requests --quiet
|
||||||
|
/tmp/playstore-venv/bin/python3 scripts/verify_playstore_deploy.py
|
||||||
|
|
||||||
|
|
||||||
deploy-apk:
|
deploy-apk:
|
||||||
name: Build & Deploy APK to Server
|
name: Build & Deploy APK to Server
|
||||||
@@ -145,14 +190,7 @@ jobs:
|
|||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Build & Deploy APK to server
|
- name: Build & Deploy APK to server
|
||||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task deploy-apk
|
run: task deploy-apk
|
||||||
|
|
||||||
@@ -180,12 +218,7 @@ jobs:
|
|||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Build & Deploy Linux to server
|
- name: Build & Deploy Linux to server
|
||||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task deploy-linux
|
run: task deploy-linux
|
||||||
|
|
||||||
|
|||||||
@@ -65,9 +65,7 @@ jobs:
|
|||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Run Android Tests on Firebase Test Lab
|
- name: Run Android Tests on Firebase Test Lab
|
||||||
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
|
|
||||||
env:
|
env:
|
||||||
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
|
|
||||||
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task test-android-firebase
|
run: task test-android-firebase
|
||||||
|
|||||||
@@ -27,5 +27,4 @@ jobs:
|
|||||||
- name: Run Renovate
|
- name: Run Renovate
|
||||||
env:
|
env:
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
RENOVATE_FORGEJO_TOKEN: ${{ secrets.RENOVATE_FORGEJO_TOKEN }}
|
|
||||||
run: task renovate
|
run: task renovate
|
||||||
|
|||||||
@@ -33,17 +33,11 @@ jobs:
|
|||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Build & Update Website
|
- name: Build & Update Website
|
||||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task publish-website
|
run: task publish-website
|
||||||
|
|
||||||
- name: Verify Website
|
- name: Verify Website
|
||||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
|
||||||
env:
|
env:
|
||||||
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
SSH_HOST: ${{ env.WEBSITE_SSH_HOST }}
|
||||||
run: scripts/website-verify.sh
|
run: scripts/website-verify.sh
|
||||||
|
|||||||
@@ -1,250 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analyze-and-test:
|
|
||||||
name: Analyze & unit test
|
|
||||||
runs-on: sharedinbox-runner
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
flutter-version: "3.41.6"
|
|
||||||
channel: stable
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Generate Drift code
|
|
||||||
run: flutter pub run build_runner build --delete-conflicting-outputs
|
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
run: dart format --set-exit-if-changed .
|
|
||||||
|
|
||||||
- name: Analyze
|
|
||||||
run: flutter analyze --fatal-infos
|
|
||||||
|
|
||||||
- name: Unit + widget tests with coverage
|
|
||||||
run: flutter test test/unit/ test/widget/ --coverage
|
|
||||||
|
|
||||||
- name: Coverage gate
|
|
||||||
run: dart run scripts/check_coverage.dart
|
|
||||||
|
|
||||||
integration:
|
|
||||||
name: Integration tests (Stalwart)
|
|
||||||
runs-on: sharedinbox-runner
|
|
||||||
# Run integration tests only on push to main, not on every PR.
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: DeterminateSystems/nix-installer-action@v14
|
|
||||||
|
|
||||||
- uses: DeterminateSystems/magic-nix-cache-action@v8
|
|
||||||
|
|
||||||
- name: Cache FVM Flutter SDK
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.fvm
|
|
||||||
key: fvm-${{ hashFiles('.fvm/fvm_config.json') }}
|
|
||||||
|
|
||||||
- name: Cache pub packages
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.pub-cache
|
|
||||||
key: pub-${{ hashFiles('pubspec.lock') }}
|
|
||||||
restore-keys: pub-
|
|
||||||
|
|
||||||
- name: Run integration tests
|
|
||||||
run: |
|
|
||||||
nix develop --command bash -c "
|
|
||||||
fvm install --skip-pub-get &&
|
|
||||||
fvm flutter pub get &&
|
|
||||||
fvm flutter pub run build_runner build --delete-conflicting-outputs &&
|
|
||||||
stalwart-dev/test.sh
|
|
||||||
"
|
|
||||||
|
|
||||||
integration-ui:
|
|
||||||
name: UI Integration tests (Stalwart + Xvfb)
|
|
||||||
runs-on: sharedinbox-runner
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: DeterminateSystems/nix-installer-action@v14
|
|
||||||
|
|
||||||
- uses: DeterminateSystems/magic-nix-cache-action@v8
|
|
||||||
|
|
||||||
- name: Install Flutter Linux build dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -q
|
|
||||||
sudo apt-get install -y --no-install-recommends \
|
|
||||||
libgtk-3-dev pkg-config cmake ninja-build clang \
|
|
||||||
libsecret-1-dev
|
|
||||||
|
|
||||||
- name: Cache FVM Flutter SDK
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.fvm
|
|
||||||
key: fvm-${{ hashFiles('.fvm/fvm_config.json') }}
|
|
||||||
|
|
||||||
- name: Cache pub packages
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.pub-cache
|
|
||||||
key: pub-${{ hashFiles('pubspec.lock') }}
|
|
||||||
restore-keys: pub-
|
|
||||||
|
|
||||||
- name: Cache Linux debug build
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
build/linux
|
|
||||||
.dart_tool/flutter_build
|
|
||||||
key: linux-debug-${{ hashFiles('pubspec.lock', 'lib/**/*.dart', 'integration_test/**/*.dart') }}
|
|
||||||
restore-keys: linux-debug-
|
|
||||||
|
|
||||||
- name: Run UI integration tests
|
|
||||||
run: |
|
|
||||||
nix develop --command bash -c "
|
|
||||||
fvm install --skip-pub-get &&
|
|
||||||
fvm flutter pub get &&
|
|
||||||
fvm flutter pub run build_runner build --delete-conflicting-outputs &&
|
|
||||||
stalwart-dev/integration_ui_test.sh
|
|
||||||
"
|
|
||||||
|
|
||||||
build-linux:
|
|
||||||
name: Build Linux desktop
|
|
||||||
runs-on: sharedinbox-runner
|
|
||||||
needs: analyze-and-test
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install GTK3, build tools and libsecret
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -q
|
|
||||||
sudo apt-get install -y --no-install-recommends \
|
|
||||||
libgtk-3-dev pkg-config cmake ninja-build clang \
|
|
||||||
libsecret-1-dev
|
|
||||||
|
|
||||||
- uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
flutter-version: "3.41.6"
|
|
||||||
channel: stable
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Generate Drift code
|
|
||||||
run: flutter pub run build_runner build --delete-conflicting-outputs
|
|
||||||
|
|
||||||
- name: Build Linux release
|
|
||||||
run: flutter build linux --release
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
name: Deploy Linux build & publish website
|
|
||||||
runs-on: sharedinbox-runner
|
|
||||||
needs: build-linux
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
env:
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install build & deploy dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -q
|
|
||||||
sudo apt-get install -y --no-install-recommends \
|
|
||||||
libgtk-3-dev pkg-config cmake ninja-build clang \
|
|
||||||
libsecret-1-dev hugo rsync
|
|
||||||
|
|
||||||
- uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
flutter-version: "3.41.6"
|
|
||||||
channel: stable
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Cache pub packages
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.pub-cache
|
|
||||||
key: pub-${{ hashFiles('pubspec.lock') }}
|
|
||||||
restore-keys: pub-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Generate Drift code
|
|
||||||
run: flutter pub run build_runner build --delete-conflicting-outputs
|
|
||||||
|
|
||||||
- name: Generate changelog
|
|
||||||
run: |
|
|
||||||
mkdir -p assets
|
|
||||||
git log -n 50 \
|
|
||||||
--pretty=format:'* %ad [%h](https://codeberg.org/guettli/sharedinbox/commit/%H): %s' \
|
|
||||||
--date=short > assets/changelog.txt
|
|
||||||
|
|
||||||
- name: Setup SSH
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
printf '%s\n' "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
|
|
||||||
chmod 644 ~/.ssh/known_hosts
|
|
||||||
|
|
||||||
- name: Build Linux release
|
|
||||||
run: |
|
|
||||||
HASH=$(git rev-parse --short HEAD)
|
|
||||||
flutter build linux --release --no-pub --dart-define=GIT_HASH=$HASH
|
|
||||||
|
|
||||||
- name: Deploy Linux build to server
|
|
||||||
run: |
|
|
||||||
HASH=$(git rev-parse --short HEAD)
|
|
||||||
DATE_PATH=$(date -u +%Y/%m/%d)
|
|
||||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
|
||||||
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
|
|
||||||
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
|
|
||||||
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
|
||||||
scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
|
||||||
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
|
|
||||||
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" \
|
|
||||||
"cat public_html/latest.json 2>/dev/null || echo '{}'")
|
|
||||||
WINDOWS_URL=$(echo "$EXISTING" | \
|
|
||||||
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" \
|
|
||||||
2>/dev/null || true)
|
|
||||||
if [ -n "$WINDOWS_URL" ]; then
|
|
||||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
|
|
||||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
|
||||||
else
|
|
||||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
|
||||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Generate build history pages
|
|
||||||
run: python3 scripts/generate_build_history.py
|
|
||||||
|
|
||||||
- name: Build website
|
|
||||||
env:
|
|
||||||
HUGO_PARAMS_GITVERSION: ${{ github.sha }}
|
|
||||||
run: hugo --source website --minify
|
|
||||||
|
|
||||||
- name: Deploy website
|
|
||||||
run: |
|
|
||||||
rsync -avz --delete \
|
|
||||||
--exclude='*.apk' \
|
|
||||||
--exclude='*.tar.gz' \
|
|
||||||
website/public/ \
|
|
||||||
"$SSH_USER@$SSH_HOST:public_html/"
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# --- Flutter/Dart ---
|
# --- Flutter/Dart ---
|
||||||
coverage/
|
coverage/
|
||||||
|
screenshots/
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
.dart-tool/
|
.dart-tool/
|
||||||
.packages
|
.packages
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ repos:
|
|||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
|
|
||||||
|
- repo: https://github.com/guettli/pre-commit-branch-up-to-date
|
||||||
|
rev: v0.0.5
|
||||||
|
hooks:
|
||||||
|
- id: branch-up-to-date
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-no-binary
|
- id: check-no-binary
|
||||||
@@ -42,3 +47,9 @@ repos:
|
|||||||
entry: "bash -c 'git --no-pager grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
|
entry: "bash -c 'git --no-pager grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
|
- id: ci-image-exists
|
||||||
|
name: verify container images in ci/main.go are reachable
|
||||||
|
language: system
|
||||||
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-ci-images'
|
||||||
|
pass_filenames: false
|
||||||
|
files: ^(ci/main\.go|\.fvmrc)$
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ WorkingDirectory=/home/dagger-svc
|
|||||||
# Replace 1003 with the actual UID of dagger-svc
|
# Replace 1003 with the actual UID of dagger-svc
|
||||||
Environment=DOCKER_HOST=unix:///run/user/1003/podman/podman.sock
|
Environment=DOCKER_HOST=unix:///run/user/1003/podman/podman.sock
|
||||||
Environment=XDG_RUNTIME_DIR=/run/user/1003
|
Environment=XDG_RUNTIME_DIR=/run/user/1003
|
||||||
ExecStart=/usr/bin/nix run github:dagger/nix/v0.11.4#dagger -- engine --addr tcp://0.0.0.0:8080
|
ExecStart=/usr/bin/nix run github:dagger/nix/v0.20.8#dagger -- engine --addr tcp://0.0.0.0:8080
|
||||||
Restart=always
|
Restart=always
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
# Implementation Plan: Secure WebView for HTML Emails (#21)
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Replace the current `flutter_html` based rendering with a hardened WebView-based approach to improve rendering fidelity while strictly enforcing security and privacy.
|
|
||||||
|
|
||||||
## 1. Dependency Management
|
|
||||||
- **Core**: `webview_flutter` (v4+)
|
|
||||||
- **Linux Platform**: `webview_flutter_linux` (Official community-supported or WebKitGTK based implementation). *Note: I will verify the exact package name during implementation.*
|
|
||||||
- **Utilities**: `url_launcher` (existing) for opening links in the system browser.
|
|
||||||
|
|
||||||
## 2. Secure WebView Component (`lib/ui/widgets/secure_email_webview.dart`)
|
|
||||||
Create a new widget `SecureEmailWebView` that encapsulates the `WebViewWidget` and its controller.
|
|
||||||
|
|
||||||
### Configuration & Hardening
|
|
||||||
- **Disable JavaScript**: `controller.setJavaScriptMode(JavaScriptMode.disabled)`.
|
|
||||||
- **Background**: Match the application theme (e.g., transparent or surface color).
|
|
||||||
- **Security Headers/CSP**: Inject a Content Security Policy via `<meta>` tag in the HTML wrapper:
|
|
||||||
- `default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:;` (Blocks all external assets by default).
|
|
||||||
|
|
||||||
### Image Blocking Logic
|
|
||||||
- **Initial State**: Block remote images by injecting a CSP that restricts `img-src` to `data:` and local schemes.
|
|
||||||
- **Toggle Mechanism**:
|
|
||||||
- Provide a "Load Remote Images" button in the Flutter UI.
|
|
||||||
- When triggered, re-render the HTML with an updated CSP: `img-src * data:;`.
|
|
||||||
|
|
||||||
### Link Interception & Phishing Protection
|
|
||||||
- Implement `NavigationDelegate.onNavigationRequest`.
|
|
||||||
- **Process**:
|
|
||||||
1. Intercept any URL that doesn't start with `about:blank` or `data:`.
|
|
||||||
2. Block the navigation in the WebView.
|
|
||||||
3. Trigger a Flutter `showDialog` for confirmation.
|
|
||||||
- **Phishing Protection Dialog**:
|
|
||||||
- Show the full URL.
|
|
||||||
- **Bold the FQDN**: Parse the URL using `Uri.parse`.
|
|
||||||
- Example: `https://`**`important-bank.com`**`/login`
|
|
||||||
- "Open in Browser" button uses `url_launcher`.
|
|
||||||
|
|
||||||
## 3. Integration Plan
|
|
||||||
### Step 1: Initialization
|
|
||||||
Modify `lib/main.dart` to initialize the Linux WebView platform (using `webview_flutter_linux` or similar) during app startup.
|
|
||||||
|
|
||||||
### Step 2: Replace Renderer in Screens
|
|
||||||
- **EmailDetailScreen**: Replace `Html(...)` with `SecureEmailWebView(html: body.htmlBody!)`.
|
|
||||||
- **ThreadDetailScreen**: Replace `Html(...)` with `SecureEmailWebView(html: body.htmlBody!)`.
|
|
||||||
- Remove `flutter_html` imports and dependencies once migration is complete.
|
|
||||||
|
|
||||||
## 4. Verification & Security Audit
|
|
||||||
- **Manual Tests**:
|
|
||||||
- Open emails with complex HTML layouts.
|
|
||||||
- Verify images are blocked initially.
|
|
||||||
- Verify "Load images" works.
|
|
||||||
- Click various links (http, https, mailto) and verify the confirmation dialog and FQDN bolding.
|
|
||||||
- **Security Check**:
|
|
||||||
- Verify that `<script>` tags are not executed.
|
|
||||||
- Verify no network requests for external images occur before user consent (via DevTools or proxy).
|
|
||||||
|
|
||||||
## 5. Potential Challenges
|
|
||||||
- **Linux WebView Stability**: WebKitGTK on Linux can sometimes have rendering or sizing issues in Flutter.
|
|
||||||
- **Scrolling**: Ensuring the WebView integrates smoothly into the `ListView` of the email detail screen (might require fixed height or `SizedBox`).
|
|
||||||
@@ -218,7 +218,7 @@ tasks:
|
|||||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
msg: "SSH_KNOWN_HOSTS is not set"
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||||
|
|
||||||
build-android-bundle:
|
build-android-bundle:
|
||||||
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
||||||
@@ -247,7 +247,7 @@ tasks:
|
|||||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
|
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
|
||||||
|
|
||||||
deploy-apk:
|
deploy-apk:
|
||||||
desc: Build and deploy Android APK via Dagger
|
desc: Build and deploy Android APK via Dagger
|
||||||
@@ -261,7 +261,7 @@ tasks:
|
|||||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
|
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
|
||||||
|
|
||||||
publish-website:
|
publish-website:
|
||||||
desc: Build and publish website via Dagger
|
desc: Build and publish website via Dagger
|
||||||
@@ -271,7 +271,7 @@ tasks:
|
|||||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
msg: "SSH_KNOWN_HOSTS is not set"
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST"
|
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||||
|
|
||||||
check-dagger:
|
check-dagger:
|
||||||
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
||||||
@@ -294,7 +294,7 @@ tasks:
|
|||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
run_dagger "$@" && return 0
|
run_dagger "$@" && return 0
|
||||||
RC=$?
|
RC=$?
|
||||||
if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|context canceled|context deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; then
|
if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|context deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; then
|
||||||
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
||||||
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
|
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
|
||||||
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
|
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
|
||||||
@@ -426,6 +426,25 @@ tasks:
|
|||||||
fi
|
fi
|
||||||
echo "Uploaded $TARBALL and updated latest.json"
|
echo "Uploaded $TARBALL and updated latest.json"
|
||||||
|
|
||||||
|
deploy-bugreport:
|
||||||
|
desc: Build and deploy the Go bugreport server to the webserver
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_USER"
|
||||||
|
msg: "SSH_USER is not set"
|
||||||
|
- sh: test -n "$SSH_HOST"
|
||||||
|
msg: "SSH_HOST is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
|
cmds:
|
||||||
|
- cd server/bugreport && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ../../build/bugreport-server .
|
||||||
|
- |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||||
|
ssh "$SSH_USER@$SSH_HOST" "mkdir -p bugreport/reports"
|
||||||
|
scp build/bugreport-server "$SSH_USER@$SSH_HOST:bugreport/bugreport-server"
|
||||||
|
ssh "root@$SSH_HOST" "systemctl daemon-reload && systemctl restart bugreport"
|
||||||
|
echo "Uploaded bugreport-server to $SSH_HOST and restarted service"
|
||||||
|
|
||||||
build-windows-release:
|
build-windows-release:
|
||||||
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
|
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
|
||||||
deps: [_pub-get, generate-changelog]
|
deps: [_pub-get, generate-changelog]
|
||||||
@@ -569,7 +588,7 @@ tasks:
|
|||||||
|
|
||||||
run:
|
run:
|
||||||
desc: Run the app on Linux desktop
|
desc: Run the app on Linux desktop
|
||||||
deps: [_preflight, _linux-deps-check, _pub-get]
|
deps: [_preflight, _linux-deps-check, _pub-get, _codegen]
|
||||||
cmds:
|
cmds:
|
||||||
- fvm flutter run -d linux --no-pub
|
- fvm flutter run -d linux --no-pub
|
||||||
|
|
||||||
@@ -700,6 +719,11 @@ tasks:
|
|||||||
fi
|
fi
|
||||||
echo "Hygiene check passed."
|
echo "Hygiene check passed."
|
||||||
|
|
||||||
|
check-ci-images:
|
||||||
|
desc: Verify that all container images referenced in ci/main.go are reachable
|
||||||
|
cmds:
|
||||||
|
- scripts/check_ci_images.sh
|
||||||
|
|
||||||
_integrations:
|
_integrations:
|
||||||
internal: true
|
internal: true
|
||||||
run: once
|
run: once
|
||||||
@@ -712,6 +736,12 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- scripts/ci_logs.sh "{{.RUN}}" "{{.JOB}}"
|
- scripts/ci_logs.sh "{{.RUN}}" "{{.JOB}}"
|
||||||
|
|
||||||
|
screenshots:
|
||||||
|
desc: Generate Play Store promotional screenshots (30 golden files — 3 devices × 2 themes × 5 scenes)
|
||||||
|
deps: [_preflight, _codegen]
|
||||||
|
cmds:
|
||||||
|
- fvm flutter test test/screenshot_automation_test.dart --update-goldens
|
||||||
|
|
||||||
check:
|
check:
|
||||||
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
|
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
|
||||||
deps: [analyze, build-linux, test]
|
deps: [analyze, build-linux, test]
|
||||||
|
|||||||
@@ -20,7 +20,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 "8.13.2" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -2,52 +2,4 @@ module dagger/ci
|
|||||||
|
|
||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require golang.org/x/sync v0.20.0
|
||||||
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72
|
|
||||||
github.com/Khan/genqlient v0.8.1
|
|
||||||
github.com/dagger/otel-go v1.43.0
|
|
||||||
github.com/vektah/gqlparser/v2 v2.5.33
|
|
||||||
go.opentelemetry.io/otel v1.43.0
|
|
||||||
go.opentelemetry.io/otel/trace v1.43.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/99designs/gqlgen v0.17.90 // indirect
|
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
|
||||||
github.com/sosodev/duration v1.4.0 // indirect
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.17.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.17.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/log v0.17.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0
|
|
||||||
go.opentelemetry.io/otel/sdk/log v0.17.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
|
||||||
golang.org/x/net v0.52.0 // indirect
|
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
|
||||||
golang.org/x/text v0.35.0 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
|
||||||
google.golang.org/grpc v1.79.3 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
|
||||||
)
|
|
||||||
|
|
||||||
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0
|
|
||||||
|
|
||||||
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0
|
|
||||||
|
|
||||||
replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.19.0
|
|
||||||
|
|
||||||
replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.19.0
|
|
||||||
|
|||||||
@@ -1,97 +1,2 @@
|
|||||||
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72 h1:s39e07WvaUU6tLhpojK8ZEIoIbOSn5hHOJra0waenxQ=
|
|
||||||
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72/go.mod h1:ZXg8+pQZaZUC8rAw4V/gPP8aKvKARIJZ+pfcV+RC1es=
|
|
||||||
github.com/99designs/gqlgen v0.17.90 h1:wSv6blm/PoplU6QoNw83EcQpNtC0HX3/+44vITJOzpk=
|
|
||||||
github.com/99designs/gqlgen v0.17.90/go.mod h1:GqYrEwYsqCG8VaOsq2kJUCUKwAE1T+u2i+Nj7NtXiVI=
|
|
||||||
github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs=
|
|
||||||
github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU=
|
|
||||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
|
||||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
|
||||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
|
||||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
|
||||||
github.com/dagger/otel-go v1.43.0 h1:AYCnAamWmxtSxigWPTgC+8EWqiWPcDZEegh8y05gdJ8=
|
|
||||||
github.com/dagger/otel-go v1.43.0/go.mod h1:83CTuXi70zcx1kaym5buqmb7RNzg1E9dEiQSFyLbLdU=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
|
||||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
|
||||||
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
|
|
||||||
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
|
||||||
github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E=
|
|
||||||
github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
|
||||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
|
||||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 h1:VO3BL6OZXRQ1yQc8W6EVfJzINeJ35BkiHx4MYfoQf44=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0/go.mod h1:qRDnJ2nv3CQXMK2HUd9K9VtvedsPAce3S+/4LZHjX/s=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 h1:MMrOAN8H1FrvDyq9UJ4lu5/+ss49Qgfgb7Zpm0m8ABo=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0/go.mod h1:Na+2NNASJtF+uT4NxDe0G+NQb+bUgdPDfwxY/6JmS/c=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
|
|
||||||
go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
|
|
||||||
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
|
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
|
||||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
|
||||||
go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
|
|
||||||
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
|
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
|
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
|
||||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
|
||||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
|
||||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"dagger/ci/internal/dagger"
|
"dagger/ci/internal/dagger"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -148,16 +149,33 @@ if __name__ == "__main__":
|
|||||||
`
|
`
|
||||||
|
|
||||||
type Ci struct {
|
type Ci struct {
|
||||||
Source *dagger.Directory
|
Source *dagger.Directory
|
||||||
|
FlutterVersion string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
|
ctx context.Context,
|
||||||
// +defaultPath=".."
|
// +defaultPath=".."
|
||||||
source *dagger.Directory,
|
source *dagger.Directory,
|
||||||
) *Ci {
|
) (*Ci, error) {
|
||||||
|
fvmrcContents, err := source.File(".fvmrc").Contents(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read .fvmrc: %w", err)
|
||||||
|
}
|
||||||
|
var fvmrc struct {
|
||||||
|
Flutter string `json:"flutter"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(fvmrcContents), &fvmrc); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse .fvmrc: %w", err)
|
||||||
|
}
|
||||||
|
if fvmrc.Flutter == "" {
|
||||||
|
return nil, fmt.Errorf(".fvmrc is missing the 'flutter' field")
|
||||||
|
}
|
||||||
return &Ci{
|
return &Ci{
|
||||||
|
FlutterVersion: fvmrc.Flutter,
|
||||||
Source: source.Filter(dagger.DirectoryFilterOpts{
|
Source: source.Filter(dagger.DirectoryFilterOpts{
|
||||||
Include: []string{
|
Include: []string{
|
||||||
|
".fvmrc",
|
||||||
"lib/",
|
"lib/",
|
||||||
"test/",
|
"test/",
|
||||||
"assets/",
|
"assets/",
|
||||||
@@ -173,7 +191,7 @@ func New(
|
|||||||
"website/",
|
"website/",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolchain returns the Flutter+Android toolchain without any mutable cache mounts.
|
// toolchain returns the Flutter+Android toolchain without any mutable cache mounts.
|
||||||
@@ -181,7 +199,7 @@ func New(
|
|||||||
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
|
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
|
||||||
func (m *Ci) toolchain() *dagger.Container {
|
func (m *Ci) toolchain() *dagger.Container {
|
||||||
return dag.Container().
|
return dag.Container().
|
||||||
From("ghcr.io/cirruslabs/flutter:3.44.0").
|
From("ghcr.io/cirruslabs/flutter:"+m.FlutterVersion).
|
||||||
WithExec([]string{"apt-get", "-qq", "update"}).
|
WithExec([]string{"apt-get", "-qq", "update"}).
|
||||||
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
|
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
|
||||||
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
||||||
@@ -338,7 +356,17 @@ func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.
|
|||||||
return dag.Container().
|
return dag.Container().
|
||||||
From("alpine:3.21").
|
From("alpine:3.21").
|
||||||
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
|
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
|
||||||
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
// Create .ssh with strict permissions before Dagger mounts anything there,
|
||||||
|
// so the directory is 700 (not Dagger's default 755).
|
||||||
|
WithExec([]string{"sh", "-c", "mkdir -p /root/.ssh && chmod 700 /root/.ssh"}).
|
||||||
|
// Mount the raw key outside .ssh so Dagger cannot override the directory
|
||||||
|
// permissions we just set above.
|
||||||
|
WithMountedSecret("/tmp/id_ed25519.raw", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||||
|
// Normalise with Python3: strip CRLF/bare-CR, ensure trailing newline.
|
||||||
|
// Using Python3 (not tr) changes the Dagger cache key so stale cached
|
||||||
|
// results from the old tr-based step are not reused.
|
||||||
|
WithExec([]string{"python3", "-c",
|
||||||
|
"import os; raw=open('/tmp/id_ed25519.raw','rb').read(); key=raw.replace(b'\\r\\n',b'\\n').replace(b'\\r',b'\\n'); key=key if key.endswith(b'\\n') else key+b'\\n'; open('/root/.ssh/id_ed25519','wb').write(key); os.chmod('/root/.ssh/id_ed25519',0o600)"}).
|
||||||
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
|
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
|
||||||
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
|
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
|
||||||
}
|
}
|
||||||
@@ -412,11 +440,11 @@ func (m *Ci) Format(ctx context.Context) (string, error) {
|
|||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckMocks verifies that generated mocks are up to date.
|
// CheckGenerated verifies that all generated files (*.g.dart, *.mocks.dart) are up to date.
|
||||||
// It snapshots the committed source (including any stale *.mocks.dart) before
|
// It snapshots the committed source (including any stale generated files) before
|
||||||
// running build_runner, so git diff detects real staleness instead of always
|
// running build_runner, so git diff detects real staleness instead of always
|
||||||
// comparing two freshly-generated outputs.
|
// comparing two freshly-generated outputs.
|
||||||
func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
|
func (m *Ci) CheckGenerated(ctx context.Context) (string, error) {
|
||||||
return m.pubGetLayer().
|
return m.pubGetLayer().
|
||||||
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||||
WithWorkdir("/src").
|
WithWorkdir("/src").
|
||||||
@@ -429,16 +457,16 @@ func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
|
|||||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
|
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
|
||||||
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
|
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . \\( -name '*.g.dart' -o -name '*.mocks.dart' \\) | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Generated files are out of date — run: dart run build_runner build\"; exit 1; fi; echo \"Generated files are up to date.\""}).
|
||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coverage runs unit tests with coverage gate.
|
// Coverage runs unit and widget tests with coverage gate.
|
||||||
func (m *Ci) Coverage(ctx context.Context) (string, error) {
|
func (m *Ci) Coverage(ctx context.Context) (string, error) {
|
||||||
return m.setup(m.checkSrc()).
|
return m.setup(m.checkSrc()).
|
||||||
WithExec([]string{"/bin/bash", "-c",
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
`flutter test test/unit --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
`flutter test test/unit test/widget --exclude-tags golden --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||||
WithExec([]string{"dart", "scripts/check_coverage.dart"}).
|
WithExec([]string{"dart", "scripts/check_coverage.dart"}).
|
||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
@@ -480,11 +508,18 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
|||||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if _, err := m.CheckHygiene(ctx); err != nil {
|
// Run cheap structural checks in parallel for faster fail detection.
|
||||||
return "Hygiene check failed", err
|
var fastEg errgroup.Group
|
||||||
}
|
fastEg.Go(func() error {
|
||||||
if _, err := m.CheckLayers(ctx); err != nil {
|
_, err := m.CheckHygiene(ctx)
|
||||||
return "Layer check failed", err
|
return err
|
||||||
|
})
|
||||||
|
fastEg.Go(func() error {
|
||||||
|
_, err := m.CheckLayers(ctx)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err := fastEg.Wait(); err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
checkSetup := m.setup(m.checkSrc())
|
checkSetup := m.setup(m.checkSrc())
|
||||||
@@ -498,7 +533,7 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
|||||||
return analyze, err
|
return analyze, err
|
||||||
}
|
}
|
||||||
|
|
||||||
mocks, err := m.CheckMocks(ctx)
|
mocks, err := m.CheckGenerated(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return mocks, err
|
return mocks, err
|
||||||
}
|
}
|
||||||
@@ -508,16 +543,19 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
|||||||
return coverage, err
|
return coverage, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use errgroup.Group (not WithContext) so a failing test does not cancel its
|
||||||
|
// sibling via context — which would surface as "context canceled" in dagger
|
||||||
|
// output and trigger spurious retries in check-dagger.
|
||||||
var testBackend, testIntegration string
|
var testBackend, testIntegration string
|
||||||
eg, egCtx := errgroup.WithContext(ctx)
|
var eg errgroup.Group
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
var e error
|
var e error
|
||||||
testBackend, e = m.TestBackend(egCtx)
|
testBackend, e = m.TestBackend(ctx)
|
||||||
return e
|
return e
|
||||||
})
|
})
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
var e error
|
var e error
|
||||||
testIntegration, e = m.TestIntegration(egCtx)
|
testIntegration, e = m.TestIntegration(ctx)
|
||||||
return e
|
return e
|
||||||
})
|
})
|
||||||
if err := eg.Wait(); err != nil {
|
if err := eg.Wait(); err != nil {
|
||||||
@@ -559,6 +597,8 @@ func (m *Ci) BuildWebsite(
|
|||||||
knownHosts *dagger.Secret,
|
knownHosts *dagger.Secret,
|
||||||
sshUser string,
|
sshUser string,
|
||||||
sshHost string,
|
sshHost string,
|
||||||
|
// +optional
|
||||||
|
commitHash string,
|
||||||
) *dagger.Directory {
|
) *dagger.Directory {
|
||||||
buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
|
buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
|
||||||
|
|
||||||
@@ -566,9 +606,13 @@ func (m *Ci) BuildWebsite(
|
|||||||
Include: []string{"website/"},
|
Include: []string{"website/"},
|
||||||
}).WithDirectory("website/content/builds", buildHistory)
|
}).WithDirectory("website/content/builds", buildHistory)
|
||||||
|
|
||||||
return m.Hugo().
|
hugo := m.Hugo().
|
||||||
WithDirectory("/src", websiteSource).
|
WithDirectory("/src", websiteSource).
|
||||||
WithWorkdir("/src/website").
|
WithWorkdir("/src/website")
|
||||||
|
if commitHash != "" {
|
||||||
|
hugo = hugo.WithEnvVariable("HUGO_PARAMS_GITVERSION", commitHash)
|
||||||
|
}
|
||||||
|
return hugo.
|
||||||
WithExec([]string{"hugo", "--minify"}).
|
WithExec([]string{"hugo", "--minify"}).
|
||||||
Directory("public")
|
Directory("public")
|
||||||
}
|
}
|
||||||
@@ -580,8 +624,10 @@ func (m *Ci) PublishWebsite(
|
|||||||
knownHosts *dagger.Secret,
|
knownHosts *dagger.Secret,
|
||||||
sshUser string,
|
sshUser string,
|
||||||
sshHost string,
|
sshHost string,
|
||||||
|
// +optional
|
||||||
|
commitHash string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost)
|
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost, commitHash)
|
||||||
|
|
||||||
return m.Deployer(sshKey, knownHosts).
|
return m.Deployer(sshKey, knownHosts).
|
||||||
WithDirectory("/public", public).
|
WithDirectory("/public", public).
|
||||||
@@ -874,12 +920,12 @@ func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string
|
|||||||
//
|
//
|
||||||
// dagger call --progress=plain -q -m ci --source=. graph
|
// dagger call --progress=plain -q -m ci --source=. graph
|
||||||
func (m *Ci) Graph() string {
|
func (m *Ci) Graph() string {
|
||||||
return `# CI Pipeline Graph
|
return fmt.Sprintf(`# CI Pipeline Graph
|
||||||
|
|
||||||
` + "```" + `mermaid
|
`+"```"+`mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
subgraph dagger ["Dagger · Check pipeline"]
|
subgraph dagger ["Dagger · Check pipeline"]
|
||||||
toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"]
|
toolchain["toolchain\nflutter:%s + NDK + apt + precache"]`, m.FlutterVersion) + `
|
||||||
pubGet["pubGetLayer\nflutter pub get"]
|
pubGet["pubGetLayer\nflutter pub get"]
|
||||||
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
||||||
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
||||||
@@ -889,7 +935,7 @@ flowchart TD
|
|||||||
|
|
||||||
pubGet --> hygiene["CheckHygiene"]
|
pubGet --> hygiene["CheckHygiene"]
|
||||||
pubGet --> layers["CheckLayers"]
|
pubGet --> layers["CheckLayers"]
|
||||||
pubGet --> mocks["CheckMocks\n(own build_runner run)"]
|
pubGet --> mocks["CheckGenerated\n(own build_runner run)"]
|
||||||
|
|
||||||
codegen --> fmt["Format"]
|
codegen --> fmt["Format"]
|
||||||
codegen --> analyze["Analyze"]
|
codegen --> analyze["Analyze"]
|
||||||
|
|||||||
@@ -99,6 +99,7 @@
|
|||||||
httplib2
|
httplib2
|
||||||
])) # used by stalwart-dev/start and deploy_playstore.py
|
])) # used by stalwart-dev/start and deploy_playstore.py
|
||||||
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
||||||
|
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" shape-rendering="geometricPrecision">
|
||||||
|
<!-- White Background -->
|
||||||
|
<rect width="512" height="512" fill="white"/>
|
||||||
|
|
||||||
|
<!-- 6 Concentric Rainbow Rings (Tunnel Vision Geometry) -->
|
||||||
|
<g fill-rule="evenodd" stroke="black" stroke-width="2.5">
|
||||||
|
<!-- Red -->
|
||||||
|
<path fill="#FF0000" d="M256,256 m-242,0 a242,242 0 1,0 484,0 a242,242 0 1,0 -484,0 Z M256,256 m-190,0 a190,190 0 1,0 380,0 a190,190 0 1,0 -380,0 Z" />
|
||||||
|
|
||||||
|
<!-- Orange -->
|
||||||
|
<path fill="#FF8C00" d="M256,256 m-170,0 a170,170 0 1,0 340,0 a170,170 0 1,0 -340,0 Z M256,256 m-131,0 a131,131 0 1,0 262,0 a131,131 0 1,0 -262,0 Z" />
|
||||||
|
|
||||||
|
<!-- Yellow -->
|
||||||
|
<path fill="#FFD700" d="M256,256 m-115,0 a115,115 0 1,0 230,0 a115,115 0 1,0 -230,0 Z M256,256 m-85,0 a85,85 0 1,0 170,0 a85,85 0 1,0 -170,0 Z" />
|
||||||
|
|
||||||
|
<!-- Green -->
|
||||||
|
<path fill="#22AA00" d="M256,256 m-73,0 a73,73 0 1,0 146,0 a73,73 0 1,0 -146,0 Z M256,256 m-51,0 a51,51 0 1,0 102,0 a51,51 0 1,0 -102,0 Z" />
|
||||||
|
|
||||||
|
<!-- Blue -->
|
||||||
|
<path fill="#0055FF" d="M256,256 m-41,0 a41,41 0 1,0 82,0 a41,41 0 1,0 -82,0 Z M256,256 m-24,0 a24,24 0 1,0 48,0 a24,24 0 1,0 -48,0 Z" />
|
||||||
|
|
||||||
|
<!-- Purple -->
|
||||||
|
<path fill="#8B00FF" d="M256,256 m-16,0 a16,16 0 1,0 32,0 a16,16 0 1,0 -32,0 Z M256,256 m-3,0 a3,3 0 1,0 6,0 a3,3 0 1,0 -6,0 Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1 +1 @@
|
|||||||
const int dbSchemaVersion = 36;
|
const int dbSchemaVersion = 38;
|
||||||
|
|||||||
@@ -2,13 +2,30 @@ enum MenuPosition { bottom, top }
|
|||||||
|
|
||||||
enum AfterMailViewAction { nextMessage, showMailbox }
|
enum AfterMailViewAction { nextMessage, showMailbox }
|
||||||
|
|
||||||
|
enum PrefetchMode {
|
||||||
|
disabled,
|
||||||
|
wifiOnly,
|
||||||
|
always;
|
||||||
|
|
||||||
|
static PrefetchMode fromString(String? value) {
|
||||||
|
return PrefetchMode.values.firstWhere(
|
||||||
|
(e) => e.name == value,
|
||||||
|
orElse: () => PrefetchMode.wifiOnly,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class UserPreferences {
|
class UserPreferences {
|
||||||
const UserPreferences({
|
const UserPreferences({
|
||||||
this.menuPosition = MenuPosition.bottom,
|
this.menuPosition = MenuPosition.bottom,
|
||||||
this.mailViewButtonPosition = MenuPosition.bottom,
|
this.mailViewButtonPosition = MenuPosition.bottom,
|
||||||
this.afterMailViewAction = AfterMailViewAction.nextMessage,
|
this.afterMailViewAction = AfterMailViewAction.nextMessage,
|
||||||
|
this.prefetchMode = PrefetchMode.wifiOnly,
|
||||||
|
this.bodyCacheLimitMb = 100,
|
||||||
});
|
});
|
||||||
final MenuPosition menuPosition;
|
final MenuPosition menuPosition;
|
||||||
final MenuPosition mailViewButtonPosition;
|
final MenuPosition mailViewButtonPosition;
|
||||||
final AfterMailViewAction afterMailViewAction;
|
final AfterMailViewAction afterMailViewAction;
|
||||||
|
final PrefetchMode prefetchMode;
|
||||||
|
final int bodyCacheLimitMb;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ abstract class EmailRepository {
|
|||||||
int limit = 50,
|
int limit = 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Returns threads from the INBOX mailbox of every account, sorted by latest
|
||||||
|
/// message date descending. Inbox mailboxes are identified by role = 'inbox'.
|
||||||
|
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50});
|
||||||
|
|
||||||
/// Returns all emails belonging to [threadId] in [mailboxPath].
|
/// Returns all emails belonging to [threadId] in [mailboxPath].
|
||||||
Stream<List<Email>> observeEmailsInThread(
|
Stream<List<Email>> observeEmailsInThread(
|
||||||
String accountId,
|
String accountId,
|
||||||
|
|||||||
@@ -5,4 +5,10 @@ abstract class UserPreferencesRepository {
|
|||||||
Future<void> updateMenuPosition(MenuPosition position);
|
Future<void> updateMenuPosition(MenuPosition position);
|
||||||
Future<void> updateMailViewButtonPosition(MenuPosition position);
|
Future<void> updateMailViewButtonPosition(MenuPosition position);
|
||||||
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
|
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
|
||||||
|
Future<void> updatePrefetchMode(PrefetchMode mode);
|
||||||
|
Future<void> updateBodyCacheLimitMb(int mb);
|
||||||
|
|
||||||
|
Stream<List<String>> observeTrustedImageSenders();
|
||||||
|
Future<void> addTrustedImageSender(String senderEmail);
|
||||||
|
Future<void> removeTrustedImageSender(String senderEmail);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||||
|
|
||||||
|
/// Prefetches email bodies in the background and enforces a local cache size
|
||||||
|
/// limit by evicting the oldest cached bodies when the limit is exceeded.
|
||||||
|
class BodyCacheService {
|
||||||
|
BodyCacheService(this._db, this._accountRepo);
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
final AccountRepository _accountRepo;
|
||||||
|
|
||||||
|
static const _batchSize = 20;
|
||||||
|
|
||||||
|
Future<void> run() async {
|
||||||
|
final prefs = await (_db.select(
|
||||||
|
_db.userPreferences,
|
||||||
|
)).getSingleOrNull();
|
||||||
|
final limitMb = prefs?.bodyCacheLimitMb ?? 100;
|
||||||
|
final limitBytes = limitMb * 1024 * 1024;
|
||||||
|
|
||||||
|
await _evictIfNeeded(limitBytes);
|
||||||
|
|
||||||
|
final candidates = await _fetchCandidates();
|
||||||
|
if (candidates.isEmpty) return;
|
||||||
|
|
||||||
|
final emailRepo = EmailRepositoryImpl(_db, _accountRepo);
|
||||||
|
|
||||||
|
for (final emailId in candidates) {
|
||||||
|
final currentSize = await _getCacheSizeBytes();
|
||||||
|
if (currentSize >= limitBytes) break;
|
||||||
|
try {
|
||||||
|
await emailRepo.getEmailBody(emailId);
|
||||||
|
} catch (_) {
|
||||||
|
// Skip emails that fail to fetch.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _evictIfNeeded(int limitBytes) async {
|
||||||
|
final currentSize = await _getCacheSizeBytes();
|
||||||
|
if (currentSize <= limitBytes) return;
|
||||||
|
|
||||||
|
final bodies = await (_db.select(_db.emailBodies)
|
||||||
|
..where((t) => t.cachedAt.isNotNull())
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.cachedAt)]))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
var remaining = currentSize;
|
||||||
|
for (final body in bodies) {
|
||||||
|
if (remaining <= limitBytes) break;
|
||||||
|
final bodySize =
|
||||||
|
(body.textBody?.length ?? 0) + (body.htmlBody?.length ?? 0);
|
||||||
|
await (_db.delete(_db.emailBodies)
|
||||||
|
..where((t) => t.emailId.equals(body.emailId)))
|
||||||
|
.go();
|
||||||
|
remaining -= bodySize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> _getCacheSizeBytes() async {
|
||||||
|
final result = await _db
|
||||||
|
.customSelect(
|
||||||
|
"SELECT COALESCE(SUM(LENGTH(COALESCE(text_body, '')) + LENGTH(COALESCE(html_body, ''))), 0) AS total FROM email_bodies",
|
||||||
|
)
|
||||||
|
.getSingle();
|
||||||
|
return result.read<int>('total');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> _fetchCandidates() async {
|
||||||
|
final rows = await _db.customSelect(
|
||||||
|
'SELECT e.id FROM emails e '
|
||||||
|
'LEFT JOIN email_bodies eb ON eb.email_id = e.id '
|
||||||
|
'WHERE eb.email_id IS NULL '
|
||||||
|
'ORDER BY e.received_at DESC '
|
||||||
|
'LIMIT ?',
|
||||||
|
variables: [Variable.withInt(_batchSize)],
|
||||||
|
).get();
|
||||||
|
return rows.map((r) => r.read<String>('id')).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,9 @@ import 'package:path/path.dart' as p;
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/account.dart' as model;
|
import 'package:sharedinbox/core/models/account.dart' as model;
|
||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/services/body_cache_service.dart';
|
||||||
import 'package:sharedinbox/core/services/notification_service.dart';
|
import 'package:sharedinbox/core/services/notification_service.dart';
|
||||||
import 'package:sharedinbox/data/db/database.dart';
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
@@ -21,6 +23,7 @@ import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
|||||||
import 'package:workmanager/workmanager.dart';
|
import 'package:workmanager/workmanager.dart';
|
||||||
|
|
||||||
const _kTaskName = 'si_bg_sync';
|
const _kTaskName = 'si_bg_sync';
|
||||||
|
const _kPrefetchTaskName = 'si_bg_prefetch';
|
||||||
const _kResourceType = 'background_check';
|
const _kResourceType = 'background_check';
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
@@ -28,9 +31,13 @@ void callbackDispatcher() {
|
|||||||
// Required so that path_provider and other plugins are available in this
|
// Required so that path_provider and other plugins are available in this
|
||||||
// background isolate (issue #192).
|
// background isolate (issue #192).
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
Workmanager().executeTask((_, __) async {
|
Workmanager().executeTask((taskName, __) async {
|
||||||
try {
|
try {
|
||||||
await _doBackgroundSync();
|
if (taskName == _kPrefetchTaskName) {
|
||||||
|
await _doBodyPrefetch();
|
||||||
|
} else {
|
||||||
|
await _doBackgroundSync();
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -55,6 +62,31 @@ Future<void> registerBackgroundSync() async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Registers (or cancels) the body-prefetch WorkManager task based on [mode].
|
||||||
|
/// Call on app startup and whenever the user changes the prefetch preference.
|
||||||
|
Future<void> registerBodyPrefetchTask(PrefetchMode mode) async {
|
||||||
|
try {
|
||||||
|
if (mode == PrefetchMode.disabled) {
|
||||||
|
await Workmanager().cancelByUniqueName(_kPrefetchTaskName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final networkType = mode == PrefetchMode.wifiOnly
|
||||||
|
? NetworkType.unmetered
|
||||||
|
: NetworkType.connected;
|
||||||
|
await Workmanager().registerPeriodicTask(
|
||||||
|
_kPrefetchTaskName,
|
||||||
|
_kPrefetchTaskName,
|
||||||
|
frequency: const Duration(hours: 1),
|
||||||
|
constraints: Constraints(networkType: networkType),
|
||||||
|
existingWorkPolicy: ExistingPeriodicWorkPolicy.replace,
|
||||||
|
);
|
||||||
|
} on PlatformException {
|
||||||
|
// Ignore — WorkManager unavailable.
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Ignore — plugin not registered.
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _doBackgroundSync() async {
|
Future<void> _doBackgroundSync() async {
|
||||||
final dir = await getApplicationSupportDirectory();
|
final dir = await getApplicationSupportDirectory();
|
||||||
final db = AppDatabase(
|
final db = AppDatabase(
|
||||||
@@ -76,6 +108,22 @@ Future<void> _doBackgroundSync() async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _doBodyPrefetch() async {
|
||||||
|
final dir = await getApplicationSupportDirectory();
|
||||||
|
final db = AppDatabase(
|
||||||
|
NativeDatabase(File(p.join(dir.path, 'sharedinbox.db'))),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final accountRepo = AccountRepositoryImpl(
|
||||||
|
db,
|
||||||
|
const FlutterSecureStorageImpl(),
|
||||||
|
);
|
||||||
|
await BodyCacheService(db, accountRepo).run();
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _checkAccount(
|
Future<void> _checkAccount(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
AccountRepository accountRepo,
|
AccountRepository accountRepo,
|
||||||
|
|||||||
@@ -307,6 +307,17 @@ class LocalSieveApplied extends Table {
|
|||||||
Set<Column> get primaryKey => {accountId, messageId};
|
Set<Column> get primaryKey => {accountId, messageId};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Senders for whom remote images are loaded automatically.
|
||||||
|
/// Per-device/per-user — not tied to any email account.
|
||||||
|
@DataClassName('ImageTrustedSenderRow')
|
||||||
|
class ImageTrustedSenders extends Table {
|
||||||
|
TextColumn get senderEmail => text()();
|
||||||
|
DateTimeColumn get addedAt => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {senderEmail};
|
||||||
|
}
|
||||||
|
|
||||||
/// App-wide user preferences, stored as a singleton row (id always 1).
|
/// App-wide user preferences, stored as a singleton row (id always 1).
|
||||||
@DataClassName('UserPreferencesRow')
|
@DataClassName('UserPreferencesRow')
|
||||||
class UserPreferences extends Table {
|
class UserPreferences extends Table {
|
||||||
@@ -319,6 +330,12 @@ class UserPreferences extends Table {
|
|||||||
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
|
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
|
||||||
TextColumn get afterMailViewAction =>
|
TextColumn get afterMailViewAction =>
|
||||||
text().withDefault(const Constant('nextMessage'))();
|
text().withDefault(const Constant('nextMessage'))();
|
||||||
|
// Added in schema v38: 'disabled' | 'wifiOnly' (default) | 'always'
|
||||||
|
TextColumn get prefetchMode =>
|
||||||
|
text().withDefault(const Constant('wifiOnly'))();
|
||||||
|
// Added in schema v38: max cache size for offline email bodies, in megabytes.
|
||||||
|
IntColumn get bodyCacheLimitMb =>
|
||||||
|
integer().withDefault(const Constant(100))();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
@@ -345,6 +362,7 @@ class UserPreferences extends Table {
|
|||||||
LocalSieveApplied,
|
LocalSieveApplied,
|
||||||
ShareKeys,
|
ShareKeys,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
|
ImageTrustedSenders,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
@@ -611,6 +629,16 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
userPreferences.afterMailViewAction,
|
userPreferences.afterMailViewAction,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (from < 37) {
|
||||||
|
await m.createTable(imageTrustedSenders);
|
||||||
|
}
|
||||||
|
if (from >= 34 && from < 38) {
|
||||||
|
await m.addColumn(userPreferences, userPreferences.prefetchMode);
|
||||||
|
await m.addColumn(
|
||||||
|
userPreferences,
|
||||||
|
userPreferences.bodyCacheLimitMb,
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,26 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
.map((rows) => rows.map(_threadRowToModel).toList());
|
.map((rows) => rows.map(_threadRowToModel).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<model.EmailThread>> observeAllInboxThreads({int limit = 50}) {
|
||||||
|
final query = _db.select(_db.threads).join([
|
||||||
|
innerJoin(
|
||||||
|
_db.mailboxes,
|
||||||
|
_db.mailboxes.accountId.equalsExp(_db.threads.accountId) &
|
||||||
|
_db.mailboxes.path.equalsExp(_db.threads.mailboxPath),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
query
|
||||||
|
..where(_db.mailboxes.role.equals('inbox'))
|
||||||
|
..orderBy([OrderingTerm.desc(_db.threads.latestDate)])
|
||||||
|
..limit(limit);
|
||||||
|
return query.watch().map(
|
||||||
|
(rows) => rows
|
||||||
|
.map((row) => _threadRowToModel(row.readTable(_db.threads)))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
model.EmailThread _threadRowToModel(ThreadRow row) {
|
model.EmailThread _threadRowToModel(ThreadRow row) {
|
||||||
List<model.EmailAddress> parseAddresses(String json) {
|
List<model.EmailAddress> parseAddresses(String json) {
|
||||||
final list = jsonDecode(json) as List<dynamic>;
|
final list = jsonDecode(json) as List<dynamic>;
|
||||||
@@ -2963,6 +2983,20 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}) async {
|
}) async {
|
||||||
if (query.length < 2) return [];
|
if (query.length < 2) return [];
|
||||||
final pattern = '%${query.toLowerCase()}%';
|
final pattern = '%${query.toLowerCase()}%';
|
||||||
|
|
||||||
|
// Addresses we deliberately wrote to (sent folder) should appear before
|
||||||
|
// addresses that happened to email us (inbox/other folders).
|
||||||
|
final sentMailboxes = await (_db.select(_db.mailboxes)
|
||||||
|
..where((t) {
|
||||||
|
Expression<bool> cond = t.role.equals('sent');
|
||||||
|
if (accountId != null) {
|
||||||
|
cond = t.accountId.equals(accountId) & cond;
|
||||||
|
}
|
||||||
|
return cond;
|
||||||
|
}))
|
||||||
|
.get();
|
||||||
|
final sentPaths = {for (final m in sentMailboxes) m.path};
|
||||||
|
|
||||||
final rows = await (_db.select(_db.emails)
|
final rows = await (_db.select(_db.emails)
|
||||||
..where((t) {
|
..where((t) {
|
||||||
Expression<bool> cond = const Constant(true);
|
Expression<bool> cond = const Constant(true);
|
||||||
@@ -2977,11 +3011,22 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
..limit(100))
|
..limit(100))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
|
// Two passes: sent-folder rows first (prioritise recipients we chose),
|
||||||
|
// then other rows (senders who contacted us).
|
||||||
|
final sortedRows = [
|
||||||
|
...rows.where((r) => sentPaths.contains(r.mailboxPath)),
|
||||||
|
...rows.where((r) => !sentPaths.contains(r.mailboxPath)),
|
||||||
|
];
|
||||||
|
|
||||||
final seen = <String>{};
|
final seen = <String>{};
|
||||||
final results = <model.EmailAddress>[];
|
final results = <model.EmailAddress>[];
|
||||||
final lowerQuery = query.toLowerCase();
|
final lowerQuery = query.toLowerCase();
|
||||||
for (final row in rows) {
|
for (final row in sortedRows) {
|
||||||
for (final jsonStr in [row.fromJson, row.toAddresses, row.ccJson]) {
|
final isSent = sentPaths.contains(row.mailboxPath);
|
||||||
|
final fields = isSent
|
||||||
|
? [row.toAddresses, row.ccJson, row.fromJson]
|
||||||
|
: [row.fromJson, row.toAddresses, row.ccJson];
|
||||||
|
for (final jsonStr in fields) {
|
||||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||||
for (final e in list) {
|
for (final e in list) {
|
||||||
final map = e as Map<String, dynamic>;
|
final map = e as Map<String, dynamic>;
|
||||||
|
|||||||
@@ -50,6 +50,51 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updatePrefetchMode(pref.PrefetchMode mode) async {
|
||||||
|
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||||
|
UserPreferencesCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
prefetchMode: Value(mode.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateBodyCacheLimitMb(int mb) async {
|
||||||
|
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||||
|
UserPreferencesCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
bodyCacheLimitMb: Value(mb),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<String>> observeTrustedImageSenders() {
|
||||||
|
return (_db.select(_db.imageTrustedSenders)
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.addedAt)]))
|
||||||
|
.watch()
|
||||||
|
.map((rows) => rows.map((r) => r.senderEmail).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> addTrustedImageSender(String senderEmail) async {
|
||||||
|
await _db.into(_db.imageTrustedSenders).insertOnConflictUpdate(
|
||||||
|
ImageTrustedSendersCompanion(
|
||||||
|
senderEmail: Value(senderEmail.toLowerCase()),
|
||||||
|
addedAt: Value(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> removeTrustedImageSender(String senderEmail) async {
|
||||||
|
await (_db.delete(_db.imageTrustedSenders)
|
||||||
|
..where((t) => t.senderEmail.equals(senderEmail.toLowerCase())))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
static pref.UserPreferences _rowToModel(UserPreferencesRow? row) {
|
static pref.UserPreferences _rowToModel(UserPreferencesRow? row) {
|
||||||
if (row == null) return const pref.UserPreferences();
|
if (row == null) return const pref.UserPreferences();
|
||||||
return pref.UserPreferences(
|
return pref.UserPreferences(
|
||||||
@@ -65,6 +110,8 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
|||||||
(e) => e.name == row.afterMailViewAction,
|
(e) => e.name == row.afterMailViewAction,
|
||||||
orElse: () => pref.AfterMailViewAction.nextMessage,
|
orElse: () => pref.AfterMailViewAction.nextMessage,
|
||||||
),
|
),
|
||||||
|
prefetchMode: pref.PrefetchMode.fromString(row.prefetchMode),
|
||||||
|
bodyCacheLimitMb: row.bodyCacheLimitMb,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,10 +211,38 @@ class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
|
|||||||
repo.getEmailBody(_emailId),
|
repo.getEmailBody(_emailId),
|
||||||
]);
|
]);
|
||||||
unawaited(repo.setFlag(_emailId, seen: true));
|
unawaited(repo.setFlag(_emailId, seen: true));
|
||||||
|
final header = results[0] as Email?;
|
||||||
|
if (header != null) {
|
||||||
|
unawaited(_prefetchNextEmailBody(repo, header));
|
||||||
|
}
|
||||||
return (results[0] as Email?, results[1] as EmailBody);
|
return (results[0] as Email?, results[1] as EmailBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _prefetchNextEmailBody(
|
||||||
|
EmailRepository repo,
|
||||||
|
Email header,
|
||||||
|
) async {
|
||||||
|
final prefs = ref.read(userPreferencesProvider).value;
|
||||||
|
final action =
|
||||||
|
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
|
||||||
|
if (action != AfterMailViewAction.nextMessage) return;
|
||||||
|
|
||||||
|
final threads =
|
||||||
|
await repo.observeThreads(header.accountId, header.mailboxPath).first;
|
||||||
|
final currentIndex = threads.indexWhere(
|
||||||
|
(t) => t.emailIds.contains(_emailId),
|
||||||
|
);
|
||||||
|
if (currentIndex < 0 || currentIndex + 1 >= threads.length) return;
|
||||||
|
|
||||||
|
final nextId = threads[currentIndex + 1].latestEmailId;
|
||||||
|
await repo.getEmailBody(nextId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final allAccountsProvider = StreamProvider<List<model.Account>>((ref) {
|
||||||
|
return ref.watch(accountRepositoryProvider).observeAccounts();
|
||||||
|
});
|
||||||
|
|
||||||
final accountByIdProvider =
|
final accountByIdProvider =
|
||||||
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
|
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
|
||||||
return ref.watch(accountRepositoryProvider).observeAccounts().map(
|
return ref.watch(accountRepositoryProvider).observeAccounts().map(
|
||||||
@@ -247,3 +275,10 @@ final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>((
|
|||||||
) {
|
) {
|
||||||
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
|
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final trustedImageSendersProvider =
|
||||||
|
StreamProvider.autoDispose<List<String>>((ref) {
|
||||||
|
return ref
|
||||||
|
.watch(userPreferencesRepositoryProvider)
|
||||||
|
.observeTrustedImageSenders();
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,19 +5,30 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_riverpod/misc.dart' show Override;
|
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/services/notification_service.dart';
|
import 'package:sharedinbox/core/services/notification_service.dart';
|
||||||
import 'package:sharedinbox/core/sync/background_sync.dart';
|
import 'package:sharedinbox/core/sync/background_sync.dart';
|
||||||
import 'package:sharedinbox/data/db/database.dart';
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/router.dart';
|
import 'package:sharedinbox/ui/router.dart';
|
||||||
import 'package:sharedinbox/ui/screens/crash_screen.dart';
|
import 'package:sharedinbox/ui/screens/crash_screen.dart';
|
||||||
|
import 'package:stack_trace/stack_trace.dart' as stack_trace;
|
||||||
|
|
||||||
void main({List<Override> overrides = const []}) async {
|
void main({List<Override> overrides = const []}) {
|
||||||
unawaited(
|
unawaited(
|
||||||
runZonedGuarded(
|
runZonedGuarded(
|
||||||
() async {
|
() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Dart's async machinery propagates stack traces in chain format
|
||||||
|
// (with '===== asynchronous gap =====' separators). Flutter's
|
||||||
|
// StackFrame parser asserts on those lines, so strip them first.
|
||||||
|
FlutterError.demangleStackTrace = (StackTrace s) {
|
||||||
|
if (s is stack_trace.Chain) return s.toTrace().vmTrace;
|
||||||
|
if (s is stack_trace.Trace) return s.vmTrace;
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
// Catch errors during build (e.g. layout exceptions) and show CrashScreen.
|
// Catch errors during build (e.g. layout exceptions) and show CrashScreen.
|
||||||
ErrorWidget.builder = (details) => CrashScreen(
|
ErrorWidget.builder = (details) => CrashScreen(
|
||||||
exception: details.exception,
|
exception: details.exception,
|
||||||
@@ -39,19 +50,35 @@ void main({List<Override> overrides = const []}) async {
|
|||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
await initNotifications();
|
await initNotifications();
|
||||||
await registerBackgroundSync();
|
await registerBackgroundSync();
|
||||||
|
await _registerPrefetchTaskFromStoredPrefs();
|
||||||
}
|
}
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
|
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
(error, stack) {
|
// This handler runs in the parent zone — runApp cannot be called here.
|
||||||
// Catch unhandled async errors.
|
// Framework errors are already handled by FlutterError.onError above.
|
||||||
runApp(CrashScreen(exception: error, stackTrace: stack));
|
(error, stack) => FlutterError.reportError(
|
||||||
},
|
FlutterErrorDetails(exception: error, stack: stack),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads the stored prefetch preference and registers the WorkManager task
|
||||||
|
/// with the correct network constraint for it. Opens and immediately closes
|
||||||
|
/// a temporary DB connection; safe because initDatabasePath() has already run.
|
||||||
|
Future<void> _registerPrefetchTaskFromStoredPrefs() async {
|
||||||
|
final db = AppDatabase();
|
||||||
|
try {
|
||||||
|
final row = await db.select(db.userPreferences).getSingleOrNull();
|
||||||
|
final mode = PrefetchMode.fromString(row?.prefetchMode);
|
||||||
|
await registerBodyPrefetchTask(mode);
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class SharedInboxApp extends ConsumerStatefulWidget {
|
class SharedInboxApp extends ConsumerStatefulWidget {
|
||||||
const SharedInboxApp({super.key});
|
const SharedInboxApp({super.key});
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
|
|||||||
import 'package:sharedinbox/ui/screens/account_send_screen.dart';
|
import 'package:sharedinbox/ui/screens/account_send_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
|
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
|
import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/bug_report_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/combined_inbox_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/compose_screen.dart';
|
import 'package:sharedinbox/ui/screens/compose_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/edit_account_screen.dart';
|
import 'package:sharedinbox/ui/screens/edit_account_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
|
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
|
||||||
@@ -24,11 +26,15 @@ import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
|||||||
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
||||||
|
|
||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
initialLocation: '/accounts',
|
initialLocation: '/inbox',
|
||||||
routes: [
|
routes: [
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
builder: (ctx, state, child) => UndoShell(child: child),
|
builder: (ctx, state, child) => UndoShell(child: child),
|
||||||
routes: [
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/inbox',
|
||||||
|
builder: (ctx, state) => const CombinedInboxScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/accounts',
|
path: '/accounts',
|
||||||
builder: (ctx, state) => const AccountListScreen(),
|
builder: (ctx, state) => const AccountListScreen(),
|
||||||
@@ -164,6 +170,12 @@ final router = GoRouter(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/bug-report',
|
||||||
|
builder: (ctx, state) => BugReportScreen(
|
||||||
|
emailId: state.uri.queryParameters['emailId'],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
@@ -197,22 +198,30 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.copy),
|
icon: const Icon(Icons.copy),
|
||||||
label: const Text('Copy to clipboard'),
|
label: const Text('Copy info'),
|
||||||
onPressed: () => unawaited(
|
onPressed: () => unawaited(
|
||||||
_copyToClipboard(context, imapCount, jmapCount),
|
_copyToClipboard(context, imapCount, jmapCount),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton.icon(
|
child: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.bug_report),
|
icon: const Icon(Icons.bug_report_outlined),
|
||||||
label: const Text('Create issue'),
|
label: const Text('Public issue'),
|
||||||
onPressed: () => unawaited(
|
onPressed: () => unawaited(
|
||||||
_createIssue(context, imapCount, jmapCount),
|
_createIssue(context, imapCount, jmapCount),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
icon: const Icon(Icons.feedback_outlined),
|
||||||
|
label: const Text('Report bug'),
|
||||||
|
onPressed: () => context.push('/bug-report'),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,635 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/utils/about_markdown.dart';
|
||||||
|
|
||||||
|
const _bugReportApiUrl = String.fromEnvironment(
|
||||||
|
'BUG_REPORT_API_URL',
|
||||||
|
defaultValue: 'https://sharedinbox.de/api/v1/bug-reports',
|
||||||
|
);
|
||||||
|
|
||||||
|
class BugReportScreen extends ConsumerStatefulWidget {
|
||||||
|
const BugReportScreen({super.key, this.emailId});
|
||||||
|
|
||||||
|
final String? emailId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<BugReportScreen> createState() => _BugReportScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BugReportScreenState extends ConsumerState<BugReportScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _descriptionController = TextEditingController();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
|
||||||
|
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
||||||
|
late final Future<String?> _deviceModelFuture = getDeviceModel();
|
||||||
|
|
||||||
|
final List<PlatformFile> _attachments = [];
|
||||||
|
bool _includeEmail = false;
|
||||||
|
bool _includeSyncLog = false;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
Email? _attachedEmail;
|
||||||
|
List<Account> _accounts = [];
|
||||||
|
String? _selectedAccountId;
|
||||||
|
String? _deviceModel;
|
||||||
|
bool _loadingEmail = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
unawaited(_loadInitialData());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_descriptionController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadInitialData() async {
|
||||||
|
setState(() => _loadingEmail = true);
|
||||||
|
try {
|
||||||
|
_deviceModel = await _deviceModelFuture;
|
||||||
|
_accounts =
|
||||||
|
await ref.read(accountRepositoryProvider).observeAccounts().first;
|
||||||
|
|
||||||
|
if (widget.emailId != null) {
|
||||||
|
final email =
|
||||||
|
await ref.read(emailRepositoryProvider).getEmail(widget.emailId!);
|
||||||
|
if (mounted && email != null) {
|
||||||
|
_attachedEmail = email;
|
||||||
|
_selectedAccountId = email.accountId;
|
||||||
|
final fromStr =
|
||||||
|
email.from.isNotEmpty ? email.from.first.toString() : 'unknown';
|
||||||
|
final subjectStr = email.subject ?? '(no subject)';
|
||||||
|
_descriptionController.text =
|
||||||
|
'Problem with email from $fromStr: "$subjectStr"\n\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedAccountId == null && _accounts.isNotEmpty) {
|
||||||
|
_selectedAccountId = _accounts.first.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedAccountId != null) {
|
||||||
|
final matching =
|
||||||
|
_accounts.where((a) => a.id == _selectedAccountId).firstOrNull;
|
||||||
|
if (matching != null) {
|
||||||
|
_emailController.text = matching.email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _loadingEmail = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int get _totalAttachmentSize {
|
||||||
|
return _attachments.fold(0, (sum, f) => sum + f.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatSize(int bytes) {
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickAttachments() async {
|
||||||
|
try {
|
||||||
|
final result = await FilePicker.pickFiles();
|
||||||
|
if (result == null) return;
|
||||||
|
final newFiles =
|
||||||
|
result.files.where((PlatformFile f) => f.path != null).toList();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_attachments.addAll(newFiles);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to pick files: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeAttachment(int index) {
|
||||||
|
setState(() {
|
||||||
|
_attachments.removeAt(index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _serializeSyncLogs(List<SyncLogEntry> entries) {
|
||||||
|
final sb = StringBuffer();
|
||||||
|
for (final entry in entries.take(50)) {
|
||||||
|
sb.writeln('ID: ${entry.id}');
|
||||||
|
sb.writeln('Started: ${entry.startedAt.toIso8601String()}');
|
||||||
|
sb.writeln('Finished: ${entry.finishedAt.toIso8601String()}');
|
||||||
|
sb.writeln('Result: ${entry.result}');
|
||||||
|
if (entry.errorMessage != null) {
|
||||||
|
sb.writeln('Error: ${entry.errorMessage}');
|
||||||
|
}
|
||||||
|
if (entry.stackTrace != null) {
|
||||||
|
sb.writeln('StackTrace:\n${entry.stackTrace}');
|
||||||
|
}
|
||||||
|
sb.writeln('Protocol: ${entry.protocol}');
|
||||||
|
sb.writeln(
|
||||||
|
'Fetched: ${entry.emailsFetched}, Skipped: ${entry.emailsSkipped}',
|
||||||
|
);
|
||||||
|
if (entry.protocolLog != null) {
|
||||||
|
sb.writeln('Protocol Log:\n${entry.protocolLog}');
|
||||||
|
}
|
||||||
|
sb.writeln('---');
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitReport() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
final totalSize = _totalAttachmentSize;
|
||||||
|
if (totalSize > 20 * 1024 * 1024) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Total attachments size exceeds the 20 MB limit. Please remove some files.',
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final client = ref.read(httpClientProvider);
|
||||||
|
final uri = Uri.parse(_bugReportApiUrl);
|
||||||
|
final request = http.MultipartRequest('POST', uri);
|
||||||
|
|
||||||
|
// Description
|
||||||
|
request.fields['description'] = _descriptionController.text;
|
||||||
|
|
||||||
|
// Email Data if from email view
|
||||||
|
if (_attachedEmail != null) {
|
||||||
|
final emailMap = {
|
||||||
|
'id': _attachedEmail!.id,
|
||||||
|
'subject': _attachedEmail!.subject,
|
||||||
|
'from': _attachedEmail!.from.map((e) => e.toString()).toList(),
|
||||||
|
'date': _attachedEmail!.sentAt?.toIso8601String() ??
|
||||||
|
_attachedEmail!.receivedAt.toIso8601String(),
|
||||||
|
'preview': _attachedEmail!.preview,
|
||||||
|
};
|
||||||
|
request.fields['email_data'] = jsonEncode(emailMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contact Email
|
||||||
|
if (_includeEmail) {
|
||||||
|
request.fields['email'] = _emailController.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// About Info
|
||||||
|
PackageInfo? pkg;
|
||||||
|
try {
|
||||||
|
pkg = await _packageInfoFuture;
|
||||||
|
} catch (_) {}
|
||||||
|
final imapCount =
|
||||||
|
_accounts.where((a) => a.type == AccountType.imap).length;
|
||||||
|
final jmapCount =
|
||||||
|
_accounts.where((a) => a.type == AccountType.jmap).length;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
final aboutInfo = buildAboutMarkdown(
|
||||||
|
context: context,
|
||||||
|
pkg: pkg,
|
||||||
|
imapCount: imapCount,
|
||||||
|
jmapCount: jmapCount,
|
||||||
|
deviceModel: _deviceModel,
|
||||||
|
);
|
||||||
|
request.fields['about_info'] = aboutInfo;
|
||||||
|
|
||||||
|
// Sync Log
|
||||||
|
if (_includeSyncLog && _selectedAccountId != null) {
|
||||||
|
final syncLogs = await ref
|
||||||
|
.read(syncLogRepositoryProvider)
|
||||||
|
.observeSyncLogs(_selectedAccountId!)
|
||||||
|
.first;
|
||||||
|
request.fields['sync_log'] = _serializeSyncLogs(syncLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
for (final file in _attachments) {
|
||||||
|
final multipartFile = await http.MultipartFile.fromPath(
|
||||||
|
'attachments[]',
|
||||||
|
file.path!,
|
||||||
|
filename: file.name,
|
||||||
|
);
|
||||||
|
request.files.add(multipartFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
final streamedResponse = await client.send(request);
|
||||||
|
final response = await http.Response.fromStream(streamedResponse);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (response.statusCode == 201) {
|
||||||
|
final resData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final reportId = resData['id'] as String;
|
||||||
|
_showSuccessDialog(reportId);
|
||||||
|
} else if (response.statusCode == 429) {
|
||||||
|
final retryAfter = response.headers['retry-after'] ?? '6';
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Rate limited. Please retry in $retryAfter seconds.'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
String errorMsg =
|
||||||
|
'Failed to submit report. Server returned status: ${response.statusCode}';
|
||||||
|
try {
|
||||||
|
final resData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
if (resData['error'] != null) {
|
||||||
|
errorMsg = resData['error'] as String;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(errorMsg),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('An error occurred: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSuccessDialog(String reportId) {
|
||||||
|
unawaited(
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Bug Report Submitted'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: ListBody(
|
||||||
|
children: [
|
||||||
|
const Text('Thank you for helping us improve SharedInbox!'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Your Report ID is:\n$reportId',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Your report is handled confidentially and has not been posted to the public issue tracker.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(); // Dismiss dialog
|
||||||
|
context.pop(); // Go back to previous screen
|
||||||
|
},
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final totalSize = _totalAttachmentSize;
|
||||||
|
const sizeLimit = 20 * 1024 * 1024;
|
||||||
|
final approachingLimit = totalSize > 15 * 1024 * 1024;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Report a Bug'),
|
||||||
|
),
|
||||||
|
body: _loadingEmail
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
children: [
|
||||||
|
// Confidentiality info card
|
||||||
|
Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: theme.colorScheme.secondaryContainer
|
||||||
|
.withValues(alpha: 0.4),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: BorderSide(
|
||||||
|
color:
|
||||||
|
theme.colorScheme.secondary.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.lock_outline,
|
||||||
|
color: theme.colorScheme.secondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Your report is handled confidentially and will not be posted to the public issue tracker.',
|
||||||
|
style: TextStyle(height: 1.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Description Text Field
|
||||||
|
TextFormField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
autofocus: true,
|
||||||
|
maxLines: 8,
|
||||||
|
minLines: 4,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'What went wrong?',
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
helperText:
|
||||||
|
'Please describe the problem and how to reproduce it.',
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Please enter a description.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Email info chip if email is attached
|
||||||
|
if (_attachedEmail != null) ...[
|
||||||
|
Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12.0,
|
||||||
|
vertical: 8.0,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.email_outlined,
|
||||||
|
size: 20,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'The current email metadata will be attached automatically.',
|
||||||
|
style: TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Attachments Section
|
||||||
|
Text(
|
||||||
|
'Attachments',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _submitting ? null : _pickAttachments,
|
||||||
|
icon: const Icon(Icons.add_a_photo_outlined),
|
||||||
|
label: const Text('Add screenshots'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Screenshots help us understand the problem faster.',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_attachments.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: _attachments.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final file = _attachments[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8.0),
|
||||||
|
child: InputChip(
|
||||||
|
label: Text(
|
||||||
|
'${file.name} (${_formatSize(file.size)})',
|
||||||
|
),
|
||||||
|
onDeleted: _submitting
|
||||||
|
? null
|
||||||
|
: () => _removeAttachment(index),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Total Attachment Size: ${_formatSize(totalSize)} / ${_formatSize(sizeLimit)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: totalSize > sizeLimit
|
||||||
|
? Colors.red
|
||||||
|
: approachingLimit
|
||||||
|
? Colors.orange
|
||||||
|
: Colors.grey,
|
||||||
|
fontWeight: approachingLimit
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (totalSize > sizeLimit) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Email opt-in
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Include my email for follow-up'),
|
||||||
|
value: _includeEmail,
|
||||||
|
onChanged: _submitting
|
||||||
|
? null
|
||||||
|
: (val) {
|
||||||
|
setState(() => _includeEmail = val ?? false);
|
||||||
|
},
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
if (_includeEmail) ...[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16.0),
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Contact Email Address',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (_includeEmail &&
|
||||||
|
(value == null || value.trim().isEmpty)) {
|
||||||
|
return 'Please enter an email address.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Sync log opt-in
|
||||||
|
if (_selectedAccountId != null) ...[
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Include recent sync log'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Helps diagnose connection and protocol issues.',
|
||||||
|
),
|
||||||
|
value: _includeSyncLog,
|
||||||
|
onChanged: _submitting
|
||||||
|
? null
|
||||||
|
: (val) {
|
||||||
|
setState(() => _includeSyncLog = val ?? false);
|
||||||
|
},
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
|
||||||
|
// System info section
|
||||||
|
FutureBuilder<PackageInfo>(
|
||||||
|
future: _packageInfoFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final imapCount = _accounts
|
||||||
|
.where((a) => a.type == AccountType.imap)
|
||||||
|
.length;
|
||||||
|
final jmapCount = _accounts
|
||||||
|
.where((a) => a.type == AccountType.jmap)
|
||||||
|
.length;
|
||||||
|
final aboutMd = buildAboutMarkdown(
|
||||||
|
context: context,
|
||||||
|
pkg: snapshot.data,
|
||||||
|
imapCount: imapCount,
|
||||||
|
jmapCount: jmapCount,
|
||||||
|
deviceModel: _deviceModel,
|
||||||
|
);
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: BorderSide(
|
||||||
|
color: theme.dividerColor.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: ExpansionTile(
|
||||||
|
title: const Text(
|
||||||
|
'System Info (attached automatically)',
|
||||||
|
style: TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: MarkdownBody(data: aboutMd),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Submit Button
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _submitting ? null : _submitReport,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||||
|
child: _submitting
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text(
|
||||||
|
'Send Bug Report',
|
||||||
|
style: TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
|
||||||
|
final _dateFmt = DateFormat('MMM d');
|
||||||
|
final _formattedDates = <int, String>{};
|
||||||
|
|
||||||
|
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
|
||||||
|
|
||||||
|
String _fmtDate(DateTime dt) =>
|
||||||
|
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
|
||||||
|
|
||||||
|
class CombinedInboxScreen extends ConsumerStatefulWidget {
|
||||||
|
const CombinedInboxScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CombinedInboxScreen> createState() =>
|
||||||
|
_CombinedInboxScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
||||||
|
static const _pageSize = 50;
|
||||||
|
int _limit = _pageSize;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final accountsAsync = ref.watch(allAccountsProvider);
|
||||||
|
|
||||||
|
return accountsAsync.when(
|
||||||
|
loading: () => const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
error: (e, _) => Scaffold(
|
||||||
|
body: Center(child: Text('Error: $e')),
|
||||||
|
),
|
||||||
|
data: (accounts) {
|
||||||
|
if (accounts.isEmpty) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (context.mounted) context.go('/accounts');
|
||||||
|
});
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final accountNames = {
|
||||||
|
for (final a in accounts) a.id: a.displayName,
|
||||||
|
};
|
||||||
|
final showAccount = accounts.length > 1;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: _buildAppBar(accounts),
|
||||||
|
drawer: _buildDrawer(context, accounts),
|
||||||
|
body: _buildBody(accountNames, showAccount),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () => context.push('/compose'),
|
||||||
|
child: const Icon(Icons.edit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PreferredSizeWidget _buildAppBar(List<Account> accounts) {
|
||||||
|
return AppBar(
|
||||||
|
title: const Text('Combined Inbox'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
|
tooltip: 'Search',
|
||||||
|
onPressed: () => context.push('/search'),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.sync),
|
||||||
|
tooltip: 'Sync all',
|
||||||
|
onPressed: () {
|
||||||
|
for (final a in accounts) {
|
||||||
|
ref.read(syncManagerProvider).syncNow(a.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDrawer(BuildContext context, List<Account> accounts) {
|
||||||
|
return Drawer(
|
||||||
|
child: ListView(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
children: [
|
||||||
|
const DrawerHeader(
|
||||||
|
decoration: BoxDecoration(color: Colors.blueGrey),
|
||||||
|
child: Text(
|
||||||
|
'sharedinbox.de',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.manage_accounts),
|
||||||
|
title: const Text('Accounts'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.go('/accounts');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.person_add),
|
||||||
|
title: const Text('Add account'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
unawaited(context.push('/accounts/add'));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
for (final account in accounts)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.inbox),
|
||||||
|
title: Text(account.displayName),
|
||||||
|
subtitle: Text(account.email),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
unawaited(context.push('/accounts/${account.id}/mailboxes'));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.settings),
|
||||||
|
title: const Text('Preferences'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
unawaited(context.push('/accounts/preferences'));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.history),
|
||||||
|
title: const Text('Undo Log'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
unawaited(context.push('/accounts/undo-log'));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.info_outline),
|
||||||
|
title: const Text('About'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
unawaited(context.push('/accounts/about'));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(Map<String, String> accountNames, bool showAccount) {
|
||||||
|
final emailRepo = ref.watch(emailRepositoryProvider);
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
final accounts = ref.read(allAccountsProvider).value ?? [];
|
||||||
|
for (final a in accounts) {
|
||||||
|
ref.read(syncManagerProvider).syncNow(a.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: StreamBuilder<List<EmailThread>>(
|
||||||
|
stream: emailRepo.observeAllInboxThreads(limit: _limit),
|
||||||
|
builder: (ctx, snap) {
|
||||||
|
if (!snap.hasData) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
final threads = snap.data!;
|
||||||
|
if (threads.isEmpty) {
|
||||||
|
return ListView(
|
||||||
|
children: const [
|
||||||
|
SizedBox(
|
||||||
|
height: 300,
|
||||||
|
child: Center(child: Text('No emails')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _buildThreadList(threads, accountNames, showAccount);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildThreadList(
|
||||||
|
List<EmailThread> threads,
|
||||||
|
Map<String, String> accountNames,
|
||||||
|
bool showAccount,
|
||||||
|
) {
|
||||||
|
final hasMore = threads.length == _limit;
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: threads.length + (hasMore ? 1 : 0),
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
if (i == threads.length) {
|
||||||
|
return TextButton(
|
||||||
|
onPressed: () => setState(() => _limit += _pageSize),
|
||||||
|
child: const Text('Load more'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _buildThreadTile(ctx, threads[i], accountNames, showAccount);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildThreadTile(
|
||||||
|
BuildContext ctx,
|
||||||
|
EmailThread t,
|
||||||
|
Map<String, String> accountNames,
|
||||||
|
bool showAccount,
|
||||||
|
) {
|
||||||
|
final senderNames =
|
||||||
|
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
||||||
|
|
||||||
|
final tile = ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
t.hasUnread ? Icons.mail : Icons.mail_outline,
|
||||||
|
color: t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
senderNames.isEmpty ? '(unknown)' : senderNames,
|
||||||
|
style: t.hasUnread
|
||||||
|
? const TextStyle(fontWeight: FontWeight.bold)
|
||||||
|
: null,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (t.messageCount > 1)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4),
|
||||||
|
child: Text(
|
||||||
|
'[${t.messageCount}]',
|
||||||
|
style: Theme.of(ctx).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
t.subject ?? '(no subject)',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: t.hasUnread
|
||||||
|
? const TextStyle(fontWeight: FontWeight.bold)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
if (t.preview != null && t.preview!.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
t.preview!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(ctx).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (showAccount)
|
||||||
|
Text(
|
||||||
|
accountNames[t.accountId] ?? t.accountId,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(ctx).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (t.isFlagged)
|
||||||
|
const Icon(Icons.star, color: Colors.amber, size: 16),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_fmtDate(t.latestDate),
|
||||||
|
style: Theme.of(ctx).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: t.messageCount > 1
|
||||||
|
? () => context.push(
|
||||||
|
'/accounts/${t.accountId}/mailboxes'
|
||||||
|
'/${Uri.encodeComponent(t.mailboxPath)}'
|
||||||
|
'/threads/${Uri.encodeComponent(t.threadId)}',
|
||||||
|
)
|
||||||
|
: () => context.push(
|
||||||
|
'/accounts/${t.accountId}/mailboxes'
|
||||||
|
'/${Uri.encodeComponent(t.mailboxPath)}'
|
||||||
|
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Dismissible(
|
||||||
|
key: ValueKey('${t.accountId}:${t.threadId}'),
|
||||||
|
background: _swipeBackground(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
color: Colors.green,
|
||||||
|
icon: Icons.archive,
|
||||||
|
label: 'Archive',
|
||||||
|
),
|
||||||
|
secondaryBackground: _swipeBackground(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
color: Colors.red,
|
||||||
|
icon: Icons.delete,
|
||||||
|
label: 'Delete',
|
||||||
|
),
|
||||||
|
onDismissed: (direction) => unawaited(_onSwipeDismissed(t, direction)),
|
||||||
|
child: tile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSwipeDismissed(
|
||||||
|
EmailThread t,
|
||||||
|
DismissDirection direction,
|
||||||
|
) async {
|
||||||
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
|
||||||
|
final originalEmails = (await Future.wait(
|
||||||
|
t.emailIds.map((id) => repo.getEmail(id)),
|
||||||
|
))
|
||||||
|
.whereType<Email>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (direction == DismissDirection.startToEnd) {
|
||||||
|
final archive = await ref
|
||||||
|
.read(mailboxRepositoryProvider)
|
||||||
|
.findMailboxByRole(t.accountId, 'archive');
|
||||||
|
if (!mounted || archive == null) return;
|
||||||
|
|
||||||
|
for (final id in t.emailIds) {
|
||||||
|
await repo.moveEmail(id, archive.path);
|
||||||
|
}
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: t.accountId,
|
||||||
|
type: UndoType.move,
|
||||||
|
emailIds: t.emailIds,
|
||||||
|
sourceMailboxPath: t.mailboxPath,
|
||||||
|
destinationMailboxPath: archive.path,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? lastDestPath;
|
||||||
|
for (final id in t.emailIds) {
|
||||||
|
lastDestPath = await repo.deleteEmail(id);
|
||||||
|
}
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: t.accountId,
|
||||||
|
type: UndoType.delete,
|
||||||
|
emailIds: t.emailIds,
|
||||||
|
sourceMailboxPath: t.mailboxPath,
|
||||||
|
destinationMailboxPath: lastDestPath,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _swipeBackground({
|
||||||
|
required AlignmentGeometry alignment,
|
||||||
|
required Color color,
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
color: color,
|
||||||
|
alignment: alignment,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: Colors.white),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(label, style: const TextStyle(color: Colors.white)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import 'package:sharedinbox/core/utils/format_utils.dart';
|
|||||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/email_headers_dialog.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -92,19 +93,17 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
final destPath = await repo.deleteEmail(widget.emailId);
|
final destPath = await repo.deleteEmail(widget.emailId);
|
||||||
|
|
||||||
if (header != null) {
|
if (header != null) {
|
||||||
unawaited(
|
await ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
UndoAction(
|
||||||
UndoAction(
|
id: DateTime.now().toIso8601String(),
|
||||||
id: DateTime.now().toIso8601String(),
|
accountId: header.accountId,
|
||||||
accountId: header.accountId,
|
type: UndoType.delete,
|
||||||
type: UndoType.delete,
|
emailIds: [widget.emailId],
|
||||||
emailIds: [widget.emailId],
|
sourceMailboxPath: header.mailboxPath,
|
||||||
sourceMailboxPath: header.mailboxPath,
|
destinationMailboxPath: destPath,
|
||||||
destinationMailboxPath: destPath,
|
originalEmails: [header],
|
||||||
originalEmails: [header],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||||
@@ -142,6 +141,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
child: Text('Show Mail Structure'),
|
child: Text('Show Mail Structure'),
|
||||||
),
|
),
|
||||||
const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')),
|
const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')),
|
||||||
|
const PopupMenuDivider(),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'bug_report',
|
||||||
|
child: Text('Report a Bug'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
onSelected: (value) async {
|
onSelected: (value) async {
|
||||||
if (value == 'forward' && header != null) {
|
if (value == 'forward' && header != null) {
|
||||||
@@ -162,6 +166,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
_showStructure(context, body);
|
_showStructure(context, body);
|
||||||
} else if (value == 'rfc') {
|
} else if (value == 'rfc') {
|
||||||
unawaited(_showRaw(context, header));
|
unawaited(_showRaw(context, header));
|
||||||
|
} else if (value == 'bug_report') {
|
||||||
|
unawaited(
|
||||||
|
context.push('/bug-report?emailId=${widget.emailId}'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -170,19 +178,35 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
body: detail.when(
|
body: detail.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('Error: $e')),
|
error: (e, _) => Center(child: Text('Error: $e')),
|
||||||
data: (d) => _buildBody(context, d.$1, d.$2),
|
data: (d) {
|
||||||
|
final trusted =
|
||||||
|
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
|
||||||
|
return _buildBody(context, d.$1, d.$2, trusted);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody(BuildContext ctx, Email? header, EmailBody body) {
|
Widget _buildBody(
|
||||||
|
BuildContext ctx,
|
||||||
|
Email? header,
|
||||||
|
EmailBody body,
|
||||||
|
List<String> trustedSenders,
|
||||||
|
) {
|
||||||
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
|
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
|
||||||
|
final senderEmail = header?.from.isNotEmpty == true
|
||||||
|
? header!.from.first.email.toLowerCase()
|
||||||
|
: null;
|
||||||
|
final isTrusted =
|
||||||
|
senderEmail != null && trustedSenders.contains(senderEmail);
|
||||||
|
final effectiveLoadImages = _loadRemoteImages || isTrusted;
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
if (header != null) ...[_buildHeader(ctx, header), const Divider()],
|
if (header != null) ...[_buildHeader(ctx, header), const Divider()],
|
||||||
if (hasHtml) ...[
|
if (hasHtml) ...[
|
||||||
if (!_loadRemoteImages)
|
if (!effectiveLoadImages)
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -190,13 +214,40 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.image_outlined, size: 18),
|
icon: const Icon(Icons.image_outlined, size: 18),
|
||||||
label: const Text('Load remote images'),
|
label: const Text('Load remote images'),
|
||||||
onPressed: () => setState(() => _loadRemoteImages = true),
|
onPressed: () {
|
||||||
|
setState(() => _loadRemoteImages = true);
|
||||||
|
if (senderEmail != null) {
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.addTrustedImageSender(senderEmail),
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
content: const Text(
|
||||||
|
'Images will be loaded automatically for this sender.',
|
||||||
|
),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'Settings',
|
||||||
|
onPressed: () {
|
||||||
|
if (mounted) {
|
||||||
|
unawaited(
|
||||||
|
context.push('/accounts/preferences'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SecureEmailWebView(
|
SecureEmailWebView(
|
||||||
htmlBody: body.htmlBody!,
|
htmlBody: body.htmlBody!,
|
||||||
loadRemoteImages: _loadRemoteImages,
|
loadRemoteImages: effectiveLoadImages,
|
||||||
),
|
),
|
||||||
] else
|
] else
|
||||||
SelectableText(
|
SelectableText(
|
||||||
@@ -722,47 +773,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
unawaited(
|
unawaited(
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => EmailHeadersDialog(headers: body.headers),
|
||||||
title: const Text('Mail Headers'),
|
|
||||||
content: SizedBox(
|
|
||||||
width: double.maxFinite,
|
|
||||||
child: ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: body.headers.length,
|
|
||||||
itemBuilder: (ctx, i) {
|
|
||||||
final header = body.headers[i];
|
|
||||||
return Container(
|
|
||||||
color: i.isEven
|
|
||||||
? Theme.of(ctx).colorScheme.surfaceContainerHighest
|
|
||||||
: Theme.of(ctx).colorScheme.surface,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 4,
|
|
||||||
horizontal: 8,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: SelectableText(
|
|
||||||
header.name,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(flex: 2, child: SelectableText(header.value)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('Close'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -785,12 +796,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
unawaited(
|
unawaited(
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => Dialog.fullscreen(
|
||||||
title: const Text('Mail Structure'),
|
child: Scaffold(
|
||||||
content: SizedBox(
|
appBar: AppBar(
|
||||||
width: double.maxFinite,
|
title: const Text('Mail Structure'),
|
||||||
child: ListView.builder(
|
leading: const CloseButton(),
|
||||||
shrinkWrap: true,
|
),
|
||||||
|
body: ListView.builder(
|
||||||
itemCount: rows.length,
|
itemCount: rows.length,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
final row = rows[i];
|
final row = rows[i];
|
||||||
@@ -819,12 +831,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('Close'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,10 +51,12 @@ class MailboxListScreen extends ConsumerWidget {
|
|||||||
? BottomAppBar(
|
? BottomAppBar(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
Builder(
|
||||||
icon: const Icon(Icons.menu),
|
builder: (ctx) => IconButton(
|
||||||
tooltip: 'Open folders',
|
icon: const Icon(Icons.menu),
|
||||||
onPressed: () => Scaffold.of(context).openDrawer(),
|
tooltip: 'Open folders',
|
||||||
|
onPressed: () => Scaffold.of(ctx).openDrawer(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final trustedSenders =
|
||||||
|
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
|
||||||
|
final senderEmail = widget.email.from.isNotEmpty
|
||||||
|
? widget.email.from.first.email.toLowerCase()
|
||||||
|
: null;
|
||||||
|
final isTrusted =
|
||||||
|
senderEmail != null && trustedSenders.contains(senderEmail);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -147,13 +155,13 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_expanded) _buildExpandedBody(),
|
if (_expanded) _buildExpandedBody(isTrusted, senderEmail),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildExpandedBody() {
|
Widget _buildExpandedBody(bool isTrusted, String? senderEmail) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -184,21 +192,48 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
}
|
}
|
||||||
final body = snapshot.data!;
|
final body = snapshot.data!;
|
||||||
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
|
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
|
||||||
|
final effectiveLoadImages = _loadRemoteImages || isTrusted;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (hasHtml) ...[
|
if (hasHtml) ...[
|
||||||
if (!_loadRemoteImages)
|
if (!effectiveLoadImages)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
icon: const Icon(Icons.image_outlined, size: 16),
|
icon: const Icon(Icons.image_outlined, size: 16),
|
||||||
label: const Text('Load remote images'),
|
label: const Text('Load remote images'),
|
||||||
onPressed: () =>
|
onPressed: () {
|
||||||
setState(() => _loadRemoteImages = true),
|
setState(() => _loadRemoteImages = true);
|
||||||
|
if (senderEmail != null) {
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.addTrustedImageSender(senderEmail),
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
content: const Text(
|
||||||
|
'Images will be loaded automatically for this sender.',
|
||||||
|
),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'Settings',
|
||||||
|
onPressed: () {
|
||||||
|
if (mounted) {
|
||||||
|
unawaited(
|
||||||
|
context.push('/accounts/preferences'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
SecureEmailWebView(
|
SecureEmailWebView(
|
||||||
htmlBody: body.htmlBody!,
|
htmlBody: body.htmlBody!,
|
||||||
loadRemoteImages: _loadRemoteImages,
|
loadRemoteImages: effectiveLoadImages,
|
||||||
),
|
),
|
||||||
] else
|
] else
|
||||||
SelectableText(
|
SelectableText(
|
||||||
@@ -262,47 +297,27 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _delete() async {
|
Future<void> _delete() async {
|
||||||
final confirmed = await showDialog<bool>(
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
context: context,
|
// Fetch data first for IMAP undo support
|
||||||
builder: (ctx) => AlertDialog(
|
final original = await repo.getEmail(widget.email.id);
|
||||||
title: const Text('Delete email'),
|
|
||||||
content: const Text('Move this email to Trash?'),
|
final destPath = await repo.deleteEmail(widget.email.id);
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
|
||||||
child: const Text('Delete'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (confirmed == true) {
|
if (original != null) {
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
unawaited(
|
||||||
// Fetch data first for IMAP undo support
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
final original = await repo.getEmail(widget.email.id);
|
UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
final destPath = await repo.deleteEmail(widget.email.id);
|
accountId: widget.email.accountId,
|
||||||
|
type: UndoType.delete,
|
||||||
if (!mounted) return;
|
emailIds: [widget.email.id],
|
||||||
if (original != null) {
|
sourceMailboxPath: widget.email.mailboxPath,
|
||||||
unawaited(
|
destinationMailboxPath: destPath,
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
originalEmails: [original],
|
||||||
UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: widget.email.accountId,
|
|
||||||
type: UndoType.delete,
|
|
||||||
emailIds: [widget.email.id],
|
|
||||||
sourceMailboxPath: widget.email.mailboxPath,
|
|
||||||
destinationMailboxPath: destPath,
|
|
||||||
originalEmails: [original],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
|
import 'package:sharedinbox/core/sync/background_sync.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
|
||||||
class UserPreferencesScreen extends ConsumerWidget {
|
class UserPreferencesScreen extends ConsumerWidget {
|
||||||
@@ -12,6 +13,7 @@ class UserPreferencesScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final prefsAsync = ref.watch(userPreferencesProvider);
|
final prefsAsync = ref.watch(userPreferencesProvider);
|
||||||
|
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Preferences')),
|
appBar: AppBar(title: const Text('Preferences')),
|
||||||
@@ -131,9 +133,132 @@ class UserPreferencesScreen extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
'Offline email cache',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Pre-fetch email bodies in the background so they are available offline.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
RadioGroup<PrefetchMode>(
|
||||||
|
groupValue: prefs.prefetchMode,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null) return;
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.updatePrefetchMode(value),
|
||||||
|
);
|
||||||
|
unawaited(registerBodyPrefetchTask(value));
|
||||||
|
},
|
||||||
|
child: const Column(
|
||||||
|
children: [
|
||||||
|
RadioListTile<PrefetchMode>(
|
||||||
|
title: Text('Wi-Fi only (default)'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Pre-fetch bodies in the background when connected to Wi-Fi.',
|
||||||
|
),
|
||||||
|
value: PrefetchMode.wifiOnly,
|
||||||
|
),
|
||||||
|
RadioListTile<PrefetchMode>(
|
||||||
|
title: Text('Any network'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Pre-fetch bodies on Wi-Fi and mobile data.',
|
||||||
|
),
|
||||||
|
value: PrefetchMode.always,
|
||||||
|
),
|
||||||
|
RadioListTile<PrefetchMode>(
|
||||||
|
title: Text('Disabled'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Do not pre-fetch email bodies in the background.',
|
||||||
|
),
|
||||||
|
value: PrefetchMode.disabled,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (prefs.prefetchMode != PrefetchMode.disabled) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text('Cache size limit:'),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
DropdownButton<int>(
|
||||||
|
value: _nearestCacheOption(prefs.bodyCacheLimitMb),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 50, child: Text('50 MB')),
|
||||||
|
DropdownMenuItem(value: 100, child: Text('100 MB')),
|
||||||
|
DropdownMenuItem(value: 200, child: Text('200 MB')),
|
||||||
|
DropdownMenuItem(value: 500, child: Text('500 MB')),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null) return;
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.updateBodyCacheLimitMb(value),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
'Trusted image senders',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Remote images are loaded automatically for these senders.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...trustedSendersAsync.when(
|
||||||
|
loading: () => const [],
|
||||||
|
error: (_, __) => const [],
|
||||||
|
data: (senders) => senders.isEmpty
|
||||||
|
? [
|
||||||
|
const Padding(
|
||||||
|
padding:
|
||||||
|
EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Text('No trusted senders yet.'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
for (final sender in senders)
|
||||||
|
ListTile(
|
||||||
|
title: Text(sender),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
tooltip: 'Remove',
|
||||||
|
onPressed: () {
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.removeTrustedImageSender(sender),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _nearestCacheOption(int mb) {
|
||||||
|
const options = [50, 100, 200, 500];
|
||||||
|
return options.reduce(
|
||||||
|
(a, b) => (a - mb).abs() <= (b - mb).abs() ? a : b,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
|
||||||
|
/// Full-screen dialog for browsing email headers, organised into groups.
|
||||||
|
class EmailHeadersDialog extends StatelessWidget {
|
||||||
|
const EmailHeadersDialog({super.key, required this.headers});
|
||||||
|
final List<EmailHeader> headers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dialog.fullscreen(
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Mail Headers'),
|
||||||
|
leading: const CloseButton(),
|
||||||
|
),
|
||||||
|
body: _HeadersBody(headers: headers),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HeadersBody extends StatelessWidget {
|
||||||
|
const _HeadersBody({required this.headers});
|
||||||
|
final List<EmailHeader> headers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final receivedHeaders = <EmailHeader>[];
|
||||||
|
final listHeaders = <EmailHeader>[];
|
||||||
|
final arcHeaders = <EmailHeader>[];
|
||||||
|
final otherHeaders = <EmailHeader>[];
|
||||||
|
// Maps X- prefix (e.g. "X-Google") → headers with that prefix.
|
||||||
|
final xByPrefix = <String, List<EmailHeader>>{};
|
||||||
|
|
||||||
|
for (final h in headers) {
|
||||||
|
final lower = h.name.toLowerCase();
|
||||||
|
if (lower == 'received') {
|
||||||
|
receivedHeaders.add(h);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lower.startsWith('list-')) {
|
||||||
|
listHeaders.add(h);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lower.startsWith('arc-')) {
|
||||||
|
arcHeaders.add(h);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lower.startsWith('x-')) {
|
||||||
|
final parts = h.name.split('-');
|
||||||
|
// "X-Foo-Bar-Baz" → prefix "X-Foo"; "X-Single" → prefix "X-Single".
|
||||||
|
final prefix = parts.length >= 3 ? '${parts[0]}-${parts[1]}' : h.name;
|
||||||
|
xByPrefix.putIfAbsent(prefix, () => []).add(h);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
otherHeaders.add(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
final sections = <Widget>[];
|
||||||
|
|
||||||
|
if (otherHeaders.isNotEmpty) {
|
||||||
|
sections.add(_HeadersSection(title: 'Headers', headers: otherHeaders));
|
||||||
|
}
|
||||||
|
if (listHeaders.isNotEmpty) {
|
||||||
|
sections.add(
|
||||||
|
_HeadersSection(title: 'List- Headers', headers: listHeaders),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (receivedHeaders.isNotEmpty) {
|
||||||
|
sections.add(_ReceivedSection(headers: receivedHeaders));
|
||||||
|
}
|
||||||
|
if (arcHeaders.isNotEmpty) {
|
||||||
|
sections.add(
|
||||||
|
_HeadersSection(title: 'ARC- Headers', headers: arcHeaders),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// X- headers at bottom, each prefix in its own collapsible group.
|
||||||
|
final sortedPrefixes = xByPrefix.keys.toList()
|
||||||
|
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
||||||
|
for (final prefix in sortedPrefixes) {
|
||||||
|
sections.add(
|
||||||
|
_HeadersSection(
|
||||||
|
title: '$prefix Headers',
|
||||||
|
headers: xByPrefix[prefix]!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView(children: sections);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HeadersSection extends StatelessWidget {
|
||||||
|
const _HeadersSection({required this.title, required this.headers});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final List<EmailHeader> headers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ExpansionTile(
|
||||||
|
title: Text('$title (${headers.length})'),
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < headers.length; i++)
|
||||||
|
_HeaderRow(header: headers[i], index: i),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Received headers section — collapsed by default; shows inter-hop delays.
|
||||||
|
class _ReceivedSection extends StatelessWidget {
|
||||||
|
const _ReceivedSection({required this.headers});
|
||||||
|
final List<EmailHeader> headers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final entries = _buildEntries(headers);
|
||||||
|
return ExpansionTile(
|
||||||
|
title: Text('Received (${headers.length})'),
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < entries.length; i++) ...[
|
||||||
|
_HeaderRow(header: entries[i].header, index: i),
|
||||||
|
if (entries[i].delay != null) _DelayRow(delay: entries[i].delay!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<_ReceivedEntry> _buildEntries(List<EmailHeader> headers) {
|
||||||
|
final timestamps =
|
||||||
|
headers.map((h) => _parseReceivedTimestamp(h.value)).toList();
|
||||||
|
return [
|
||||||
|
for (var i = 0; i < headers.length; i++)
|
||||||
|
_ReceivedEntry(
|
||||||
|
header: headers[i],
|
||||||
|
delay: _computeDelay(timestamps, i),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static Duration? _computeDelay(List<DateTime?> timestamps, int i) {
|
||||||
|
if (i >= timestamps.length - 1) return null;
|
||||||
|
final current = timestamps[i];
|
||||||
|
final next = timestamps[i + 1];
|
||||||
|
if (current == null || next == null) return null;
|
||||||
|
final d = current.difference(next);
|
||||||
|
return d.isNegative ? Duration.zero : d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReceivedEntry {
|
||||||
|
const _ReceivedEntry({required this.header, this.delay});
|
||||||
|
final EmailHeader header;
|
||||||
|
final Duration? delay;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HeaderRow extends StatelessWidget {
|
||||||
|
const _HeaderRow({required this.header, required this.index});
|
||||||
|
final EmailHeader header;
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bg = index.isEven
|
||||||
|
? Theme.of(context).colorScheme.surfaceContainerHighest
|
||||||
|
: Theme.of(context).colorScheme.surface;
|
||||||
|
return Container(
|
||||||
|
color: bg,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SelectableText(
|
||||||
|
header.name,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(flex: 2, child: SelectableText(header.value)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DelayRow extends StatelessWidget {
|
||||||
|
const _DelayRow({required this.delay});
|
||||||
|
final Duration delay;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = _delayColor(delay);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.arrow_downward, size: 14, color: color),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_formatDuration(delay),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: color,
|
||||||
|
fontWeight:
|
||||||
|
delay.inSeconds >= 30 ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the RFC 2822 timestamp from a Received header value.
|
||||||
|
///
|
||||||
|
/// Received headers end with `; date`, e.g.:
|
||||||
|
/// by mx.example.com; Mon, 1 Jan 2024 12:00:00 +0000 (UTC)
|
||||||
|
DateTime? _parseReceivedTimestamp(String value) {
|
||||||
|
final semiIndex = value.lastIndexOf(';');
|
||||||
|
if (semiIndex < 0) return null;
|
||||||
|
var s = value.substring(semiIndex + 1).trim();
|
||||||
|
// Strip parenthesised comments like (UTC).
|
||||||
|
s = s.replaceAll(RegExp(r'\([^)]*\)'), ' ').trim();
|
||||||
|
// Strip leading day-of-week abbreviation like "Mon, ".
|
||||||
|
s = s.replaceFirst(RegExp(r'^[A-Za-z]{2,4},\s*'), '');
|
||||||
|
// Collapse runs of whitespace.
|
||||||
|
s = s.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||||
|
|
||||||
|
for (final fmt in [
|
||||||
|
DateFormat('dd MMM yyyy HH:mm:ss Z', 'en_US'),
|
||||||
|
DateFormat('d MMM yyyy HH:mm:ss Z', 'en_US'),
|
||||||
|
DateFormat('dd MMM yyyy HH:mm:ss', 'en_US'),
|
||||||
|
DateFormat('d MMM yyyy HH:mm:ss', 'en_US'),
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
return fmt.parse(s);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(Duration d) {
|
||||||
|
if (d.inSeconds < 60) return '${d.inSeconds}s';
|
||||||
|
if (d.inMinutes < 60) return '${d.inMinutes}m ${d.inSeconds.remainder(60)}s';
|
||||||
|
return '${d.inHours}h ${d.inMinutes.remainder(60)}m';
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _delayColor(Duration d) {
|
||||||
|
if (d.inSeconds < 30) return Colors.green;
|
||||||
|
if (d.inSeconds < 300) return Colors.orange;
|
||||||
|
return Colors.red;
|
||||||
|
}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# Plan Log
|
|
||||||
|
|
||||||
## 2026-05-10
|
|
||||||
- Improved Undo Log (Issue #7): Added support for undoing any action from history.
|
|
||||||
- Refactored `UndoService.undo()` to support targeted rollbacks by action ID.
|
|
||||||
- Removed "latest only" restriction from `UndoLogScreen`.
|
|
||||||
- Successfully deployed release APK to distribution server via `task deploy-android`.
|
|
||||||
- Verified system integrity with unit, widget, and E2E integration tests.
|
|
||||||
|
|
||||||
## 2026-05-10
|
|
||||||
- Implemented global Undo Log with persistent history.
|
|
||||||
- Refactored `UndoService` to store a list of recent actions instead of just the latest one.
|
|
||||||
- Added `UndoLogScreen` to view and interact with undo history.
|
|
||||||
- Added "History" icon to account list for better discoverability.
|
|
||||||
- Updated `.gitignore` to better handle Dart/Flutter and Android tool artifacts.
|
|
||||||
- Verified all changes with fast check suite (analyze + unit + widget tests).
|
|
||||||
|
|
||||||
## 2026-05-09
|
|
||||||
- Fixed Crash Page (Issue 3): Added Codeberg reporting button.
|
|
||||||
- Fixed Show Mail Headers (Issue 1): Added raw header storage and UI display.
|
|
||||||
- Fixed Exception on Undo of delete (Issue 2): Added serialization to EmailAddress.
|
|
||||||
- Updated Taskfile with Nix experimental features check.
|
|
||||||
- Pushed all changes to branch `fix-issues`.
|
|
||||||
|
|
||||||
## 2026-05-09
|
|
||||||
- Fixed Undo feature for IMAP accounts.
|
|
||||||
- Identified that IMAP moveEmail hard-deletes local rows, making Undo impossible without data.
|
|
||||||
- Added `originalEmails` to `UndoAction` and `restoreEmails` to `EmailRepository`.
|
|
||||||
- Updated UI to fetch email data before move/delete to support restoration.
|
|
||||||
- Fixed `UndoService` to restore rows and be more robust with pending change cancellation.
|
|
||||||
- Verified with `test/unit/undo_reproduction_test.dart` and updated unit tests.
|
|
||||||
- Successfully deployed to Android.
|
|
||||||
|
|
||||||
## 2026-05-09
|
|
||||||
- Implemented Network Resilience (Task 1/4 from next.md).
|
|
||||||
- Added exponential backoff logic (5s to 15m) to IMAP and JMAP sync loops.
|
|
||||||
- Added permanent error detection (auth/credentials) to stop sync loops gracefully.
|
|
||||||
- Improved "Pull to Refresh" in email list to trigger full account sync and bypass backoff.
|
|
||||||
- Verified with integration tests.
|
|
||||||
|
|
||||||
- Started work on Sync Reliability (Task 1/5 from next.md).
|
|
||||||
- Added `verifySyncReliability` to `EmailRepository` interface and models.
|
|
||||||
- Implemented `verifySyncReliability` in `EmailRepositoryImpl` for IMAP and JMAP.
|
|
||||||
- Added `SyncHealth` table to database (Schema v19).
|
|
||||||
- Created `ReliabilityRunner` for periodic verification.
|
|
||||||
- Integrated sync health indicators in `AccountListScreen` UI.
|
|
||||||
- Added manual "Verify sync health" action.
|
|
||||||
- Verified with new integration tests in `test/integration/sync_reliability_test.dart`.
|
|
||||||
- All integration tests (IMAP and JMAP) passing.
|
|
||||||
- Fixed several compilation and analysis issues.
|
|
||||||
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 78 KiB |
@@ -1021,7 +1021,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.44.4"
|
version: "0.44.4"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: stack_trace
|
name: stack_trace
|
||||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ dependencies:
|
|||||||
flutter_local_notifications: ^21.0.0
|
flutter_local_notifications: ^21.0.0
|
||||||
workmanager: ^0.9.0
|
workmanager: ^0.9.0
|
||||||
|
|
||||||
|
# Stack trace chain-to-VM conversion for FlutterError.demangleStackTrace
|
||||||
|
stack_trace: ^1.12.1
|
||||||
|
|
||||||
# App version metadata for crash reports
|
# App version metadata for crash reports
|
||||||
package_info_plus: ^10.1.0
|
package_info_plus: ^10.1.0
|
||||||
share_plus: ^13.1.0
|
share_plus: ^13.1.0
|
||||||
|
|||||||
@@ -11,6 +11,29 @@
|
|||||||
{
|
{
|
||||||
"matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"],
|
"matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"],
|
||||||
"addLabels": ["automerge"]
|
"addLabels": ["automerge"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": ["gomod"],
|
||||||
|
"matchFileNames": ["ci/**"],
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"customManagers": [
|
||||||
|
{
|
||||||
|
"customType": "regex",
|
||||||
|
"fileMatch": ["^\\.forgejo/Dockerfile$"],
|
||||||
|
"matchStrings": ["DAGGER_VERSION=(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)"],
|
||||||
|
"depNameTemplate": "dagger/dagger",
|
||||||
|
"datasourceTemplate": "github-releases",
|
||||||
|
"extractVersionTemplate": "^v(?<version>.*)$"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"customType": "regex",
|
||||||
|
"fileMatch": ["^DAGGER\\.md$"],
|
||||||
|
"matchStrings": ["github:dagger/nix/v(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)#dagger"],
|
||||||
|
"depNameTemplate": "dagger/dagger",
|
||||||
|
"datasourceTemplate": "github-releases",
|
||||||
|
"extractVersionTemplate": "^v(?<version>.*)$"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verify that every container image referenced in ci/main.go is reachable.
|
||||||
|
# Runs skopeo inspect (manifest-only, no layer pull) for each From("...") call.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT=$(git rev-parse --show-toplevel)
|
||||||
|
FILE="$ROOT/ci/main.go"
|
||||||
|
|
||||||
|
# Static images from From("...") literals in ci/main.go
|
||||||
|
static_images=$(grep -oP 'From\("\K[^"]+' "$FILE" | sort -u)
|
||||||
|
|
||||||
|
# Dynamic Flutter image derived from .fvmrc (not a literal in main.go)
|
||||||
|
FVMRC="$ROOT/.fvmrc"
|
||||||
|
flutter_version=$(python3 -c "import json; print(json.load(open('$FVMRC'))['flutter'])" 2>/dev/null || true)
|
||||||
|
flutter_image=""
|
||||||
|
if [ -n "$flutter_version" ]; then
|
||||||
|
flutter_image="ghcr.io/cirruslabs/flutter:$flutter_version"
|
||||||
|
fi
|
||||||
|
|
||||||
|
images=$(printf '%s\n%s\n' "$static_images" "$flutter_image" | grep -v '^$' | sort -u)
|
||||||
|
|
||||||
|
if [ -z "$images" ]; then
|
||||||
|
echo "check-ci-images: no From() image references found in $FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
while IFS= read -r image; do
|
||||||
|
printf "check-ci-images: %-55s" "$image"
|
||||||
|
if skopeo inspect --no-creds "docker://$image" > /dev/null 2>&1; then
|
||||||
|
echo "OK"
|
||||||
|
else
|
||||||
|
echo "NOT FOUND"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done <<< "$images"
|
||||||
|
|
||||||
|
if [ "$fail" -eq 1 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "ERROR: one or more container images in ci/main.go could not be resolved."
|
||||||
|
echo "Fix the image tag before committing."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -41,7 +41,9 @@ const _excluded = {
|
|||||||
'lib/ui/screens/account_send_screen.dart',
|
'lib/ui/screens/account_send_screen.dart',
|
||||||
'lib/ui/screens/add_account_screen.dart',
|
'lib/ui/screens/add_account_screen.dart',
|
||||||
'lib/ui/screens/address_emails_screen.dart',
|
'lib/ui/screens/address_emails_screen.dart',
|
||||||
|
'lib/ui/screens/bug_report_screen.dart',
|
||||||
'lib/ui/screens/changelog_screen.dart',
|
'lib/ui/screens/changelog_screen.dart',
|
||||||
|
'lib/ui/screens/combined_inbox_screen.dart',
|
||||||
'lib/ui/screens/compose_screen.dart',
|
'lib/ui/screens/compose_screen.dart',
|
||||||
'lib/ui/screens/crash_screen.dart',
|
'lib/ui/screens/crash_screen.dart',
|
||||||
'lib/ui/screens/edit_account_screen.dart',
|
'lib/ui/screens/edit_account_screen.dart',
|
||||||
@@ -62,6 +64,7 @@ const _excluded = {
|
|||||||
'lib/ui/screens/about_screen.dart',
|
'lib/ui/screens/about_screen.dart',
|
||||||
'lib/ui/screens/email_action_helpers.dart',
|
'lib/ui/screens/email_action_helpers.dart',
|
||||||
'lib/ui/utils/about_markdown.dart',
|
'lib/ui/utils/about_markdown.dart',
|
||||||
|
'lib/ui/widgets/email_headers_dialog.dart',
|
||||||
'lib/ui/widgets/email_tile.dart',
|
'lib/ui/widgets/email_tile.dart',
|
||||||
'lib/core/sync/account_sync_manager.dart',
|
'lib/core/sync/account_sync_manager.dart',
|
||||||
'lib/core/sync/background_sync.dart',
|
'lib/core/sync/background_sync.dart',
|
||||||
|
|||||||
@@ -16,6 +16,37 @@ 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")
|
||||||
|
|
||||||
|
# Export all CI secrets to the GitHub Actions environment so subsequent steps
|
||||||
|
# can use them without referencing Forgejo secrets directly.
|
||||||
|
export_secret() {
|
||||||
|
local name="$1"
|
||||||
|
local value
|
||||||
|
value=$(jq -r --arg k "$name" '.[$k] // empty' "$SECRETS_JSON")
|
||||||
|
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||||
|
# Use heredoc syntax for multiline-safe export.
|
||||||
|
# Avoid adding a second trailing newline for values that already end with one
|
||||||
|
# (e.g. SSH private keys), which can corrupt PEM parsing.
|
||||||
|
{
|
||||||
|
printf '%s<<__EOF__\n' "$name"
|
||||||
|
printf '%s' "$value"
|
||||||
|
[ "${value%$'\n'}" = "$value" ] && printf '\n'
|
||||||
|
printf '__EOF__\n'
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
printf '[secrets] exported %s (%d chars)\n' "$name" "${#value}"
|
||||||
|
}
|
||||||
|
|
||||||
|
export_secret "SSH_PRIVATE_KEY"
|
||||||
|
export_secret "SSH_KNOWN_HOSTS"
|
||||||
|
export_secret "SSH_USER"
|
||||||
|
export_secret "SSH_HOST"
|
||||||
|
export_secret "WEBSITE_SSH_HOST"
|
||||||
|
export_secret "PLAY_STORE_CONFIG_JSON"
|
||||||
|
export_secret "ANDROID_KEYSTORE_BASE64"
|
||||||
|
export_secret "ANDROID_KEYSTORE_PASSWORD"
|
||||||
|
export_secret "FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY"
|
||||||
|
export_secret "RENOVATE_FORGEJO_TOKEN"
|
||||||
|
|
||||||
# Setup SSH directory and keys
|
# Setup SSH directory and keys
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
chmod 700 ~/.ssh
|
chmod 700 ~/.ssh
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for verify_playstore_deploy.py."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
import verify_playstore_deploy
|
||||||
|
|
||||||
|
|
||||||
|
def _make_session(version_code, track="internal"):
|
||||||
|
"""Return a mock AuthorizedSession with the given version code on the track."""
|
||||||
|
session = MagicMock()
|
||||||
|
|
||||||
|
edit_resp = MagicMock()
|
||||||
|
edit_resp.json.return_value = {"id": "edit-99"}
|
||||||
|
session.post.return_value = edit_resp
|
||||||
|
|
||||||
|
track_resp = MagicMock()
|
||||||
|
track_resp.json.return_value = {
|
||||||
|
"releases": [{"versionCodes": [str(version_code)], "status": "completed"}]
|
||||||
|
}
|
||||||
|
session.get.return_value = track_resp
|
||||||
|
session.delete.return_value = MagicMock()
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
class TestMissingEnv(unittest.TestCase):
|
||||||
|
def test_missing_env_exits(self):
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
with self.assertRaises(SystemExit) as ctx:
|
||||||
|
verify_playstore_deploy.main()
|
||||||
|
self.assertEqual(ctx.exception.code, 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecentDeploy(unittest.TestCase):
|
||||||
|
def _run(self, version_code):
|
||||||
|
session = _make_session(version_code)
|
||||||
|
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
|
||||||
|
with patch("verify_playstore_deploy.service_account.Credentials.from_service_account_info"):
|
||||||
|
with patch("verify_playstore_deploy.AuthorizedSession", return_value=session):
|
||||||
|
verify_playstore_deploy.main()
|
||||||
|
|
||||||
|
def test_recent_version_code_passes(self):
|
||||||
|
# Version code is Unix timestamp — a very recent one should pass.
|
||||||
|
recent_vc = int(time.time()) - 60 # 1 minute ago
|
||||||
|
self._run(recent_vc)
|
||||||
|
|
||||||
|
def test_old_version_code_fails(self):
|
||||||
|
old_vc = int(time.time()) - 7200 # 2 hours ago
|
||||||
|
with self.assertRaises(SystemExit) as ctx:
|
||||||
|
self._run(old_vc)
|
||||||
|
self.assertEqual(ctx.exception.code, 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmptyTrack(unittest.TestCase):
|
||||||
|
def _run_empty(self, releases):
|
||||||
|
session = MagicMock()
|
||||||
|
session.post.return_value = MagicMock(**{"json.return_value": {"id": "edit-1"}})
|
||||||
|
session.get.return_value = MagicMock(**{"json.return_value": {"releases": releases}})
|
||||||
|
session.delete.return_value = MagicMock()
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
|
||||||
|
with patch("verify_playstore_deploy.service_account.Credentials.from_service_account_info"):
|
||||||
|
with patch("verify_playstore_deploy.AuthorizedSession", return_value=session):
|
||||||
|
verify_playstore_deploy.main()
|
||||||
|
|
||||||
|
def test_no_releases_exits(self):
|
||||||
|
with self.assertRaises(SystemExit) as ctx:
|
||||||
|
self._run_empty([])
|
||||||
|
self.assertEqual(ctx.exception.code, 1)
|
||||||
|
|
||||||
|
def test_release_with_no_version_codes_exits(self):
|
||||||
|
with self.assertRaises(SystemExit) as ctx:
|
||||||
|
self._run_empty([{"status": "completed", "versionCodes": []}])
|
||||||
|
self.assertEqual(ctx.exception.code, 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify that the Android app was recently published to the Play Store internal track.
|
||||||
|
|
||||||
|
The publish-android pipeline sets versionCode = int(time.Now().Unix()), so a
|
||||||
|
freshly deployed release always has a version code close to the current Unix
|
||||||
|
timestamp. This script queries the internal track and fails if the latest
|
||||||
|
version code is older than _MAX_DEPLOY_AGE_SECONDS, which would mean the
|
||||||
|
deployment silently did not land.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from google.auth.transport.requests import AuthorizedSession
|
||||||
|
from google.oauth2 import service_account
|
||||||
|
|
||||||
|
PACKAGE_NAME = "de.sharedinbox.mua"
|
||||||
|
TRACK = "internal"
|
||||||
|
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
|
||||||
|
# Allow up to one hour for the build + upload to complete.
|
||||||
|
_MAX_DEPLOY_AGE_SECONDS = 3600
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
config_json = os.environ.get("PLAY_STORE_CONFIG_JSON")
|
||||||
|
if not config_json:
|
||||||
|
print("Error: PLAY_STORE_CONFIG_JSON environment variable not set", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
creds = service_account.Credentials.from_service_account_info(
|
||||||
|
json.loads(config_json),
|
||||||
|
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
||||||
|
)
|
||||||
|
session = AuthorizedSession(creds)
|
||||||
|
|
||||||
|
# Open a read-only edit to query the current track state.
|
||||||
|
edit_resp = session.post(f"{_BASE}/{PACKAGE_NAME}/edits", json={}, timeout=30)
|
||||||
|
edit_resp.raise_for_status()
|
||||||
|
edit_id = edit_resp.json()["id"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
track_resp = session.get(
|
||||||
|
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
track_resp.raise_for_status()
|
||||||
|
track_data = track_resp.json()
|
||||||
|
finally:
|
||||||
|
# Discard the edit — we made no changes.
|
||||||
|
try:
|
||||||
|
session.delete(f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}", timeout=30)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
releases = track_data.get("releases", [])
|
||||||
|
if not releases:
|
||||||
|
print(
|
||||||
|
f"ERROR: No releases found on {TRACK} track — deploy may have failed silently",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
all_version_codes = [
|
||||||
|
int(vc)
|
||||||
|
for release in releases
|
||||||
|
for vc in release.get("versionCodes", [])
|
||||||
|
]
|
||||||
|
if not all_version_codes:
|
||||||
|
print("ERROR: Latest release has no version codes", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
latest_vc = max(all_version_codes)
|
||||||
|
now = int(time.time())
|
||||||
|
# versionCode is set to Unix timestamp by PublishAndroid in ci/main.go.
|
||||||
|
age_seconds = now - latest_vc
|
||||||
|
|
||||||
|
print(f"Latest version code on {TRACK} track: {latest_vc}")
|
||||||
|
print(f"Current time: {now} — version code age: {age_seconds}s")
|
||||||
|
|
||||||
|
if age_seconds > _MAX_DEPLOY_AGE_SECONDS:
|
||||||
|
print(
|
||||||
|
f"::error::Latest version code {latest_vc} is {age_seconds}s old "
|
||||||
|
f"(limit: {_MAX_DEPLOY_AGE_SECONDS}s). The deploy may have failed silently.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"OK: version {latest_vc} verified on {TRACK} track ({age_seconds}s old)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module sharedinbox.de/bugreport
|
||||||
|
|
||||||
|
go 1.21
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BugReport represents the data stored in report.json
|
||||||
|
type BugReport struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
AboutInfo string `json:"about_info"`
|
||||||
|
EmailData string `json:"email_data,omitempty"`
|
||||||
|
SyncLog string `json:"sync_log,omitempty"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
HashedIP string `json:"hashed_ip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
rateLimitMu sync.Mutex
|
||||||
|
requestTimes []time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkRateLimit implements a sliding window rate limiter: max 10 requests per minute globally.
|
||||||
|
func checkRateLimit() (bool, time.Duration) {
|
||||||
|
rateLimitMu.Lock()
|
||||||
|
defer rateLimitMu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
// Clean up timestamps older than 1 minute
|
||||||
|
var valid []time.Time
|
||||||
|
for _, t := range requestTimes {
|
||||||
|
if now.Sub(t) < time.Minute {
|
||||||
|
valid = append(valid, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestTimes = valid
|
||||||
|
|
||||||
|
if len(requestTimes) >= 10 {
|
||||||
|
// Calculate time until the oldest request in the window falls out of it
|
||||||
|
oldest := requestTimes[0]
|
||||||
|
remaining := time.Minute - now.Sub(oldest)
|
||||||
|
if remaining < 0 {
|
||||||
|
remaining = 0
|
||||||
|
}
|
||||||
|
return false, remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
requestTimes = append(requestTimes, now)
|
||||||
|
return true, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateUUID() (string, error) {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Format as UUID v4 structure
|
||||||
|
b[6] = (b[6] & 0x0f) | 0x40 // Version 4
|
||||||
|
b[8] = (b[8] & 0x3f) | 0x80 // Variant is 10
|
||||||
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashIP(ip string) string {
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte(ip))
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func bugReportHandler(storageDir string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Enable CORS so the web app (if applicable) can upload
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting check
|
||||||
|
allowed, waitTime := checkRateLimit()
|
||||||
|
if !allowed {
|
||||||
|
retryAfter := int(waitTime.Seconds())
|
||||||
|
if retryAfter < 1 {
|
||||||
|
retryAfter = 1
|
||||||
|
}
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Too many requests. Please try again later."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit body size to 20 MB (20 * 1024 * 1024 bytes)
|
||||||
|
const maxBodySize = 20 * 1024 * 1024
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
||||||
|
|
||||||
|
// Parse the multipart form
|
||||||
|
err := r.ParseMultipartForm(maxBodySize)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to parse multipart form: %v", err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Request body too large or invalid multipart form."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = r.MultipartForm.RemoveAll()
|
||||||
|
}()
|
||||||
|
|
||||||
|
description := r.FormValue("description")
|
||||||
|
aboutInfo := r.FormValue("about_info")
|
||||||
|
|
||||||
|
if description == "" || aboutInfo == "" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "description and about_info are required fields."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email := r.FormValue("email")
|
||||||
|
emailData := r.FormValue("email_data")
|
||||||
|
syncLog := r.FormValue("sync_log")
|
||||||
|
|
||||||
|
// Get IP address
|
||||||
|
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
ip = r.RemoteAddr
|
||||||
|
}
|
||||||
|
// Check X-Forwarded-For if behind a proxy
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
parts := strings.Split(xff, ",")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
ip = strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hashedIP := hashIP(ip)
|
||||||
|
|
||||||
|
uuidVal, err := generateUUID()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to generate UUID: %v", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
timestampStr := now.Format("20060102_150405")
|
||||||
|
dirName := fmt.Sprintf("%s_%s", timestampStr, uuidVal)
|
||||||
|
reportDir := filepath.Join(storageDir, dirName)
|
||||||
|
|
||||||
|
err = os.MkdirAll(reportDir, 0750)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create report directory: %v", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write report.json
|
||||||
|
report := BugReport{
|
||||||
|
Description: description,
|
||||||
|
Email: email,
|
||||||
|
AboutInfo: aboutInfo,
|
||||||
|
EmailData: emailData,
|
||||||
|
SyncLog: syncLog,
|
||||||
|
Timestamp: now,
|
||||||
|
HashedIP: hashedIP,
|
||||||
|
}
|
||||||
|
|
||||||
|
reportJSONPath := filepath.Join(reportDir, "report.json")
|
||||||
|
reportJSONFile, err := os.OpenFile(reportJSONPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create report.json: %v", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer reportJSONFile.Close()
|
||||||
|
|
||||||
|
enc := json.NewEncoder(reportJSONFile)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
err = enc.Encode(report)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to write report.json: %v", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save attachments
|
||||||
|
form := r.MultipartForm
|
||||||
|
files := form.File["attachments[]"]
|
||||||
|
for i, fileHeader := range files {
|
||||||
|
file, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to open attachment %d: %v", i, err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Sanitize filename to avoid directory traversal
|
||||||
|
baseName := filepath.Base(fileHeader.Filename)
|
||||||
|
attachmentName := fmt.Sprintf("attachment_%d_%s", i, baseName)
|
||||||
|
attachmentPath := filepath.Join(reportDir, attachmentName)
|
||||||
|
|
||||||
|
destFile, err := os.OpenFile(attachmentPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create attachment file %s: %v", attachmentName, err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer destFile.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(destFile, file)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to copy attachment content to %s: %v", attachmentName, err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"id": uuidVal})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
port := os.Getenv("BUGREPORT_PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8090"
|
||||||
|
}
|
||||||
|
|
||||||
|
storageDir := os.Getenv("BUGREPORT_STORAGE_DIR")
|
||||||
|
if storageDir == "" {
|
||||||
|
storageDir = "./reports"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create storage directory if it doesn't exist
|
||||||
|
err := os.MkdirAll(storageDir, 0750)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to create storage directory %s: %v", storageDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/api/v1/bug-reports", bugReportHandler(storageDir))
|
||||||
|
|
||||||
|
addr := net.JoinHostPort("127.0.0.1", port)
|
||||||
|
log.Printf("Bug report server starting on %s...", addr)
|
||||||
|
log.Printf("Reports storage directory: %s", storageDir)
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: mux,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
|
WriteTimeout: 15 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := server.ListenAndServe(); err != nil {
|
||||||
|
log.Fatalf("Server failed to start: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -186,6 +186,10 @@ class _FakeEmails implements EmailRepository {
|
|||||||
}) =>
|
}) =>
|
||||||
Stream.value([]);
|
Stream.value([]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
|
||||||
|
Stream.value([]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||||
Stream.value([]);
|
Stream.value([]);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Loads Material fonts (Roboto + MaterialIcons) before any test runs so that
|
||||||
|
// golden/screenshot tests render real text instead of placeholder boxes.
|
||||||
|
//
|
||||||
|
// Flutter widget tests don't load fonts by default. This file is discovered
|
||||||
|
// automatically by `flutter test` for every test under test/.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
|
||||||
|
setUpAll(_loadMaterialFonts);
|
||||||
|
await testMain();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMaterialFonts() async {
|
||||||
|
// Locate Flutter's cached material fonts relative to the flutter_tester executable.
|
||||||
|
// Layout: <flutter-root>/bin/cache/artifacts/engine/linux-x64/flutter_tester
|
||||||
|
// <flutter-root>/bin/cache/artifacts/material_fonts/
|
||||||
|
final cacheDir =
|
||||||
|
File(Platform.resolvedExecutable).parent.parent.parent.parent;
|
||||||
|
final fontsDir = '${cacheDir.path}/artifacts/material_fonts';
|
||||||
|
|
||||||
|
Future<ByteData> load(String name) async {
|
||||||
|
final bytes = await File('$fontsDir/$name').readAsBytes();
|
||||||
|
return ByteData.view(bytes.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
await (FontLoader('Roboto')
|
||||||
|
..addFont(load('Roboto-Regular.ttf'))
|
||||||
|
..addFont(load('Roboto-Medium.ttf'))
|
||||||
|
..addFont(load('Roboto-Bold.ttf'))
|
||||||
|
..addFont(load('Roboto-Italic.ttf'))
|
||||||
|
..addFont(load('Roboto-MediumItalic.ttf'))
|
||||||
|
..addFont(load('Roboto-BoldItalic.ttf')))
|
||||||
|
.load();
|
||||||
|
|
||||||
|
await (FontLoader('MaterialIcons')
|
||||||
|
..addFont(load('MaterialIcons-Regular.otf')))
|
||||||
|
.load();
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
// Generates Play Store promotional screenshots for all three device classes.
|
||||||
|
//
|
||||||
|
// Run with:
|
||||||
|
// fvm flutter test test/screenshot_automation_test.dart --update-goldens
|
||||||
|
//
|
||||||
|
// Output: screenshots/{phone,tablet_7in,tablet_10in}/{light,dark}/<scene>.png
|
||||||
|
// at the repository root (one directory above test/).
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/email_list_screen.dart';
|
||||||
|
|
||||||
|
import 'widget/helpers.dart';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Device configurations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
typedef _Device = ({String name, double width, double height, double dpr});
|
||||||
|
|
||||||
|
const _devices = <_Device>[
|
||||||
|
(name: 'phone', width: 1080.0, height: 1920.0, dpr: 3.0),
|
||||||
|
(name: 'tablet_7in', width: 1200.0, height: 1920.0, dpr: 2.0),
|
||||||
|
(name: 'tablet_10in', width: 1600.0, height: 2560.0, dpr: 2.0),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sample data — fixed date so golden files are stable between runs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const _kAccount = Account(
|
||||||
|
id: 'acc-1',
|
||||||
|
displayName: 'Alice',
|
||||||
|
email: 'alice@sharedinbox.de',
|
||||||
|
imapHost: 'imap.sharedinbox.de',
|
||||||
|
smtpHost: 'smtp.sharedinbox.de',
|
||||||
|
);
|
||||||
|
|
||||||
|
final _kDate = DateTime(2025, 5, 14, 10, 30);
|
||||||
|
|
||||||
|
Email _email({
|
||||||
|
required String id,
|
||||||
|
required String subject,
|
||||||
|
required String fromName,
|
||||||
|
required String fromEmail,
|
||||||
|
bool isSeen = true,
|
||||||
|
bool isFlagged = false,
|
||||||
|
bool hasAttachment = false,
|
||||||
|
String? preview,
|
||||||
|
}) =>
|
||||||
|
Email(
|
||||||
|
id: id,
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: int.parse(id.split(':').last),
|
||||||
|
subject: subject,
|
||||||
|
receivedAt: _kDate,
|
||||||
|
sentAt: _kDate,
|
||||||
|
from: [EmailAddress(name: fromName, email: fromEmail)],
|
||||||
|
to: const [EmailAddress(name: 'Alice', email: 'alice@sharedinbox.de')],
|
||||||
|
cc: const [],
|
||||||
|
isSeen: isSeen,
|
||||||
|
isFlagged: isFlagged,
|
||||||
|
hasAttachment: hasAttachment,
|
||||||
|
preview: preview,
|
||||||
|
);
|
||||||
|
|
||||||
|
final _sampleEmails = [
|
||||||
|
_email(
|
||||||
|
id: 'acc-1:1',
|
||||||
|
subject: 'Re: Project kick-off next week',
|
||||||
|
fromName: 'Maria Hoffmann',
|
||||||
|
fromEmail: 'maria@corp.example',
|
||||||
|
isSeen: false,
|
||||||
|
preview: 'Sounds great! I will prepare the slides beforehand.',
|
||||||
|
),
|
||||||
|
_email(
|
||||||
|
id: 'acc-1:2',
|
||||||
|
subject: 'Your invoice #2024-0312 is ready',
|
||||||
|
fromName: 'Billing',
|
||||||
|
fromEmail: 'billing@service.example',
|
||||||
|
isSeen: false,
|
||||||
|
preview: 'Your invoice for May is attached as a PDF.',
|
||||||
|
),
|
||||||
|
_email(
|
||||||
|
id: 'acc-1:3',
|
||||||
|
subject: 'Team lunch — Friday 12:30',
|
||||||
|
fromName: 'Thomas Müller',
|
||||||
|
fromEmail: 'thomas@corp.example',
|
||||||
|
isFlagged: true,
|
||||||
|
preview: 'The Italian place on Main Street. RSVP by Thursday please.',
|
||||||
|
),
|
||||||
|
_email(
|
||||||
|
id: 'acc-1:4',
|
||||||
|
subject: 'Quarterly review agenda',
|
||||||
|
fromName: 'HR Team',
|
||||||
|
fromEmail: 'hr@corp.example',
|
||||||
|
preview:
|
||||||
|
"Please find the agenda for next week's quarterly review attached.",
|
||||||
|
),
|
||||||
|
_email(
|
||||||
|
id: 'acc-1:5',
|
||||||
|
subject: 'Weekend hiking trip — photos inside',
|
||||||
|
fromName: 'Jonas Weber',
|
||||||
|
fromEmail: 'jonas@personal.example',
|
||||||
|
hasAttachment: true,
|
||||||
|
preview: 'Had such a great time! Here are the photos from Saturday.',
|
||||||
|
),
|
||||||
|
_email(
|
||||||
|
id: 'acc-1:6',
|
||||||
|
subject: 'Reminder: dentist appointment tomorrow',
|
||||||
|
fromName: 'City Dental',
|
||||||
|
fromEmail: 'noreply@citydental.example',
|
||||||
|
preview: 'Your appointment is confirmed for Thursday at 14:00.',
|
||||||
|
),
|
||||||
|
_email(
|
||||||
|
id: 'acc-1:7',
|
||||||
|
subject: 'Re: Feedback on the draft',
|
||||||
|
fromName: 'Laura Schmidt',
|
||||||
|
fromEmail: 'laura@corp.example',
|
||||||
|
isSeen: false,
|
||||||
|
preview: 'I left some comments on page 3. Overall it looks really solid!',
|
||||||
|
),
|
||||||
|
_email(
|
||||||
|
id: 'acc-1:8',
|
||||||
|
subject: 'Flight confirmation PNR XYZ123',
|
||||||
|
fromName: 'Sunshine Airlines',
|
||||||
|
fromEmail: 'noreply@airline.example',
|
||||||
|
preview:
|
||||||
|
'Your booking is confirmed. Check-in opens 24 hours before departure.',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
final _sampleMailboxes = [
|
||||||
|
const Mailbox(
|
||||||
|
id: 'acc-1:INBOX',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
path: 'INBOX',
|
||||||
|
name: 'INBOX',
|
||||||
|
role: 'inbox',
|
||||||
|
unreadCount: 3,
|
||||||
|
totalCount: 8,
|
||||||
|
),
|
||||||
|
const Mailbox(
|
||||||
|
id: 'acc-1:Sent',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
path: 'Sent',
|
||||||
|
name: 'Sent',
|
||||||
|
role: 'sent',
|
||||||
|
unreadCount: 0,
|
||||||
|
totalCount: 42,
|
||||||
|
),
|
||||||
|
const Mailbox(
|
||||||
|
id: 'acc-1:Drafts',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
path: 'Drafts',
|
||||||
|
name: 'Drafts',
|
||||||
|
role: 'drafts',
|
||||||
|
unreadCount: 0,
|
||||||
|
totalCount: 1,
|
||||||
|
),
|
||||||
|
const Mailbox(
|
||||||
|
id: 'acc-1:Trash',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
path: 'Trash',
|
||||||
|
name: 'Trash',
|
||||||
|
role: 'trash',
|
||||||
|
unreadCount: 0,
|
||||||
|
totalCount: 7,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Email shown in the detail scene.
|
||||||
|
final _detailEmail = _email(
|
||||||
|
id: 'acc-1:1',
|
||||||
|
subject: 'Re: Project kick-off next week',
|
||||||
|
fromName: 'Maria Hoffmann',
|
||||||
|
fromEmail: 'maria@corp.example',
|
||||||
|
);
|
||||||
|
|
||||||
|
const _detailBody = EmailBody(
|
||||||
|
emailId: 'acc-1:1',
|
||||||
|
attachments: [],
|
||||||
|
textBody: 'Hi Alice,\n\n'
|
||||||
|
'Sounds great! I will prepare the slides beforehand so we have '
|
||||||
|
'something concrete to discuss.\n\n'
|
||||||
|
'Looking forward to meeting everyone!\n\n'
|
||||||
|
'Best,\nMaria',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Emails shown when the user searches for "invoice".
|
||||||
|
final _searchResults = [
|
||||||
|
_email(
|
||||||
|
id: 'acc-1:2',
|
||||||
|
subject: 'Your invoice #2024-0312 is ready',
|
||||||
|
fromName: 'Billing',
|
||||||
|
fromEmail: 'billing@service.example',
|
||||||
|
isSeen: false,
|
||||||
|
),
|
||||||
|
_email(
|
||||||
|
id: 'acc-1:9',
|
||||||
|
subject: 'Invoice for March services',
|
||||||
|
fromName: 'Cloud Services',
|
||||||
|
fromEmail: 'noreply@cloud.example',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provider override sets for each scene
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
List<Override> _inboxOverrides() => [
|
||||||
|
accountRepositoryProvider.overrideWithValue(
|
||||||
|
FakeAccountRepository([_kAccount]),
|
||||||
|
),
|
||||||
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(_sampleMailboxes),
|
||||||
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(emails: _sampleEmails),
|
||||||
|
),
|
||||||
|
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||||
|
searchHistoryRepositoryProvider.overrideWithValue(
|
||||||
|
FakeSearchHistoryRepository(),
|
||||||
|
),
|
||||||
|
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
|
||||||
|
];
|
||||||
|
|
||||||
|
List<Override> _detailOverrides() => [
|
||||||
|
accountRepositoryProvider.overrideWithValue(
|
||||||
|
FakeAccountRepository([_kAccount]),
|
||||||
|
),
|
||||||
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(_sampleMailboxes),
|
||||||
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(
|
||||||
|
emails: _sampleEmails,
|
||||||
|
emailDetail: _detailEmail,
|
||||||
|
emailBody: _detailBody,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||||
|
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
|
||||||
|
];
|
||||||
|
|
||||||
|
List<Override> _composeOverrides() => [
|
||||||
|
accountRepositoryProvider.overrideWithValue(
|
||||||
|
FakeAccountRepository([_kAccount]),
|
||||||
|
),
|
||||||
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(_sampleMailboxes),
|
||||||
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(emails: _sampleEmails),
|
||||||
|
),
|
||||||
|
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||||
|
searchHistoryRepositoryProvider.overrideWithValue(
|
||||||
|
FakeSearchHistoryRepository(),
|
||||||
|
),
|
||||||
|
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
|
||||||
|
];
|
||||||
|
|
||||||
|
List<Override> _mailboxOverrides() => [
|
||||||
|
accountRepositoryProvider.overrideWithValue(
|
||||||
|
FakeAccountRepository([_kAccount]),
|
||||||
|
),
|
||||||
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(_sampleMailboxes),
|
||||||
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
|
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||||
|
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
|
||||||
|
];
|
||||||
|
|
||||||
|
List<Override> _searchOverrides() => [
|
||||||
|
accountRepositoryProvider.overrideWithValue(
|
||||||
|
FakeAccountRepository([_kAccount]),
|
||||||
|
),
|
||||||
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(_sampleMailboxes),
|
||||||
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(
|
||||||
|
emails: _sampleEmails,
|
||||||
|
searchResults: _searchResults,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||||
|
searchHistoryRepositoryProvider.overrideWithValue(
|
||||||
|
FakeSearchHistoryRepository(),
|
||||||
|
),
|
||||||
|
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests — 3 devices × 2 themes × 5 scenes = 30 golden files
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final device in _devices) {
|
||||||
|
for (final themeMode in [ThemeMode.light, ThemeMode.dark]) {
|
||||||
|
final themeName = themeMode == ThemeMode.light ? 'light' : 'dark';
|
||||||
|
// Golden files are stored relative to this test file (test/).
|
||||||
|
// The ../ prefix places them at repo root under screenshots/.
|
||||||
|
final dir = '../screenshots/${device.name}/$themeName';
|
||||||
|
final prefix = '${device.name}_$themeName';
|
||||||
|
|
||||||
|
group('${device.name}/$themeName', () {
|
||||||
|
void setDevice(WidgetTester tester) {
|
||||||
|
tester.view.physicalSize = Size(device.width, device.height);
|
||||||
|
tester.view.devicePixelRatio = device.dpr;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('inbox_list', (tester) async {
|
||||||
|
setDevice(tester);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
|
overrides: _inboxOverrides(),
|
||||||
|
themeMode: themeMode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await expectLater(
|
||||||
|
find.byType(MaterialApp),
|
||||||
|
matchesGoldenFile('$dir/${prefix}_inbox_list.png'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('email_detail', (tester) async {
|
||||||
|
setDevice(tester);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
// The colon in "acc-1:1" must be percent-encoded in the URL.
|
||||||
|
initialLocation:
|
||||||
|
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A1',
|
||||||
|
overrides: _detailOverrides(),
|
||||||
|
themeMode: themeMode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await expectLater(
|
||||||
|
find.byType(MaterialApp),
|
||||||
|
matchesGoldenFile('$dir/${prefix}_email_detail.png'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('compose', (tester) async {
|
||||||
|
setDevice(tester);
|
||||||
|
// Start at the inbox, then navigate to compose with pre-fill extras
|
||||||
|
// so GoRouter can pass them to ComposeScreen via state.extra.
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
|
overrides: _composeOverrides(),
|
||||||
|
themeMode: themeMode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
GoRouter.of(tester.element(find.byType(EmailListScreen))).go(
|
||||||
|
'/compose',
|
||||||
|
extra: <String, dynamic>{
|
||||||
|
'accountId': 'acc-1',
|
||||||
|
'prefillTo': 'thomas@corp.example',
|
||||||
|
'prefillSubject': 'Re: Team lunch — Friday 12:30',
|
||||||
|
'prefillBody':
|
||||||
|
'Hi Thomas,\n\nCount me in! See you on Friday.\n\nBest,\nAlice',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await expectLater(
|
||||||
|
find.byType(MaterialApp),
|
||||||
|
matchesGoldenFile('$dir/${prefix}_compose.png'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('mailbox_list', (tester) async {
|
||||||
|
setDevice(tester);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes',
|
||||||
|
overrides: _mailboxOverrides(),
|
||||||
|
themeMode: themeMode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await expectLater(
|
||||||
|
find.byType(MaterialApp),
|
||||||
|
matchesGoldenFile('$dir/${prefix}_mailbox_list.png'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('search_results', (tester) async {
|
||||||
|
setDevice(tester);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
|
overrides: _searchOverrides(),
|
||||||
|
themeMode: themeMode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byType(SearchBar), 'invoice');
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await expectLater(
|
||||||
|
find.byType(MaterialApp),
|
||||||
|
matchesGoldenFile('$dir/${prefix}_search_results.png'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,9 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
}) =>
|
}) =>
|
||||||
Stream.value([]);
|
Stream.value([]);
|
||||||
@override
|
@override
|
||||||
|
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
|
||||||
|
Stream.value([]);
|
||||||
|
@override
|
||||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||||
Stream.value([]);
|
Stream.value([]);
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -287,6 +287,17 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
|
returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
|
||||||
) as _i5.Stream<List<_i3.EmailThread>>);
|
) as _i5.Stream<List<_i3.EmailThread>>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i5.Stream<List<_i3.EmailThread>> observeAllInboxThreads({int? limit = 50}) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#observeAllInboxThreads,
|
||||||
|
[],
|
||||||
|
{#limit: limit},
|
||||||
|
),
|
||||||
|
returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
|
||||||
|
) as _i5.Stream<List<_i3.EmailThread>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Stream<List<_i3.Email>> observeEmailsInThread(
|
_i5.Stream<List<_i3.Email>> observeEmailsInThread(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
|
|||||||
@@ -497,6 +497,60 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'searchAddresses prioritises sent-folder addresses over newer received',
|
||||||
|
() async {
|
||||||
|
final r = _makeRepos();
|
||||||
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
|
|
||||||
|
// Register the Sent mailbox so searchAddresses knows its role.
|
||||||
|
await r.db.into(r.db.mailboxes).insert(
|
||||||
|
MailboxesCompanion.insert(
|
||||||
|
id: 'acc-1:Sent',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
path: 'Sent',
|
||||||
|
name: 'Sent',
|
||||||
|
role: const Value('sent'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Older sent email: user deliberately wrote to info@foo.de.
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:sent-1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'Sent',
|
||||||
|
uid: 1,
|
||||||
|
receivedAt: DateTime(2025),
|
||||||
|
toAddresses: const Value(
|
||||||
|
'[{"name":"Foo","email":"info@foo.de"}]',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Newer received email: spam arrived today from info@spam.de.
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:inbox-1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 2,
|
||||||
|
receivedAt: DateTime(2026),
|
||||||
|
fromJson: const Value(
|
||||||
|
'[{"name":"Spam","email":"info@spam.de"}]',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Even though spam is newer, the sent-folder address should win.
|
||||||
|
final results = await r.emails.searchAddresses(null, 'info');
|
||||||
|
expect(results.map((a) => a.email).toList(), [
|
||||||
|
'info@foo.de',
|
||||||
|
'info@spam.de',
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ── IMAP method tests ────────────────────────────────────────────────────
|
// ── IMAP method tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
test(
|
test(
|
||||||
|
|||||||
@@ -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, 36);
|
expect(db.schemaVersion, 38);
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,6 +209,9 @@ void main() {
|
|||||||
// v36: after_mail_view_action column on user_preferences.
|
// v36: after_mail_view_action column on user_preferences.
|
||||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
expect(userPrefsColumns, contains('after_mail_view_action'));
|
||||||
|
|
||||||
|
// v37: image_trusted_senders table.
|
||||||
|
await db.customSelect('SELECT count(*) FROM image_trusted_senders').get();
|
||||||
|
|
||||||
await db.close();
|
await db.close();
|
||||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
});
|
});
|
||||||
@@ -412,12 +415,21 @@ void main() {
|
|||||||
// v36: after_mail_view_action column on user_preferences.
|
// v36: after_mail_view_action column on user_preferences.
|
||||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
expect(userPrefsColumns, contains('after_mail_view_action'));
|
||||||
|
|
||||||
|
// v37: image_trusted_senders table.
|
||||||
|
await db
|
||||||
|
.customSelect('SELECT count(*) FROM image_trusted_senders')
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// v38: prefetch_mode and body_cache_limit_mb columns on user_preferences.
|
||||||
|
expect(userPrefsColumns, contains('prefetch_mode'));
|
||||||
|
expect(userPrefsColumns, contains('body_cache_limit_mb'));
|
||||||
|
|
||||||
await db.close();
|
await db.close();
|
||||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test('fresh install creates all tables at schemaVersion 36', () async {
|
test('fresh install creates all tables at schemaVersion 38', () async {
|
||||||
final db = AppDatabase(NativeDatabase.memory());
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
await db.select(db.accounts).get();
|
await db.select(db.accounts).get();
|
||||||
|
|
||||||
@@ -445,6 +457,7 @@ void main() {
|
|||||||
'share_keys', // v31
|
'share_keys', // v31
|
||||||
'local_sieve_applied', // v32
|
'local_sieve_applied', // v32
|
||||||
'user_preferences', // v34
|
'user_preferences', // v34
|
||||||
|
'image_trusted_senders', // v37
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -473,6 +486,13 @@ void main() {
|
|||||||
// v36: after_mail_view_action column on user_preferences.
|
// v36: after_mail_view_action column on user_preferences.
|
||||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
expect(userPrefsColumns, contains('after_mail_view_action'));
|
||||||
|
|
||||||
|
// v37: image_trusted_senders table.
|
||||||
|
await db.customSelect('SELECT count(*) FROM image_trusted_senders').get();
|
||||||
|
|
||||||
|
// v38: prefetch_mode and body_cache_limit_mb columns on user_preferences.
|
||||||
|
expect(userPrefsColumns, contains('prefetch_mode'));
|
||||||
|
expect(userPrefsColumns, contains('body_cache_limit_mb'));
|
||||||
|
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ class _FakeEmails implements EmailRepository {
|
|||||||
}) =>
|
}) =>
|
||||||
Stream.value([]);
|
Stream.value([]);
|
||||||
@override
|
@override
|
||||||
|
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
|
||||||
|
Stream.value([]);
|
||||||
|
@override
|
||||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||||
Stream.value([]);
|
Stream.value([]);
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ class _CountingEmails implements EmailRepository {
|
|||||||
}) =>
|
}) =>
|
||||||
Stream.value([]);
|
Stream.value([]);
|
||||||
@override
|
@override
|
||||||
|
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
|
||||||
|
Stream.value([]);
|
||||||
|
@override
|
||||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||||
Stream.value([]);
|
Stream.value([]);
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -109,6 +109,17 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
|||||||
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
|
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
|
||||||
) as _i4.Stream<List<_i2.EmailThread>>);
|
) as _i4.Stream<List<_i2.EmailThread>>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i4.Stream<List<_i2.EmailThread>> observeAllInboxThreads({int? limit = 50}) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#observeAllInboxThreads,
|
||||||
|
[],
|
||||||
|
{#limit: limit},
|
||||||
|
),
|
||||||
|
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
|
||||||
|
) as _i4.Stream<List<_i2.EmailThread>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Stream<List<_i2.Email>> observeEmailsInThread(
|
_i4.Stream<List<_i2.Email>> observeEmailsInThread(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
|
|||||||
@@ -86,9 +86,11 @@ void main() {
|
|||||||
expect(find.textContaining('DB Schema Version'), findsWidgets);
|
expect(find.textContaining('DB Schema Version'), findsWidgets);
|
||||||
// Buttons are in the body, not in the AppBar actions
|
// Buttons are in the body, not in the AppBar actions
|
||||||
expect(find.byIcon(Icons.copy), findsOneWidget);
|
expect(find.byIcon(Icons.copy), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.bug_report), findsOneWidget);
|
expect(find.byIcon(Icons.bug_report_outlined), findsOneWidget);
|
||||||
expect(find.text('Copy to clipboard'), findsOneWidget);
|
expect(find.byIcon(Icons.feedback_outlined), findsOneWidget);
|
||||||
expect(find.text('Create issue'), findsOneWidget);
|
expect(find.text('Copy info'), findsOneWidget);
|
||||||
|
expect(find.text('Public issue'), findsOneWidget);
|
||||||
|
expect(find.text('Report bug'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('AboutScreen shows correct IMAP and JMAP account counts', (
|
testWidgets('AboutScreen shows correct IMAP and JMAP account counts', (
|
||||||
@@ -193,7 +195,7 @@ void main() {
|
|||||||
await tester.pumpWidget(_buildScreen());
|
await tester.pumpWidget(_buildScreen());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.bug_report));
|
await tester.tap(find.byIcon(Icons.bug_report_outlined));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
@Tags(['golden'])
|
||||||
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/misc.dart' show Override;
|
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|||||||
@@ -586,6 +586,8 @@ void main() {
|
|||||||
// Delete the email from the detail screen.
|
// Delete the email from the detail screen.
|
||||||
await tester.tap(find.byIcon(Icons.delete));
|
await tester.tap(find.byIcon(Icons.delete));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Should have popped all the way back to the mailbox list.
|
// Should have popped all the way back to the mailbox list.
|
||||||
expect(find.byType(EmailDetailScreen), findsNothing);
|
expect(find.byType(EmailDetailScreen), findsNothing);
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 89 KiB |
@@ -4,6 +4,7 @@
|
|||||||
// as the real app) inside a ProviderScope whose repository providers are
|
// as the real app) inside a ProviderScope whose repository providers are
|
||||||
// replaced with lightweight in-memory fakes. No database or network is used.
|
// replaced with lightweight in-memory fakes. No database or network is used.
|
||||||
|
|
||||||
|
import 'package:drift/native.dart';
|
||||||
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:flutter_riverpod/misc.dart' show Override;
|
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||||
@@ -27,7 +28,8 @@ import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
|||||||
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
||||||
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
||||||
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
||||||
import 'package:sharedinbox/data/db/database.dart' show SyncHealthRow;
|
import 'package:sharedinbox/data/db/database.dart'
|
||||||
|
show AppDatabase, SyncHealthRow;
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
|
import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
|
||||||
@@ -245,6 +247,10 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
}).toList();
|
}).toList();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
|
||||||
|
Stream.value([]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<List<Email>> observeEmailsInThread(
|
Stream<List<Email>> observeEmailsInThread(
|
||||||
String accountId,
|
String accountId,
|
||||||
@@ -415,6 +421,8 @@ Widget buildApp({
|
|||||||
required String initialLocation,
|
required String initialLocation,
|
||||||
required List<Override> overrides,
|
required List<Override> overrides,
|
||||||
UserPreferencesRepository? userPreferences,
|
UserPreferencesRepository? userPreferences,
|
||||||
|
ThemeMode themeMode = ThemeMode.light,
|
||||||
|
bool debugShowCheckedModeBanner = true,
|
||||||
}) {
|
}) {
|
||||||
final testRouter = GoRouter(
|
final testRouter = GoRouter(
|
||||||
initialLocation: initialLocation,
|
initialLocation: initialLocation,
|
||||||
@@ -520,6 +528,11 @@ Widget buildApp({
|
|||||||
// is still pending". Replacing it with a synchronous stream avoids this.
|
// is still pending". Replacing it with a synchronous stream avoids this.
|
||||||
// syncHealthProvider has the same issue and is overridden in baseOverrides.
|
// syncHealthProvider has the same issue and is overridden in baseOverrides.
|
||||||
overrides: [
|
overrides: [
|
||||||
|
dbProvider.overrideWith((ref) {
|
||||||
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
|
ref.onDispose(db.close);
|
||||||
|
return db;
|
||||||
|
}),
|
||||||
syncLogRepositoryProvider.overrideWithValue(
|
syncLogRepositoryProvider.overrideWithValue(
|
||||||
const NoOpSyncLogRepository(),
|
const NoOpSyncLogRepository(),
|
||||||
),
|
),
|
||||||
@@ -533,10 +546,19 @@ Widget buildApp({
|
|||||||
],
|
],
|
||||||
child: MaterialApp.router(
|
child: MaterialApp.router(
|
||||||
routerConfig: testRouter,
|
routerConfig: testRouter,
|
||||||
|
themeMode: themeMode,
|
||||||
|
debugShowCheckedModeBanner: debugShowCheckedModeBanner,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
),
|
),
|
||||||
|
darkTheme: ThemeData(
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: Colors.indigo,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
),
|
||||||
|
useMaterial3: true,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -627,11 +649,13 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
|
|||||||
this.menuPosition = MenuPosition.bottom,
|
this.menuPosition = MenuPosition.bottom,
|
||||||
this.mailViewButtonPosition = MenuPosition.bottom,
|
this.mailViewButtonPosition = MenuPosition.bottom,
|
||||||
this.afterMailViewAction = AfterMailViewAction.nextMessage,
|
this.afterMailViewAction = AfterMailViewAction.nextMessage,
|
||||||
});
|
List<String>? trustedImageSenders,
|
||||||
|
}) : _trustedImageSenders = trustedImageSenders ?? [];
|
||||||
|
|
||||||
MenuPosition menuPosition;
|
MenuPosition menuPosition;
|
||||||
MenuPosition mailViewButtonPosition;
|
MenuPosition mailViewButtonPosition;
|
||||||
AfterMailViewAction afterMailViewAction;
|
AfterMailViewAction afterMailViewAction;
|
||||||
|
final List<String> _trustedImageSenders;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<UserPreferences> observePreferences() => Stream.value(
|
Stream<UserPreferences> observePreferences() => Stream.value(
|
||||||
@@ -656,6 +680,29 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
|
|||||||
Future<void> updateAfterMailViewAction(AfterMailViewAction action) async {
|
Future<void> updateAfterMailViewAction(AfterMailViewAction action) async {
|
||||||
afterMailViewAction = action;
|
afterMailViewAction = action;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updatePrefetchMode(PrefetchMode mode) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateBodyCacheLimitMb(int mb) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<String>> observeTrustedImageSenders() =>
|
||||||
|
Stream.value(List.of(_trustedImageSenders));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> addTrustedImageSender(String senderEmail) async {
|
||||||
|
final normalized = senderEmail.toLowerCase();
|
||||||
|
if (!_trustedImageSenders.contains(normalized)) {
|
||||||
|
_trustedImageSenders.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> removeTrustedImageSender(String senderEmail) async {
|
||||||
|
_trustedImageSenders.remove(senderEmail.toLowerCase());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeSearchHistoryRepository implements SearchHistoryRepository {
|
class FakeSearchHistoryRepository implements SearchHistoryRepository {
|
||||||
|
|||||||