Compare commits
23
Commits
@@ -3,7 +3,41 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'lib/**'
|
||||
- 'test/**'
|
||||
- 'integration_test/**'
|
||||
- 'android/**'
|
||||
- 'linux/**'
|
||||
- 'assets/**'
|
||||
- '!assets/changelog.txt'
|
||||
- 'pubspec.yaml'
|
||||
- 'pubspec.lock'
|
||||
- 'analysis_options.yaml'
|
||||
- 'scripts/**'
|
||||
- 'stalwart-dev/**'
|
||||
- 'ci/**'
|
||||
- 'Taskfile.yml'
|
||||
- 'drift_schemas/**'
|
||||
- '.forgejo/workflows/ci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'lib/**'
|
||||
- 'test/**'
|
||||
- 'integration_test/**'
|
||||
- 'android/**'
|
||||
- 'linux/**'
|
||||
- 'assets/**'
|
||||
- '!assets/changelog.txt'
|
||||
- 'pubspec.yaml'
|
||||
- 'pubspec.lock'
|
||||
- 'analysis_options.yaml'
|
||||
- 'scripts/**'
|
||||
- 'stalwart-dev/**'
|
||||
- 'ci/**'
|
||||
- 'Taskfile.yml'
|
||||
- 'drift_schemas/**'
|
||||
- '.forgejo/workflows/ci.yml'
|
||||
|
||||
jobs:
|
||||
check:
|
||||
@@ -55,7 +89,10 @@ jobs:
|
||||
- name: Prune Dagger cache before check
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: dagger query '{ engine { localCache { prune } } }' 2>/dev/null || true
|
||||
# prune(maxUsedSpace) also reclaims named cache volumes (gradle-cache, go-build-cache, etc.)
|
||||
# when total cache exceeds the limit; without args only unreferenced entries are removed.
|
||||
run: |
|
||||
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
|
||||
|
||||
- name: Run Full Check Suite
|
||||
env:
|
||||
@@ -66,7 +103,8 @@ jobs:
|
||||
if: always()
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: dagger query '{ engine { localCache { prune } } }' 2>/dev/null || true
|
||||
run: |
|
||||
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
|
||||
@@ -6,10 +6,55 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-changes:
|
||||
name: Detect Changed Files
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
android: ${{ steps.diff.outputs.android }}
|
||||
linux: ${{ steps.diff.outputs.linux }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect Android and Linux changes
|
||||
id: diff
|
||||
shell: bash
|
||||
run: |
|
||||
# On workflow_dispatch always build everything
|
||||
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
|
||||
# when the parent is unavailable (initial commit, shallow clone).
|
||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
||||
|| git show --name-only --format= HEAD)
|
||||
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED"
|
||||
|
||||
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/)'
|
||||
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
||||
|
||||
echo "$CHANGED" | grep -qE "$android_re" \
|
||||
&& echo "android=true" >> "$GITHUB_OUTPUT" \
|
||||
|| echo "android=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "$CHANGED" | grep -qE "$linux_re" \
|
||||
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|
||||
|| echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
test-android-firebase:
|
||||
name: Android Instrumented Tests (Firebase Test Lab)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
needs: [check-changes]
|
||||
if: needs.check-changes.outputs.android == 'true'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -46,6 +91,8 @@ jobs:
|
||||
name: Build & Deploy to Play Store
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
needs: [check-changes]
|
||||
if: needs.check-changes.outputs.android == 'true'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -83,6 +130,8 @@ jobs:
|
||||
name: Build & Deploy APK to Server
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
needs: [check-changes]
|
||||
if: needs.check-changes.outputs.android == 'true'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -107,6 +156,7 @@ jobs:
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
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 }}
|
||||
@@ -122,6 +172,8 @@ jobs:
|
||||
name: Build Linux Release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
needs: [check-changes]
|
||||
if: needs.check-changes.outputs.linux == 'true'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -146,6 +198,7 @@ jobs:
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
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"
|
||||
@@ -187,6 +240,7 @@ jobs:
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
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"
|
||||
@@ -200,7 +254,13 @@ jobs:
|
||||
name: Update Deploy Health Label
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux]
|
||||
if: always() && vars.DEPLOY_HEALTH_ISSUE != ''
|
||||
if: |
|
||||
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
|
||||
needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'failure' ||
|
||||
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
|
||||
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
|
||||
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
|
||||
)
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
@@ -209,7 +269,7 @@ jobs:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
FORGEJO_URL: ${{ github.server_url }}
|
||||
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
|
||||
ALL_SUCCEEDED: ${{ needs.test-android-firebase.result == 'success' && needs.deploy-playstore.result == 'success' && needs.deploy-apk.result == 'success' && needs.build-linux.result == 'success' }}
|
||||
ALL_SUCCEEDED: ${{ (needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'skipped') && (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
|
||||
run: |
|
||||
python3 - << 'PYEOF'
|
||||
import os, json, urllib.request, urllib.error
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
name: Monitor Agent Loop
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */2 * * *' # every 2 hours
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
monitor:
|
||||
name: Check Agent Loop Health
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check agent loop heartbeat
|
||||
run: python3 scripts/agent_loop.py monitor
|
||||
@@ -202,6 +202,8 @@ jobs:
|
||||
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: |
|
||||
@@ -215,20 +217,20 @@ jobs:
|
||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
|
||||
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
||||
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 -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" \
|
||||
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 -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
else
|
||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
fi
|
||||
|
||||
- name: Generate build history pages
|
||||
@@ -244,6 +246,5 @@ jobs:
|
||||
rsync -avz --delete \
|
||||
--exclude='*.apk' \
|
||||
--exclude='*.tar.gz' \
|
||||
-e "ssh -o StrictHostKeyChecking=no" \
|
||||
website/public/ \
|
||||
"$SSH_USER@$SSH_HOST:public_html/"
|
||||
|
||||
+48
-17
@@ -215,8 +215,10 @@ tasks:
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||
msg: "SSH_PRIVATE_KEY is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||
- HASH=$(git rev-parse --short HEAD) && 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:
|
||||
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
||||
@@ -251,17 +253,24 @@ tasks:
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||
msg: "SSH_PRIVATE_KEY is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
- sh: test -n "$ANDROID_KEYSTORE_BASE64"
|
||||
msg: "ANDROID_KEYSTORE_BASE64 is not set"
|
||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||
cmds:
|
||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
|
||||
- HASH=$(git rev-parse --short HEAD) && 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:
|
||||
desc: Build and publish website via Dagger
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||
msg: "SSH_PRIVATE_KEY is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key file:$HOME/.ssh/id_ed25519 --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST"
|
||||
- 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"
|
||||
|
||||
check-dagger:
|
||||
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
||||
@@ -288,7 +297,7 @@ tasks:
|
||||
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
|
||||
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
|
||||
dagger query '{ engine { localCache { prune } } }' 2>/dev/null || true
|
||||
dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
|
||||
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
|
||||
sleep 90
|
||||
else
|
||||
@@ -320,6 +329,12 @@ tasks:
|
||||
wait "$RECV_PID" 2>/dev/null || true
|
||||
exit $RC
|
||||
|
||||
dagger-prune:
|
||||
desc: Prune the Dagger engine cache (keeps named volumes unless total exceeds 75 GB, then targets 50 GB)
|
||||
cmds:
|
||||
- |
|
||||
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
|
||||
|
||||
integration-android:
|
||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
||||
deps: [_preflight, _android-sdk-check, _android-avd-setup]
|
||||
@@ -367,25 +382,29 @@ tasks:
|
||||
msg: "SSH_USER is not set"
|
||||
- sh: test -n "$SSH_HOST"
|
||||
msg: "SSH_HOST is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
|
||||
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
||||
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"
|
||||
# Merge with any existing latest.json so we don't overwrite the windows key
|
||||
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||
WINDOWS_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" 2>/dev/null || true)
|
||||
if [ -n "$WINDOWS_URL" ]; then
|
||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
else
|
||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
fi
|
||||
echo "Uploaded $TARBALL and updated latest.json"
|
||||
|
||||
@@ -410,24 +429,28 @@ tasks:
|
||||
msg: "SSH_USER is not set"
|
||||
- sh: test -n "$SSH_HOST"
|
||||
msg: "SSH_HOST is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||
ZIPFILE="sharedinbox-windows-x64-$HASH.zip"
|
||||
cd build/windows/x64/runner && zip -r /tmp/$ZIPFILE Release/ && cd -
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp -o StrictHostKeyChecking=no /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
|
||||
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
|
||||
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$ZIPFILE"
|
||||
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||
LINUX_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('linux',''))" 2>/dev/null || true)
|
||||
if [ -n "$LINUX_URL" ]; then
|
||||
echo "{\"version\":\"$HASH\",\"linux\":\"$LINUX_URL\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
else
|
||||
echo "{\"version\":\"$HASH\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
fi
|
||||
echo "Uploaded $ZIPFILE and updated latest.json"
|
||||
|
||||
@@ -577,14 +600,18 @@ tasks:
|
||||
msg: "SSH_USER is not set"
|
||||
- sh: test -n "$SSH_HOST"
|
||||
msg: "SSH_HOST is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||
APK_NAME="sharedinbox-mua-$HASH.apk"
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp -o StrictHostKeyChecking=no \
|
||||
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp \
|
||||
build/app/outputs/flutter-apk/app-release.apk \
|
||||
"$SSH_USER@$SSH_HOST:$REMOTE_DIR/$APK_NAME"
|
||||
echo "Uploaded $APK_NAME to $REMOTE_DIR"
|
||||
@@ -613,12 +640,16 @@ tasks:
|
||||
website-deploy:
|
||||
desc: Deploy the website via rsync to public_html
|
||||
deps: [website-build]
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||
rsync -avz --delete \
|
||||
--exclude='*.apk' \
|
||||
--exclude='*.tar.gz' \
|
||||
-e "ssh -o StrictHostKeyChecking=no" \
|
||||
website/public/ \
|
||||
${SSH_USER}@${SSH_HOST}:public_html/
|
||||
|
||||
|
||||
+34
-19
@@ -318,12 +318,13 @@ func (m *Ci) Hugo() *dagger.Container {
|
||||
}
|
||||
|
||||
// Deploy container for rsync/ssh
|
||||
func (m *Ci) Deployer(sshKey *dagger.Secret) *dagger.Container {
|
||||
func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.Container {
|
||||
return dag.Container().
|
||||
From("alpine:3.21").
|
||||
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
|
||||
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||
WithEnvVariable("RSYNC_RSH", "ssh -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519")
|
||||
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
|
||||
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
|
||||
}
|
||||
|
||||
// Stalwart mail server service for backend and integration tests.
|
||||
@@ -514,6 +515,7 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
||||
func (m *Ci) GenerateBuildHistory(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
) *dagger.Directory {
|
||||
@@ -525,7 +527,7 @@ func (m *Ci) GenerateBuildHistory(
|
||||
From("python:3.12-alpine").
|
||||
WithExec([]string{"apk", "add", "--no-cache", "openssh-client"}).
|
||||
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||
WithExec([]string{"chmod", "700", "/root/.ssh"}).
|
||||
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
|
||||
WithEnvVariable("SSH_USER", sshUser).
|
||||
WithEnvVariable("SSH_HOST", sshHost).
|
||||
WithDirectory("/src", scriptSource).
|
||||
@@ -538,10 +540,11 @@ func (m *Ci) GenerateBuildHistory(
|
||||
func (m *Ci) BuildWebsite(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
) *dagger.Directory {
|
||||
buildHistory := m.GenerateBuildHistory(ctx, sshKey, sshUser, sshHost)
|
||||
buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
|
||||
|
||||
websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"website/"},
|
||||
@@ -558,12 +561,13 @@ func (m *Ci) BuildWebsite(
|
||||
func (m *Ci) PublishWebsite(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
) (string, error) {
|
||||
public := m.BuildWebsite(ctx, sshKey, sshUser, sshHost)
|
||||
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost)
|
||||
|
||||
return m.Deployer(sshKey).
|
||||
return m.Deployer(sshKey, knownHosts).
|
||||
WithDirectory("/public", public).
|
||||
WithExec([]string{"rsync", "-avz", "--delete",
|
||||
"--exclude=*.apk", "--exclude=*.tar.gz",
|
||||
@@ -589,6 +593,7 @@ func (m *Ci) BuildLinuxRelease() *dagger.Directory {
|
||||
func (m *Ci) DeployLinux(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
commitHash string,
|
||||
@@ -599,11 +604,11 @@ func (m *Ci) DeployLinux(
|
||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||
tarball := fmt.Sprintf("sharedinbox-linux-amd64-%s.tar.gz", commitHash)
|
||||
|
||||
return m.Deployer(sshKey).
|
||||
return m.Deployer(sshKey, knownHosts).
|
||||
WithDirectory("/bundle", bundle).
|
||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("tar -czf /tmp/%s -C /bundle .", tarball)}).
|
||||
WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}).
|
||||
WithExec([]string{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
@@ -626,6 +631,7 @@ func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *da
|
||||
func (m *Ci) DeployApk(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
commitHash string,
|
||||
@@ -639,10 +645,10 @@ func (m *Ci) DeployApk(
|
||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||
apkName := fmt.Sprintf("sharedinbox-mua-%s.apk", commitHash)
|
||||
|
||||
return m.Deployer(sshKey).
|
||||
return m.Deployer(sshKey, knownHosts).
|
||||
WithFile("/tmp/app.apk", apk).
|
||||
WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}).
|
||||
WithExec([]string{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
@@ -739,7 +745,7 @@ func (m *Ci) UploadToPlayStore(
|
||||
From("python:3.12-alpine").
|
||||
WithExec([]string{"apk", "add", "--no-cache", "curl"}).
|
||||
WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")).
|
||||
WithExec([]string{"pip", "install", "requests", "google-auth"}).
|
||||
WithExec([]string{"pip", "install", "google-auth", "requests"}).
|
||||
WithFile("/src/build/app/outputs/bundle/release/app-release.aab", aab).
|
||||
WithFile("/src/scripts/deploy_playstore.py", scriptSource.File("scripts/deploy_playstore.py")).
|
||||
WithSecretVariable("PLAY_STORE_CONFIG_JSON", playStoreConfig).
|
||||
@@ -835,16 +841,25 @@ flowchart TD
|
||||
integration --> check
|
||||
end
|
||||
|
||||
subgraph forgejo ["Codeberg CI · .forgejo/workflows/ci.yml"]
|
||||
subgraph forgejo_ci ["Codeberg CI · ci.yml (push/PR, source paths only)"]
|
||||
ciCheck["check"]
|
||||
buildLinux["build-linux\n(main only)"]
|
||||
deployPS["deploy-playstore\n(main only)"]
|
||||
pubWeb["publish-website\n(main only)"]
|
||||
end
|
||||
|
||||
ciCheck --> buildLinux
|
||||
ciCheck --> deployPS
|
||||
subgraph forgejo_deploy ["Codeberg CI · deploy.yml (hourly schedule + workflow_dispatch)"]
|
||||
detectChanges["check-changes\ndetect android / linux diff"]
|
||||
buildLinux["build-linux\n(linux changed)"]
|
||||
deployPS["deploy-playstore\n(android changed)"]
|
||||
deployApk["deploy-apk\n(android changed)"]
|
||||
fbTest["test-android-firebase\n(android changed)"]
|
||||
pubWeb["publish-website\n(any build succeeded)"]
|
||||
|
||||
detectChanges --> buildLinux
|
||||
detectChanges --> deployPS
|
||||
detectChanges --> deployApk
|
||||
detectChanges --> fbTest
|
||||
buildLinux --> pubWeb
|
||||
deployPS --> pubWeb
|
||||
deployApk --> pubWeb
|
||||
end
|
||||
|
||||
check -- "task check-dagger" --> ciCheck
|
||||
|
||||
@@ -94,8 +94,9 @@
|
||||
sqlite
|
||||
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
|
||||
(python3.withPackages (ps: with ps; [
|
||||
google-auth
|
||||
requests
|
||||
google-api-python-client
|
||||
google-auth-httplib2
|
||||
httplib2
|
||||
])) # used by stalwart-dev/start and deploy_playstore.py
|
||||
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
||||
]);
|
||||
|
||||
@@ -19,6 +19,8 @@ class SyncLogEntry {
|
||||
required this.id,
|
||||
required this.result,
|
||||
this.errorMessage,
|
||||
this.stackTrace,
|
||||
this.isPermanent = false,
|
||||
required this.protocol,
|
||||
required this.emailsFetched,
|
||||
required this.emailsSkipped,
|
||||
@@ -34,6 +36,8 @@ class SyncLogEntry {
|
||||
final int id;
|
||||
final String result; // 'ok' or 'error'
|
||||
final String? errorMessage;
|
||||
final String? stackTrace;
|
||||
final bool isPermanent;
|
||||
final String protocol; // 'imap' or 'jmap'
|
||||
final int emailsFetched;
|
||||
final int emailsSkipped;
|
||||
@@ -54,6 +58,8 @@ abstract class SyncLogRepository {
|
||||
required String accountId,
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool isPermanent = false,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
@@ -81,6 +87,8 @@ class NoOpSyncLogRepository implements SyncLogRepository {
|
||||
required String accountId,
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool isPermanent = false,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||
import 'package:flutter/services.dart' show MissingPluginException;
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult;
|
||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||
@@ -259,6 +260,8 @@ class _AccountSync implements _SyncLoop {
|
||||
accountId: account.id,
|
||||
success: false,
|
||||
errorMessage: e.toString(),
|
||||
stackTrace: st.toString(),
|
||||
isPermanent: isPermanent,
|
||||
protocol: 'imap',
|
||||
emailsFetched: 0,
|
||||
emailsSkipped: 0,
|
||||
@@ -294,6 +297,7 @@ class _AccountSync implements _SyncLoop {
|
||||
|
||||
bool _isPermanentError(Object e) {
|
||||
if (isTlsConfigError(e)) return true;
|
||||
if (e is MissingPluginException) return true;
|
||||
final s = e.toString().toLowerCase();
|
||||
// enough_mail doesn't always have typed exceptions for auth, so we check strings.
|
||||
return s.contains('invalid credentials') ||
|
||||
@@ -511,6 +515,8 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
accountId: account.id,
|
||||
success: false,
|
||||
errorMessage: e.toString(),
|
||||
stackTrace: st.toString(),
|
||||
isPermanent: isPermanent,
|
||||
protocol: 'jmap',
|
||||
emailsFetched: 0,
|
||||
emailsSkipped: 0,
|
||||
@@ -546,6 +552,7 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
|
||||
bool _isPermanentError(Object e) {
|
||||
if (isTlsConfigError(e)) return true;
|
||||
if (e is MissingPluginException) return true;
|
||||
final s = e.toString().toLowerCase();
|
||||
return s.contains('invalid credentials') ||
|
||||
s.contains('authentication failed') ||
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
@@ -24,6 +25,9 @@ const _kResourceType = 'background_check';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void callbackDispatcher() {
|
||||
// Required so that path_provider and other plugins are available in this
|
||||
// background isolate (issue #192).
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
Workmanager().executeTask((_, __) async {
|
||||
try {
|
||||
await _doBackgroundSync();
|
||||
|
||||
@@ -192,6 +192,9 @@ class SyncLogs extends Table {
|
||||
DateTimeColumn get finishedAt => dateTime()();
|
||||
// Added in schema v13: raw protocol log when account.verbose == true.
|
||||
TextColumn get protocolLog => text().nullable()();
|
||||
// Added in schema v33: stack trace and permanent flag for error entries.
|
||||
TextColumn get errorStackTrace => text().nullable()();
|
||||
BoolColumn get isPermanent => boolean().withDefault(const Constant(false))();
|
||||
}
|
||||
|
||||
/// Per-mailbox breakdown for a single sync cycle.
|
||||
@@ -329,7 +332,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 32;
|
||||
int get schemaVersion => 33;
|
||||
|
||||
Future<void> _createEmailFts() async {
|
||||
await customStatement('''
|
||||
@@ -570,6 +573,10 @@ class AppDatabase extends _$AppDatabase {
|
||||
if (from < 32) {
|
||||
await m.createTable(localSieveApplied);
|
||||
}
|
||||
if (from >= 7 && from < 33) {
|
||||
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
|
||||
await m.addColumn(syncLogs, syncLogs.isPermanent);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -609,6 +616,17 @@ Future<String> _resolveDatabasePath() async {
|
||||
await Future<void>.delayed(Duration(milliseconds: ms));
|
||||
}
|
||||
}
|
||||
// On Android, path_provider can be permanently broken on some devices
|
||||
// regardless of how long we wait (issue #192). Derive the path from
|
||||
// /proc/self/cmdline (the Android process name == package name) without
|
||||
// a platform channel as a last resort so the app can still open its DB.
|
||||
if (Platform.isAndroid) {
|
||||
final fallback = await _androidFallbackPath();
|
||||
if (fallback != null) {
|
||||
_dbPath = fallback;
|
||||
return _dbPath!;
|
||||
}
|
||||
}
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'path_provider unavailable after ${delays.length + 1} attempts — '
|
||||
@@ -616,10 +634,44 @@ Future<String> _resolveDatabasePath() async {
|
||||
);
|
||||
}
|
||||
|
||||
// These two functions are only called from unit tests (database_path_test.dart).
|
||||
// Reads /proc/self/cmdline to extract the Android package name, then
|
||||
// constructs the standard app files-dir path without a platform channel.
|
||||
// Returns null when the path cannot be determined or created.
|
||||
Future<String?> _androidFallbackPath() async {
|
||||
try {
|
||||
final bytes = await File('/proc/self/cmdline').readAsBytes();
|
||||
final end = bytes.indexOf(0);
|
||||
final packageName = String.fromCharCodes(
|
||||
end >= 0 ? bytes.sublist(0, end) : bytes,
|
||||
).trim();
|
||||
// A valid Android package name contains dots but not slashes.
|
||||
if (packageName.isEmpty ||
|
||||
!packageName.contains('.') ||
|
||||
packageName.contains('/')) {
|
||||
return null;
|
||||
}
|
||||
for (final base in [
|
||||
'/data/user/0/$packageName/files',
|
||||
'/data/data/$packageName/files',
|
||||
]) {
|
||||
try {
|
||||
await Directory(base).create(recursive: true);
|
||||
return p.join(base, 'sharedinbox.db');
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// These functions are only called from unit tests (database_path_test.dart).
|
||||
// They expose internals that cannot be reached via the public API.
|
||||
Future<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
|
||||
void resetDatabasePathForTesting() => _dbPath = null;
|
||||
Future<String?> androidFallbackPathForTesting() => _androidFallbackPath();
|
||||
|
||||
LazyDatabase _openConnection() {
|
||||
return LazyDatabase(() async {
|
||||
|
||||
@@ -13,6 +13,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
required String accountId,
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool isPermanent = false,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
@@ -30,6 +32,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
accountId: accountId,
|
||||
result: success ? 'ok' : 'error',
|
||||
errorMessage: Value(errorMessage),
|
||||
errorStackTrace: Value(stackTrace),
|
||||
isPermanent: Value(isPermanent),
|
||||
protocol: Value(protocol),
|
||||
itemsSynced: Value(emailsFetched),
|
||||
emailsSkipped: Value(emailsSkipped),
|
||||
@@ -75,6 +79,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
id: r.id,
|
||||
result: r.result,
|
||||
errorMessage: r.errorMessage,
|
||||
stackTrace: r.errorStackTrace,
|
||||
isPermanent: r.isPermanent,
|
||||
protocol: r.protocol,
|
||||
emailsFetched: r.itemsSynced,
|
||||
emailsSkipped: r.emailsSkipped,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
@@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/utils/about_markdown.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class AboutScreen extends ConsumerStatefulWidget {
|
||||
@@ -19,53 +20,15 @@ class AboutScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
||||
final Future<AndroidDeviceInfo?> _androidInfoFuture = getAndroidDeviceInfo();
|
||||
late final Stream<List<Account>> _accountsStream;
|
||||
|
||||
static const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
||||
}
|
||||
|
||||
String _buildMarkdown(
|
||||
BuildContext context,
|
||||
PackageInfo? pkg,
|
||||
int imapCount,
|
||||
int jmapCount,
|
||||
) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
final physW = (size.width * pixelRatio).toInt();
|
||||
final physH = (size.height * pixelRatio).toInt();
|
||||
final version =
|
||||
pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
|
||||
final versionDisplay = _gitHash.isNotEmpty
|
||||
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
|
||||
: version;
|
||||
final osName = _capitalize(Platform.operatingSystem);
|
||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||
|
||||
return '## sharedinbox.de\n\n'
|
||||
'| Property | Value |\n'
|
||||
'|----------|-------|\n'
|
||||
'| App Version | $versionDisplay |\n'
|
||||
'| Platform | ${Platform.operatingSystem} |\n'
|
||||
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
||||
'| Resolution | ${physW}x$physH px'
|
||||
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
||||
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
||||
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
||||
'| Processors | ${Platform.numberOfProcessors} |\n'
|
||||
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
||||
'| IMAP Accounts | $imapCount |\n'
|
||||
'| JMAP Accounts | $jmapCount |\n';
|
||||
}
|
||||
|
||||
static String _capitalize(String s) =>
|
||||
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
|
||||
|
||||
Future<void> _copyToClipboard(
|
||||
BuildContext context,
|
||||
int imapCount,
|
||||
@@ -75,10 +38,17 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
try {
|
||||
pkg = await _packageInfoFuture;
|
||||
} catch (_) {}
|
||||
final androidInfo = await _androidInfoFuture;
|
||||
if (!context.mounted) return;
|
||||
await Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: _buildMarkdown(context, pkg, imapCount, jmapCount),
|
||||
text: buildAboutMarkdown(
|
||||
context: context,
|
||||
pkg: pkg,
|
||||
imapCount: imapCount,
|
||||
jmapCount: jmapCount,
|
||||
androidInfo: androidInfo,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (context.mounted) {
|
||||
@@ -100,9 +70,16 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
try {
|
||||
pkg = await _packageInfoFuture;
|
||||
} catch (_) {}
|
||||
final androidInfo = await _androidInfoFuture;
|
||||
if (!context.mounted) return;
|
||||
final body = Uri.encodeComponent(
|
||||
_buildMarkdown(context, pkg, imapCount, jmapCount),
|
||||
buildAboutMarkdown(
|
||||
context: context,
|
||||
pkg: pkg,
|
||||
imapCount: imapCount,
|
||||
jmapCount: jmapCount,
|
||||
androidInfo: androidInfo,
|
||||
),
|
||||
);
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
||||
@@ -152,23 +129,29 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return Markdown(
|
||||
data: _buildMarkdown(
|
||||
context,
|
||||
snapshot.data,
|
||||
imapCount,
|
||||
jmapCount,
|
||||
),
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) {
|
||||
if (href != null) {
|
||||
unawaited(
|
||||
launchUrl(
|
||||
Uri.parse(href),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
);
|
||||
}
|
||||
return FutureBuilder<AndroidDeviceInfo?>(
|
||||
future: _androidInfoFuture,
|
||||
builder: (context, androidSnapshot) {
|
||||
return Markdown(
|
||||
data: buildAboutMarkdown(
|
||||
context: context,
|
||||
pkg: snapshot.data,
|
||||
imapCount: imapCount,
|
||||
jmapCount: jmapCount,
|
||||
androidInfo: androidSnapshot.data,
|
||||
),
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) {
|
||||
if (href != null) {
|
||||
unawaited(
|
||||
launchUrl(
|
||||
Uri.parse(href),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -37,6 +37,9 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
bool _scannerActive = false;
|
||||
|
||||
MobileScannerController? _scannerController;
|
||||
// True when the scanner plugin fails to initialise at runtime (e.g.
|
||||
// MissingPluginException on some Android builds).
|
||||
bool _scannerFailed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -76,8 +79,35 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
setState(() {
|
||||
_step = _Step.scanning;
|
||||
_scannerActive = true;
|
||||
_scannerController = MobileScannerController();
|
||||
});
|
||||
if (_cameraScanSupported()) {
|
||||
unawaited(_initScanner());
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight: start + stop the scanner to verify the plugin is available.
|
||||
// Falls back to text entry on any exception (including MissingPluginException).
|
||||
Future<void> _initScanner() async {
|
||||
MobileScannerController? ctrl;
|
||||
bool available = false;
|
||||
try {
|
||||
ctrl = MobileScannerController();
|
||||
await ctrl.start();
|
||||
await ctrl.stop();
|
||||
available = true;
|
||||
} catch (_) {
|
||||
// Plugin not available on this device; text fallback will be shown.
|
||||
} finally {
|
||||
try {
|
||||
await ctrl?.dispose();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (available) {
|
||||
setState(() => _scannerController = MobileScannerController());
|
||||
} else {
|
||||
setState(() => _scannerFailed = true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onScanned(String rawValue) async {
|
||||
@@ -266,11 +296,14 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
}
|
||||
|
||||
Widget _buildScannerView(BuildContext context) {
|
||||
// On platforms where the camera scanner is not available (Linux desktop),
|
||||
// fall back to a text-input field.
|
||||
if (!_cameraScanSupported()) {
|
||||
// Fall back to text input when the platform has no camera support or when
|
||||
// the scanner plugin fails to initialise at runtime (MissingPluginException).
|
||||
if (!_cameraScanSupported() || _scannerFailed) {
|
||||
return _buildTextFallbackView(context);
|
||||
}
|
||||
if (_scannerController == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
|
||||
@@ -45,12 +45,40 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
||||
bool _scannerActive = true;
|
||||
|
||||
MobileScannerController? _scannerController;
|
||||
// True when the scanner plugin fails to initialise at runtime (e.g.
|
||||
// MissingPluginException on some Android builds).
|
||||
bool _scannerFailed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (_cameraScanSupported()) {
|
||||
_scannerController = MobileScannerController();
|
||||
unawaited(_initScanner());
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight: start + stop the scanner to verify the plugin is available.
|
||||
// Falls back to text entry on any exception (including MissingPluginException).
|
||||
Future<void> _initScanner() async {
|
||||
MobileScannerController? ctrl;
|
||||
bool available = false;
|
||||
try {
|
||||
ctrl = MobileScannerController();
|
||||
await ctrl.start();
|
||||
await ctrl.stop();
|
||||
available = true;
|
||||
} catch (_) {
|
||||
// Plugin not available on this device; text fallback will be shown.
|
||||
} finally {
|
||||
try {
|
||||
await ctrl?.dispose();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (available) {
|
||||
setState(() => _scannerController = MobileScannerController());
|
||||
} else {
|
||||
setState(() => _scannerFailed = true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,9 +206,12 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
||||
}
|
||||
|
||||
Widget _buildScanStep(BuildContext context) {
|
||||
if (!_cameraScanSupported()) {
|
||||
if (!_cameraScanSupported() || _scannerFailed) {
|
||||
return _buildTextFallbackView(context);
|
||||
}
|
||||
if (_scannerController == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
|
||||
@@ -10,12 +10,12 @@ class CrashScreen extends StatelessWidget {
|
||||
super.key,
|
||||
required this.exception,
|
||||
required this.stackTrace,
|
||||
this.gitHash = const String.fromEnvironment('GIT_HASH'),
|
||||
});
|
||||
|
||||
final Object exception;
|
||||
final StackTrace? stackTrace;
|
||||
|
||||
static const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||
final String gitHash;
|
||||
|
||||
Future<String> _buildReport() async {
|
||||
String version = 'unknown';
|
||||
@@ -25,10 +25,13 @@ class CrashScreen extends StatelessWidget {
|
||||
} catch (_) {}
|
||||
final platform =
|
||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||
final gitLine = _gitHash.isNotEmpty
|
||||
? 'Git Commit: [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)\n'
|
||||
final versionDisplay = gitHash.isNotEmpty
|
||||
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)'
|
||||
: version;
|
||||
final gitLine = gitHash.isNotEmpty
|
||||
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
|
||||
: '';
|
||||
return 'App Version: $version\n'
|
||||
return 'App Version: $versionDisplay\n'
|
||||
'$gitLine'
|
||||
'Platform: $platform\n\n'
|
||||
'Error:\n```\n$exception\n```\n\n'
|
||||
@@ -56,12 +59,56 @@ class CrashScreen extends StatelessWidget {
|
||||
style: Theme.of(ctx).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_gitHash.isNotEmpty) ...[
|
||||
if (gitHash.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Git Commit: $_gitHash',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
FutureBuilder<PackageInfo>(
|
||||
future: PackageInfo.fromPlatform(),
|
||||
builder: (_, snapshot) {
|
||||
if (!snapshot.hasData) return const SizedBox.shrink();
|
||||
final version =
|
||||
'${snapshot.data!.version}+${snapshot.data!.buildNumber}';
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
|
||||
);
|
||||
await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
'App Version: $version',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
|
||||
);
|
||||
await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
'Git Commit: $gitHash',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
@@ -106,32 +153,6 @@ class CrashScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_gitHash.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Git Commit:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/commit/$_gitHash',
|
||||
);
|
||||
await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
child: const Text(
|
||||
_gitHash,
|
||||
style: TextStyle(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
|
||||
@@ -38,6 +38,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
var _sieveSsl = true;
|
||||
var _verbose = false;
|
||||
final _jmapUrlCtrl = TextEditingController();
|
||||
bool _hasStoredPassword = false;
|
||||
|
||||
// -- "Try connection" state ------------------------------------------------
|
||||
bool _tryTesting = false;
|
||||
@@ -63,6 +64,11 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
context.pop();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await repo.getPassword(account.id);
|
||||
_hasStoredPassword = true;
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
_account = account;
|
||||
_displayNameCtrl.text = account.displayName;
|
||||
_usernameCtrl.text = account.username;
|
||||
@@ -267,10 +273,12 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
),
|
||||
_field(
|
||||
_passwordCtrl,
|
||||
'New password (leave blank to keep)',
|
||||
_hasStoredPassword
|
||||
? 'New password (leave blank to keep)'
|
||||
: 'Password',
|
||||
key: const Key('editPasswordField'),
|
||||
obscure: true,
|
||||
required: false,
|
||||
required: !_hasStoredPassword,
|
||||
),
|
||||
if (account.type == AccountType.jmap) ...[
|
||||
const Divider(height: 32),
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/utils/about_markdown.dart';
|
||||
|
||||
final _timeFmt = DateFormat('MMM d, HH:mm:ss');
|
||||
|
||||
@@ -21,6 +25,57 @@ String _fmtBytes(int bytes) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
|
||||
String _buildSyncEntryMarkdown(SyncLogEntry entry) {
|
||||
final buf = StringBuffer();
|
||||
buf.writeln('## Sync Entry');
|
||||
buf.writeln();
|
||||
buf.writeln('| Property | Value |');
|
||||
buf.writeln('|----------|-------|');
|
||||
buf.writeln('| Started | ${_timeFmt.format(entry.startedAt)} |');
|
||||
buf.writeln('| Finished | ${_timeFmt.format(entry.finishedAt)} |');
|
||||
buf.writeln('| Duration | ${_fmtDuration(entry.duration)} |');
|
||||
if (entry.protocol.isNotEmpty) {
|
||||
buf.writeln('| Protocol | ${entry.protocol.toUpperCase()} |');
|
||||
}
|
||||
final statusLabel = entry.isOk
|
||||
? 'OK'
|
||||
: entry.isPermanent
|
||||
? 'Error (permanent)'
|
||||
: 'Error';
|
||||
buf.writeln('| Status | $statusLabel |');
|
||||
buf.writeln('| Emails fetched | ${entry.emailsFetched} |');
|
||||
buf.writeln('| Emails up-to-date | ${entry.emailsSkipped} |');
|
||||
buf.writeln('| Mailboxes synced | ${entry.mailboxesSynced} |');
|
||||
buf.writeln('| Pending changes flushed | ${entry.pendingFlushed} |');
|
||||
buf.writeln('| Data transferred | ${_fmtBytes(entry.bytesTransferred)} |');
|
||||
if (entry.mailboxStats.isNotEmpty) {
|
||||
buf.writeln();
|
||||
buf.writeln('### Per mailbox');
|
||||
buf.writeln();
|
||||
buf.writeln('| Mailbox | Fetched | Up-to-date | Duration |');
|
||||
buf.writeln('|---------|---------|------------|----------|');
|
||||
for (final m in entry.mailboxStats) {
|
||||
final dur = m.duration != null ? _fmtDuration(m.duration!) : '-';
|
||||
buf.writeln('| ${m.mailboxPath} | ${m.fetched} | ${m.skipped} | $dur |');
|
||||
}
|
||||
}
|
||||
if (entry.errorMessage != null) {
|
||||
buf.writeln();
|
||||
buf.writeln('**Error:**');
|
||||
buf.writeln();
|
||||
buf.writeln(entry.errorMessage);
|
||||
}
|
||||
if (entry.stackTrace != null) {
|
||||
buf.writeln();
|
||||
buf.writeln('**Stack trace:**');
|
||||
buf.writeln();
|
||||
buf.writeln('```');
|
||||
buf.write(entry.stackTrace);
|
||||
buf.writeln('```');
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
class SyncLogScreen extends ConsumerStatefulWidget {
|
||||
const SyncLogScreen({super.key, required this.accountId});
|
||||
|
||||
@@ -69,6 +124,41 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
|
||||
ref.read(syncManagerProvider).syncNow(widget.accountId);
|
||||
}
|
||||
|
||||
Future<void> _copyEntry(SyncLogEntry entry, BuildContext context) async {
|
||||
final accounts =
|
||||
await ref.read(accountRepositoryProvider).observeAccounts().first;
|
||||
final imapCount = accounts.where((a) => a.type == AccountType.imap).length;
|
||||
final jmapCount = accounts.where((a) => a.type == AccountType.jmap).length;
|
||||
|
||||
PackageInfo? pkg;
|
||||
try {
|
||||
pkg = await PackageInfo.fromPlatform();
|
||||
} catch (_) {}
|
||||
|
||||
final androidInfo = await getAndroidDeviceInfo();
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
final syncMd = _buildSyncEntryMarkdown(entry);
|
||||
final aboutMd = buildAboutMarkdown(
|
||||
context: context,
|
||||
pkg: pkg,
|
||||
imapCount: imapCount,
|
||||
jmapCount: jmapCount,
|
||||
androidInfo: androidInfo,
|
||||
);
|
||||
await Clipboard.setData(ClipboardData(text: '$syncMd\n$aboutMd'));
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 3),
|
||||
content: Text('Copied to clipboard'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -96,16 +186,20 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
|
||||
? const Center(child: Text('No sync entries yet'))
|
||||
: ListView.builder(
|
||||
itemCount: _entries.length,
|
||||
itemBuilder: (ctx, i) => _SyncLogTile(entry: _entries[i]),
|
||||
itemBuilder: (ctx, i) => _SyncLogTile(
|
||||
entry: _entries[i],
|
||||
onCopy: () => _copyEntry(_entries[i], ctx),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SyncLogTile extends StatelessWidget {
|
||||
const _SyncLogTile({required this.entry});
|
||||
const _SyncLogTile({required this.entry, required this.onCopy});
|
||||
|
||||
final SyncLogEntry entry;
|
||||
final VoidCallback onCopy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -115,6 +209,12 @@ class _SyncLogTile extends StatelessWidget {
|
||||
final theme = Theme.of(context);
|
||||
final errorColor = theme.colorScheme.error;
|
||||
|
||||
final subtitleText = entry.isOk
|
||||
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
|
||||
: entry.isPermanent
|
||||
? 'Error (permanent) · took $durationLabel'
|
||||
: 'Error · took $durationLabel';
|
||||
|
||||
return ExpansionTile(
|
||||
leading: Icon(
|
||||
entry.isOk ? Icons.check_circle : Icons.error_outline,
|
||||
@@ -125,11 +225,20 @@ class _SyncLogTile extends StatelessWidget {
|
||||
style: entry.isOk ? null : TextStyle(color: errorColor),
|
||||
),
|
||||
subtitle: Text(
|
||||
entry.isOk
|
||||
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
|
||||
: 'Error · took $durationLabel',
|
||||
subtitleText,
|
||||
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
tooltip: 'Copy as markdown',
|
||||
onPressed: onCopy,
|
||||
),
|
||||
const Icon(Icons.expand_more),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(72, 0, 16, 12),
|
||||
@@ -171,6 +280,31 @@ class _SyncLogTile extends StatelessWidget {
|
||||
style: TextStyle(color: errorColor, fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (entry.stackTrace != null) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 6, bottom: 2),
|
||||
child: Text(
|
||||
'Stack trace',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
entry.stackTrace!,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
color: Colors.red[300],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (entry.protocolLog != null) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 6, bottom: 2),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||
|
||||
/// Builds the About markdown table used in [AboutScreen] and sync log copies.
|
||||
///
|
||||
/// Pass [androidInfo] when running on Android; omit on other platforms.
|
||||
String buildAboutMarkdown({
|
||||
required BuildContext context,
|
||||
PackageInfo? pkg,
|
||||
required int imapCount,
|
||||
required int jmapCount,
|
||||
AndroidDeviceInfo? androidInfo,
|
||||
}) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
final physW = (size.width * pixelRatio).toInt();
|
||||
final physH = (size.height * pixelRatio).toInt();
|
||||
final version = pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
|
||||
final versionDisplay = _gitHash.isNotEmpty
|
||||
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
|
||||
: version;
|
||||
final osName = _capitalize(Platform.operatingSystem);
|
||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||
|
||||
final gitCommitLine = _gitHash.isNotEmpty
|
||||
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
||||
: '';
|
||||
final androidLines = androidInfo != null
|
||||
? '| Android Manufacturer | ${androidInfo.manufacturer} |\n'
|
||||
'| Android Model | ${androidInfo.model} |\n'
|
||||
'| Android Version | ${androidInfo.version.release} |\n'
|
||||
: '';
|
||||
|
||||
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
||||
'| Property | Value |\n'
|
||||
'|----------|-------|\n'
|
||||
'| App Version | $versionDisplay |\n'
|
||||
'$gitCommitLine'
|
||||
'| Platform | ${Platform.operatingSystem} |\n'
|
||||
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
||||
'$androidLines'
|
||||
'| Resolution | ${physW}x$physH px'
|
||||
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
||||
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
||||
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
||||
'| Processors | ${Platform.numberOfProcessors} |\n'
|
||||
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
||||
'| IMAP Accounts | $imapCount |\n'
|
||||
'| JMAP Accounts | $jmapCount |\n';
|
||||
}
|
||||
|
||||
/// Fetches Android device info, or null on non-Android platforms.
|
||||
Future<AndroidDeviceInfo?> getAndroidDeviceInfo() async {
|
||||
if (!Platform.isAndroid) return null;
|
||||
try {
|
||||
return await DeviceInfoPlugin().androidInfo;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String _capitalize(String s) =>
|
||||
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
|
||||
@@ -249,6 +249,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.1.0"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.0"
|
||||
drift:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1284,6 +1300,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
win32_registry:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32_registry
|
||||
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
workmanager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -62,6 +62,9 @@ dependencies:
|
||||
package_info_plus: ^10.1.0
|
||||
share_plus: ^13.1.0
|
||||
|
||||
# Device hardware info for bug reports
|
||||
device_info_plus: ^13.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
+216
-18
@@ -8,12 +8,15 @@ Flow
|
||||
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
|
||||
b. Age ≤ 1 h → print status, exit 0 (let it keep working)
|
||||
2. No agent running → extract pending_issue from state (if any), then check CI
|
||||
a. CI is running → save pending-ci state, exit 0
|
||||
b. Latest CI failed → start fix-CI agent (preserving pending_issue), exit 0
|
||||
c. CI ok + pending_issue → close the issue (CI passed), exit 0
|
||||
d. CI ok (or no run yet) → find oldest Ready issue, start issue agent,
|
||||
save state, exit 0
|
||||
e. No Ready issues → print "nothing to do", exit 0
|
||||
a. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
|
||||
b. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
|
||||
c. Main CI running → save pending-ci state, exit 0
|
||||
d. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
|
||||
e. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
|
||||
section 2a always returns first)
|
||||
f. Main CI ok (or no run yet) → find oldest Ready issue, start issue agent,
|
||||
save state, exit 0
|
||||
g. No Ready issues → print "nothing to do", exit 0
|
||||
|
||||
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
|
||||
|
||||
@@ -31,6 +34,7 @@ To resume the Claude conversation, look up the session UUID first:
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -49,7 +53,9 @@ os.environ["PATH"] = (
|
||||
REPO = "guettli/sharedinbox"
|
||||
REPO_URL = f"https://codeberg.org/{REPO}"
|
||||
STATE_FILE = Path.home() / ".sharedinbox-agent-state.json"
|
||||
HEARTBEAT_FILE = Path.home() / ".sharedinbox-agent-heartbeat"
|
||||
MAX_AGENT_AGE_SECONDS = 3600 # 1 hour
|
||||
MAX_HEARTBEAT_AGE_SECONDS = 7200 # 2 hours
|
||||
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" / (
|
||||
"-" + str(Path.home())[1:].replace("/", "-")
|
||||
)
|
||||
@@ -141,10 +147,19 @@ def _ready_issues() -> list[dict]:
|
||||
return ready
|
||||
|
||||
|
||||
def _latest_ci_run() -> dict | None:
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=1")
|
||||
def _latest_main_ci_run() -> dict | None:
|
||||
"""Return the latest CI run on the main branch (excludes PR and schedule runs).
|
||||
|
||||
Using the global latest run (limit=1) is wrong: a passing or failing run
|
||||
on a PR branch could mask the true state of main. We filter to push
|
||||
events on the 'main' prettyref so section-3 logic only reacts to main.
|
||||
"""
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
|
||||
runs = (data or {}).get("workflow_runs", [])
|
||||
return runs[0] if runs else None
|
||||
for run in runs:
|
||||
if run.get("event") == "push" and run.get("prettyref") == "main":
|
||||
return run
|
||||
return None
|
||||
|
||||
|
||||
def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
||||
@@ -164,7 +179,7 @@ def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
||||
return run
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
else:
|
||||
elif run.get("event") == "push":
|
||||
if run.get("prettyref") == branch:
|
||||
return run
|
||||
return None
|
||||
@@ -188,6 +203,40 @@ def _find_pr_for_branch(branch: str, state: str = "open") -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def _open_issue_prs() -> list[dict]:
|
||||
"""Return all open PRs with issue-{N}-fix branches, oldest-first."""
|
||||
result = subprocess.run(
|
||||
["fgj", "--hostname", "codeberg.org", "pr", "list",
|
||||
"--repo", REPO, "--state", "open", "--json"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return []
|
||||
prs = json.loads(result.stdout)
|
||||
issue_prs = []
|
||||
for pr in prs:
|
||||
head = pr.get("head", {})
|
||||
ref = head.get("ref") or head.get("label", "").split(":")[-1]
|
||||
if re.match(r"^issue-\d+-fix$", ref or ""):
|
||||
issue_prs.append(pr)
|
||||
issue_prs.sort(key=lambda p: p["number"])
|
||||
return issue_prs
|
||||
|
||||
|
||||
def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
|
||||
"""Return the latest CI run triggered by a pull_request event for the given PR number."""
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?event=pull_request&limit=50")
|
||||
runs = (data or {}).get("workflow_runs", [])
|
||||
for run in runs:
|
||||
try:
|
||||
payload = json.loads(run.get("event_payload", "{}"))
|
||||
if payload.get("pull_request", {}).get("number") == pr_number:
|
||||
return run
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _merge_pr(pr_number: int) -> None:
|
||||
"""Squash-merge a PR via fgj."""
|
||||
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
|
||||
@@ -226,6 +275,12 @@ def _clear_state() -> None:
|
||||
STATE_FILE.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _update_heartbeat() -> None:
|
||||
"""Record that the agent loop ran right now."""
|
||||
HEARTBEAT_FILE.write_text(datetime.now(timezone.utc).isoformat())
|
||||
HEARTBEAT_FILE.chmod(0o600)
|
||||
|
||||
|
||||
def _find_session_uuid(session_name: str) -> str | None:
|
||||
"""Return the Claude session UUID for *session_name*, or None if not found.
|
||||
|
||||
@@ -298,6 +353,15 @@ def _agent_alive(state: dict) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _is_claude_process(pid: int) -> bool:
|
||||
"""Return True if pid's comm name indicates it is a claude/node process."""
|
||||
try:
|
||||
comm = Path(f"/proc/{pid}/comm").read_text().strip()
|
||||
return comm in ("claude", "node")
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _agent_age_seconds(state: dict) -> float:
|
||||
"""Seconds elapsed since the agent was launched, from the state file timestamp."""
|
||||
try:
|
||||
@@ -332,11 +396,13 @@ def _git_summary() -> str:
|
||||
def _kill_agent(state: dict) -> None:
|
||||
"""Forcefully stop the running agent."""
|
||||
pid = state.get("pid")
|
||||
if pid:
|
||||
if pid and _is_claude_process(pid):
|
||||
try:
|
||||
os.kill(pid, 9)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
elif pid:
|
||||
print(f"WARNING: pid {pid} is not a claude process — skipping kill to avoid hitting recycled PID")
|
||||
|
||||
|
||||
# ── subcommands ───────────────────────────────────────────────────────────────
|
||||
@@ -384,12 +450,44 @@ def cmd_list() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
# ── monitor subcommand ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_monitor() -> int:
|
||||
"""Check that the agent loop has run within the last 2 hours.
|
||||
|
||||
Exits 0 if healthy, 1 if the heartbeat is missing or stale.
|
||||
Intended to be called from a scheduled CI job or cron every 2 hours.
|
||||
"""
|
||||
if not HEARTBEAT_FILE.exists():
|
||||
print(
|
||||
f"WARNING: Agent loop heartbeat file missing — "
|
||||
f"the loop may not have run yet or the file was deleted ({HEARTBEAT_FILE})."
|
||||
)
|
||||
return 1
|
||||
try:
|
||||
last_run = datetime.fromisoformat(HEARTBEAT_FILE.read_text().strip())
|
||||
except ValueError:
|
||||
print(f"WARNING: Agent loop heartbeat file is corrupted: {HEARTBEAT_FILE}")
|
||||
return 1
|
||||
age = (datetime.now(timezone.utc) - last_run).total_seconds()
|
||||
if age > MAX_HEARTBEAT_AGE_SECONDS:
|
||||
print(
|
||||
f"WARNING: Agent loop last ran {age / 3600:.1f}h ago "
|
||||
f"(limit: {MAX_HEARTBEAT_AGE_SECONDS // 3600}h) — the loop may be stalled."
|
||||
)
|
||||
return 1
|
||||
print(f"Agent loop is healthy. Last run: {age / 60:.0f} min ago.")
|
||||
return 0
|
||||
|
||||
|
||||
# ── main flow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _run_loop() -> int:
|
||||
now = datetime.now(timezone.utc)
|
||||
print(f"---------------------- Starting {now.strftime('%Y-%m-%d %H:%MZ')}")
|
||||
_update_heartbeat()
|
||||
|
||||
state = _read_state()
|
||||
|
||||
@@ -474,6 +572,9 @@ def _run_loop() -> int:
|
||||
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
|
||||
"Identify the failure, fix it, commit, and push to the same branch. "
|
||||
"Do NOT push to main, do NOT close the issue, do NOT merge the PR. "
|
||||
"Do NOT reference any issue numbers in commit messages "
|
||||
"(no 'closes #N', 'fixes #N', or similar) — auto-closing the wrong "
|
||||
"issue via a commit message would be a bug. "
|
||||
"Verify locally with 'task check' before pushing. "
|
||||
"When done, stop."
|
||||
)
|
||||
@@ -512,7 +613,25 @@ def _run_loop() -> int:
|
||||
|
||||
# CI passed on the PR branch — squash-merge and close.
|
||||
print(f"CI passed {_ci_run_url(pr_run['id'])} on branch {branch!r} — merging PR #{pr_number}.")
|
||||
_merge_pr(pr_number)
|
||||
try:
|
||||
_merge_pr(pr_number)
|
||||
except RuntimeError as e:
|
||||
print(f"Merge of PR #{pr_number} failed: {e} — setting to State/Question.")
|
||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
pending_issue,
|
||||
f"Automatic merge of PR #{pr_number} failed: {e}. Please merge manually.",
|
||||
)
|
||||
return 0
|
||||
if _find_pr_for_branch(branch):
|
||||
print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.")
|
||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
pending_issue,
|
||||
f"Automatic merge of PR #{pr_number} failed (PR is still open after the "
|
||||
"merge command). Please merge manually.",
|
||||
)
|
||||
return 0
|
||||
_close_issue(pending_issue)
|
||||
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
||||
return 0
|
||||
@@ -538,8 +657,59 @@ def _run_loop() -> int:
|
||||
)
|
||||
return 0
|
||||
|
||||
# ── 3. Global CI check (agent pushed to main, or no pending issue) ────────
|
||||
run = _latest_ci_run()
|
||||
# ── 2b. Catch-up: scan open issue-N-fix PRs orphaned by a cleared state ─────
|
||||
# This handles PRs whose CI has passed but were never merged because the
|
||||
# state file was cleared (loop restart, killed agent, manual intervention).
|
||||
open_prs = _open_issue_prs()
|
||||
for pr in open_prs:
|
||||
pr_number = pr["number"]
|
||||
pr_url = f"{REPO_URL}/pulls/{pr_number}"
|
||||
head = pr.get("head", {})
|
||||
branch = head.get("ref") or head.get("label", "").split(":")[-1]
|
||||
m = re.match(r"^issue-(\d+)-fix$", branch or "")
|
||||
issue_num = int(m.group(1)) if m else None
|
||||
pr_run = _latest_ci_run_for_pr(pr_number)
|
||||
|
||||
if pr_run and pr_run.get("status") == "running":
|
||||
print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} still running. Waiting.")
|
||||
_write_state(None, issue_num, "pending-ci")
|
||||
return 0
|
||||
|
||||
if pr_run and pr_run.get("status") in ("failure", "error"):
|
||||
print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} failed — skipping.")
|
||||
continue
|
||||
|
||||
if pr_run and pr_run.get("status") == "success":
|
||||
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
|
||||
try:
|
||||
_merge_pr(pr_number)
|
||||
except RuntimeError as e:
|
||||
print(f"Catch-up: merge of PR #{pr_number} failed: {e} — skipping.")
|
||||
continue
|
||||
# Verify the merge actually happened; fgj can exit 0 without merging
|
||||
# (e.g. branch-protection rules not satisfied).
|
||||
if _find_pr_for_branch(branch):
|
||||
print(
|
||||
f"Catch-up: PR #{pr_number} is still open after merge attempt "
|
||||
"— skipping to avoid infinite retry."
|
||||
)
|
||||
if issue_num:
|
||||
_set_labels(issue_num, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
issue_num,
|
||||
f"Automatic merge of PR #{pr_number} failed (PR is still open "
|
||||
"after the merge command). Please merge manually.",
|
||||
)
|
||||
continue
|
||||
if issue_num:
|
||||
_close_issue(issue_num)
|
||||
print(f"Merged PR #{pr_number} and closed issue #{issue_num}.")
|
||||
else:
|
||||
print(f"Merged PR #{pr_number}.")
|
||||
return 0
|
||||
|
||||
# ── 3. Global CI check (main branch only) ────────────────────────────────
|
||||
run = _latest_main_ci_run()
|
||||
|
||||
if run and run.get("status") == "running":
|
||||
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
|
||||
@@ -548,17 +718,39 @@ def _run_loop() -> int:
|
||||
return 0
|
||||
|
||||
if run and run.get("status") in ("failure", "error"):
|
||||
# Guard: if the same main CI run has been failing since the last ci-fix
|
||||
# agent started, that agent pushed to a branch instead of main. Before
|
||||
# spawning another agent, check whether any CI run is currently in
|
||||
# progress (the branch run) and wait if so.
|
||||
if ci_run_id_at_start is not None and run["id"] == ci_run_id_at_start:
|
||||
check = _tea_get(f"repos/{REPO}/actions/runs?limit=5")
|
||||
in_flight = [
|
||||
r for r in (check or {}).get("workflow_runs", [])
|
||||
if r.get("status") == "running"
|
||||
]
|
||||
if in_flight:
|
||||
print(
|
||||
f"Main CI still shows the same failed run {run['id']}; "
|
||||
f"{_ci_run_url(in_flight[0]['id'])} is running "
|
||||
"(previous ci-fix pushed to a branch). Waiting."
|
||||
)
|
||||
return 0
|
||||
print(f"CI run {_ci_run_url(run['id'])} failed — starting fix agent.")
|
||||
prompt = (
|
||||
"The Codeberg CI for guettli/sharedinbox just failed. "
|
||||
"The Codeberg CI for guettli/sharedinbox just failed on the main branch. "
|
||||
f"The CI run ID is {run['id']}. "
|
||||
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
|
||||
"Identify the failure, fix it, commit, and push. "
|
||||
"Identify the failure, fix it, commit, and push directly to main. "
|
||||
"Verify locally with 'task check' before pushing. "
|
||||
"Do NOT reference any issue numbers in commit messages "
|
||||
"(no 'closes #N', 'fixes #N', or similar) — this is a CI fix, "
|
||||
"not an issue fix, and auto-closing an issue via a commit message would be a bug. "
|
||||
"Do NOT close any issues. "
|
||||
"When done, stop."
|
||||
)
|
||||
pid = _start_agent(prompt, "ci-fix")
|
||||
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix")
|
||||
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix",
|
||||
ci_run_id=run["id"] if run else None)
|
||||
return 0
|
||||
|
||||
# CI is ok (or no run).
|
||||
@@ -617,7 +809,10 @@ Instructions:
|
||||
- Implement the required change, following the existing code style.
|
||||
- Write or update tests as appropriate.
|
||||
- Run 'task check' locally and fix any failures before committing.
|
||||
- Commit with a descriptive message referencing the issue number (e.g. "feat: ... (#{issue_number})").
|
||||
- Commit with a descriptive message and include (#{issue_number}) in the title,
|
||||
e.g. "feat: description (#{issue_number})".
|
||||
Do NOT use "Closes #N" or "Fixes #N" keywords — the loop closes the issue
|
||||
after CI passes; using those keywords would close it prematurely or wrongly.
|
||||
- Create a branch named `issue-{issue_number}-fix`, push your changes there, and open a PR against main:
|
||||
git checkout -b issue-{issue_number}-fix
|
||||
git push -u origin issue-{issue_number}-fix
|
||||
@@ -640,10 +835,13 @@ def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog="agent_loop")
|
||||
sub = parser.add_subparsers(dest="cmd")
|
||||
sub.add_parser("list", help="List recent agent sessions")
|
||||
sub.add_parser("monitor", help="Check that the loop ran within the last 2 hours")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.cmd == "list":
|
||||
return cmd_list()
|
||||
if args.cmd == "monitor":
|
||||
return cmd_monitor()
|
||||
return _run_loop()
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ const _excluded = {
|
||||
'lib/ui/widgets/try_connection_button.dart',
|
||||
'lib/ui/widgets/undo_shell.dart',
|
||||
'lib/ui/screens/about_screen.dart',
|
||||
'lib/ui/utils/about_markdown.dart',
|
||||
'lib/ui/widgets/email_tile.dart',
|
||||
'lib/core/sync/account_sync_manager.dart',
|
||||
'lib/core/sync/background_sync.dart',
|
||||
|
||||
+60
-67
@@ -6,76 +6,49 @@ import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import requests
|
||||
from google.auth.transport.requests import AuthorizedSession
|
||||
from google.oauth2 import service_account
|
||||
|
||||
PACKAGE_NAME = "de.sharedinbox.mua"
|
||||
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
||||
TRACK = "internal"
|
||||
_TIMEOUT = 300 # seconds — AAB uploads can be large
|
||||
_MAX_UPLOAD_ATTEMPTS = 3
|
||||
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
|
||||
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
|
||||
_MAX_UPLOAD_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _make_session(config_json: str) -> AuthorizedSession:
|
||||
creds = service_account.Credentials.from_service_account_info(
|
||||
json.loads(config_json),
|
||||
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
||||
def _upload_aab_resumable(session, package, edit_id, aab_path):
|
||||
"""Upload AAB using the Google resumable upload protocol."""
|
||||
file_size = os.path.getsize(aab_path)
|
||||
init_url = f"{_UPLOAD_BASE}/{package}/edits/{edit_id}/bundles"
|
||||
|
||||
# Step 1: initiate the resumable upload session
|
||||
init_resp = session.post(
|
||||
init_url,
|
||||
params={"uploadType": "resumable"},
|
||||
headers={
|
||||
"X-Upload-Content-Type": "application/octet-stream",
|
||||
"X-Upload-Content-Length": str(file_size),
|
||||
"Content-Length": "0",
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
return AuthorizedSession(creds)
|
||||
init_resp.raise_for_status()
|
||||
upload_url = init_resp.headers["Location"]
|
||||
|
||||
|
||||
def _upload_aab(session: AuthorizedSession, edit_id: str) -> int:
|
||||
"""Resumable upload of the AAB. Returns the version code."""
|
||||
file_size = os.path.getsize(AAB_PATH)
|
||||
|
||||
with open(AAB_PATH, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
last_exc = None
|
||||
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
|
||||
try:
|
||||
# Each attempt needs a fresh resumable upload URL — the previous URL expires on failure.
|
||||
init_resp = session.post(
|
||||
f"{_UPLOAD_BASE}/{PACKAGE_NAME}/edits/{edit_id}/bundles",
|
||||
params={"uploadType": "resumable"},
|
||||
headers={
|
||||
"X-Upload-Content-Type": "application/octet-stream",
|
||||
"X-Upload-Content-Length": str(file_size),
|
||||
},
|
||||
json={},
|
||||
timeout=30,
|
||||
)
|
||||
if not init_resp.ok:
|
||||
print(f"Init attempt {attempt + 1} failed: HTTP {init_resp.status_code}: {init_resp.text[:500]}")
|
||||
init_resp.raise_for_status()
|
||||
upload_url = init_resp.headers["Location"]
|
||||
|
||||
upload_resp = session.put(
|
||||
upload_url,
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": str(file_size),
|
||||
},
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
if not upload_resp.ok:
|
||||
print(f"Upload attempt {attempt + 1} failed: HTTP {upload_resp.status_code}: {upload_resp.text[:500]}")
|
||||
upload_resp.raise_for_status()
|
||||
return upload_resp.json()["versionCode"]
|
||||
except requests.RequestException as exc:
|
||||
last_exc = exc
|
||||
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
|
||||
delay = 10 * (2 ** attempt)
|
||||
print(f"Attempt {attempt + 1} failed ({exc}), retrying in {delay}s…")
|
||||
time.sleep(delay)
|
||||
|
||||
raise RuntimeError(
|
||||
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
|
||||
) from last_exc
|
||||
# Step 2: upload the file in a single PUT to the session URI
|
||||
with open(aab_path, "rb") as f:
|
||||
upload_resp = session.put(
|
||||
upload_url,
|
||||
data=f,
|
||||
headers={
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": str(file_size),
|
||||
},
|
||||
timeout=600,
|
||||
)
|
||||
upload_resp.raise_for_status()
|
||||
return upload_resp.json()
|
||||
|
||||
|
||||
def main():
|
||||
@@ -88,25 +61,45 @@ def main():
|
||||
print(f"Error: AAB not found at {AAB_PATH}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
session = _make_session(config_json)
|
||||
|
||||
edit_resp = session.post(
|
||||
f"{_BASE}/{PACKAGE_NAME}/edits",
|
||||
json={},
|
||||
timeout=30,
|
||||
creds = service_account.Credentials.from_service_account_info(
|
||||
json.loads(config_json),
|
||||
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
||||
)
|
||||
session = AuthorizedSession(creds)
|
||||
|
||||
edit_resp = session.post(f"{_BASE}/{PACKAGE_NAME}/edits", json={}, timeout=30)
|
||||
edit_resp.raise_for_status()
|
||||
edit_id = edit_resp.json()["id"]
|
||||
|
||||
version_code = _upload_aab(session, edit_id)
|
||||
last_exc = None
|
||||
bundle = None
|
||||
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
|
||||
try:
|
||||
bundle = _upload_aab_resumable(session, PACKAGE_NAME, edit_id, AAB_PATH)
|
||||
break
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
|
||||
delay = 10 * (2 ** attempt)
|
||||
print(
|
||||
f"Upload attempt {attempt + 1} failed ({type(exc).__name__}: {exc}), "
|
||||
f"retrying in {delay}s…"
|
||||
)
|
||||
time.sleep(delay)
|
||||
if bundle is None:
|
||||
raise RuntimeError(
|
||||
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
|
||||
) from last_exc
|
||||
|
||||
version_code = bundle["versionCode"]
|
||||
print(f"Uploaded AAB, version code: {version_code}")
|
||||
|
||||
tracks_resp = session.put(
|
||||
track_resp = session.put(
|
||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
|
||||
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
||||
timeout=30,
|
||||
)
|
||||
tracks_resp.raise_for_status()
|
||||
track_resp.raise_for_status()
|
||||
|
||||
commit_resp = session.post(
|
||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
|
||||
|
||||
@@ -33,9 +33,6 @@ def list_remote_files(ssh_user: str, ssh_host: str, pattern: str) -> list[str]:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ssh",
|
||||
"-v",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-i", "/root/.ssh/id_ed25519",
|
||||
f"{ssh_user}@{ssh_host}",
|
||||
f"find {REMOTE_BUILDS_DIR} -name '{pattern}' -type f | sort",
|
||||
],
|
||||
|
||||
+148
-17
@@ -6,6 +6,7 @@ import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -88,21 +89,47 @@ class TestAgentAlive(unittest.TestCase):
|
||||
self.assertFalse(agent_loop._agent_alive({"pid": None}))
|
||||
|
||||
|
||||
class TestIsClaudeProcess(unittest.TestCase):
|
||||
def test_returns_true_for_claude_comm(self):
|
||||
with patch.object(agent_loop.Path, "read_text", return_value="claude\n"):
|
||||
self.assertTrue(agent_loop._is_claude_process(1234))
|
||||
|
||||
def test_returns_true_for_node_comm(self):
|
||||
with patch.object(agent_loop.Path, "read_text", return_value="node\n"):
|
||||
self.assertTrue(agent_loop._is_claude_process(1234))
|
||||
|
||||
def test_returns_false_for_other_process(self):
|
||||
with patch.object(agent_loop.Path, "read_text", return_value="bash\n"):
|
||||
self.assertFalse(agent_loop._is_claude_process(1234))
|
||||
|
||||
def test_returns_false_when_proc_missing(self):
|
||||
with patch.object(agent_loop.Path, "read_text", side_effect=OSError):
|
||||
self.assertFalse(agent_loop._is_claude_process(1234))
|
||||
|
||||
|
||||
class TestKillAgent(unittest.TestCase):
|
||||
def test_kill_sends_sigkill(self):
|
||||
with patch("agent_loop.os.kill") as mock_kill:
|
||||
agent_loop._kill_agent({"pid": 1234})
|
||||
mock_kill.assert_called_once_with(1234, 9)
|
||||
with patch("agent_loop._is_claude_process", return_value=True):
|
||||
with patch("agent_loop.os.kill") as mock_kill:
|
||||
agent_loop._kill_agent({"pid": 1234})
|
||||
mock_kill.assert_called_once_with(1234, 9)
|
||||
|
||||
def test_kill_ignores_missing_process(self):
|
||||
with patch("agent_loop.os.kill", side_effect=ProcessLookupError):
|
||||
agent_loop._kill_agent({"pid": 1234}) # Should not raise.
|
||||
with patch("agent_loop._is_claude_process", return_value=True):
|
||||
with patch("agent_loop.os.kill", side_effect=ProcessLookupError):
|
||||
agent_loop._kill_agent({"pid": 1234}) # Should not raise.
|
||||
|
||||
def test_kill_noop_when_no_pid(self):
|
||||
with patch("agent_loop.os.kill") as mock_kill:
|
||||
agent_loop._kill_agent({})
|
||||
mock_kill.assert_not_called()
|
||||
|
||||
def test_kill_skips_recycled_pid(self):
|
||||
with patch("agent_loop._is_claude_process", return_value=False):
|
||||
with patch("agent_loop.os.kill") as mock_kill:
|
||||
agent_loop._kill_agent({"pid": 1234})
|
||||
mock_kill.assert_not_called()
|
||||
|
||||
|
||||
class TestStartAgent(unittest.TestCase):
|
||||
def _make_mock_proc(self, pid=42):
|
||||
@@ -174,7 +201,8 @@ class TestMain(unittest.TestCase):
|
||||
return 55
|
||||
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \
|
||||
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
||||
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
|
||||
@@ -200,7 +228,8 @@ class TestMain(unittest.TestCase):
|
||||
captured["remove"] = remove
|
||||
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \
|
||||
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
|
||||
patch("agent_loop._start_agent", return_value=99), \
|
||||
@@ -213,7 +242,8 @@ class TestMain(unittest.TestCase):
|
||||
def test_no_ready_issues_does_nothing(self):
|
||||
"""main() exits cleanly with 0 when there are no ready issues."""
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
patch("agent_loop._set_labels") as mock_labels, \
|
||||
patch("agent_loop._start_agent") as mock_start:
|
||||
@@ -232,7 +262,8 @@ class TestMain(unittest.TestCase):
|
||||
return 77
|
||||
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \
|
||||
patch("agent_loop._set_labels"), \
|
||||
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
|
||||
@@ -266,8 +297,9 @@ class TestPendingCi(unittest.TestCase):
|
||||
|
||||
def test_closes_issue_when_ci_passes_after_agent_finishes(self):
|
||||
"""After issue agent finishes, loop merges the PR and closes the issue once CI is green."""
|
||||
# First call: PR found open. Second call (post-merge verification): PR closed.
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr") as mock_merge, \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
@@ -282,7 +314,7 @@ class TestPendingCi(unittest.TestCase):
|
||||
"""'CI passed' line includes the CI run URL when a run is available."""
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 4145144, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr"), \
|
||||
patch("agent_loop._close_issue"), \
|
||||
@@ -392,7 +424,7 @@ class TestPendingCi(unittest.TestCase):
|
||||
def test_closes_issue_after_ci_fix_and_ci_passes(self):
|
||||
"""After ci-fix agent finishes and CI passes on PR branch, the pending issue is closed."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10, "ci-fix")), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr") as mock_merge, \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
@@ -409,7 +441,8 @@ class TestPendingCi(unittest.TestCase):
|
||||
"pid": 999999999, "issue": None, "started_at": "2026-01-01T00:00:00+00:00",
|
||||
"type": "ci-fix",
|
||||
}), \
|
||||
patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
patch("agent_loop._clear_state"):
|
||||
@@ -425,7 +458,8 @@ class TestOutputFormat(unittest.TestCase):
|
||||
def test_output_starts_with_header(self):
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
@@ -436,7 +470,8 @@ class TestOutputFormat(unittest.TestCase):
|
||||
def test_no_agent_loop_prefix_in_output(self):
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
@@ -446,7 +481,8 @@ class TestOutputFormat(unittest.TestCase):
|
||||
run = {"id": 4145144, "status": "running"}
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=run), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=run), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144",
|
||||
@@ -456,7 +492,8 @@ class TestOutputFormat(unittest.TestCase):
|
||||
issue = {"number": 128, "title": "Fix something", "body": "", "labels": []}
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[issue]), \
|
||||
patch("agent_loop._set_labels"), \
|
||||
patch("agent_loop._start_agent", return_value=99), \
|
||||
@@ -468,6 +505,35 @@ class TestOutputFormat(unittest.TestCase):
|
||||
self.assertIn("Fix something", output)
|
||||
|
||||
|
||||
class TestLatestMainCiRun(unittest.TestCase):
|
||||
"""_latest_main_ci_run() must return only push-to-main runs, ignoring schedule/deploy workflows."""
|
||||
|
||||
def test_skips_schedule_runs_returns_push_to_main(self):
|
||||
runs = [
|
||||
{"event": "schedule", "prettyref": "main", "status": "success", "id": 1},
|
||||
{"event": "push", "prettyref": "main", "status": "success", "id": 2},
|
||||
]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["id"], 2)
|
||||
|
||||
def test_returns_none_when_only_schedule_runs_exist(self):
|
||||
runs = [
|
||||
{"event": "schedule", "prettyref": "main", "status": "success", "id": 1},
|
||||
]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_push_to_main_run(self):
|
||||
runs = [{"event": "push", "prettyref": "main", "status": "running", "id": 42}]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["id"], 42)
|
||||
|
||||
|
||||
class TestLatestCiRunForBranch(unittest.TestCase):
|
||||
"""Tests for _latest_ci_run_for_branch — Forgejo API field mapping."""
|
||||
|
||||
@@ -667,5 +733,70 @@ class TestRunLoopResumeCommand(unittest.TestCase):
|
||||
self.assertNotIn("Resume:", output)
|
||||
|
||||
|
||||
class TestHeartbeat(unittest.TestCase):
|
||||
"""Tests for _update_heartbeat() and cmd_monitor()."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".heartbeat")
|
||||
self._tmp.close()
|
||||
self._orig = agent_loop.HEARTBEAT_FILE
|
||||
agent_loop.HEARTBEAT_FILE = Path(self._tmp.name)
|
||||
Path(self._tmp.name).unlink() # Start with no heartbeat file.
|
||||
|
||||
def tearDown(self):
|
||||
agent_loop.HEARTBEAT_FILE = self._orig
|
||||
Path(self._tmp.name).unlink(missing_ok=True)
|
||||
|
||||
def test_update_heartbeat_writes_timestamp(self):
|
||||
agent_loop._update_heartbeat()
|
||||
content = Path(self._tmp.name).read_text().strip()
|
||||
dt = datetime.fromisoformat(content)
|
||||
age = (datetime.now(timezone.utc) - dt).total_seconds()
|
||||
self.assertLess(age, 5)
|
||||
|
||||
def test_update_heartbeat_creates_file(self):
|
||||
self.assertFalse(Path(self._tmp.name).exists())
|
||||
agent_loop._update_heartbeat()
|
||||
self.assertTrue(Path(self._tmp.name).exists())
|
||||
|
||||
def test_monitor_healthy_when_recent(self):
|
||||
agent_loop._update_heartbeat()
|
||||
result = agent_loop.cmd_monitor()
|
||||
self.assertEqual(result, 0)
|
||||
|
||||
def test_monitor_warns_when_heartbeat_missing(self):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
result = agent_loop.cmd_monitor()
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("WARNING", buf.getvalue())
|
||||
|
||||
def test_monitor_warns_when_stale(self):
|
||||
stale = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat()
|
||||
Path(self._tmp.name).write_text(stale)
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
result = agent_loop.cmd_monitor()
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("WARNING", buf.getvalue())
|
||||
|
||||
def test_monitor_warns_when_corrupted(self):
|
||||
Path(self._tmp.name).write_text("not-a-timestamp")
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
result = agent_loop.cmd_monitor()
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("WARNING", buf.getvalue())
|
||||
|
||||
def test_run_loop_updates_heartbeat(self):
|
||||
self.assertFalse(Path(self._tmp.name).exists())
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]):
|
||||
agent_loop._run_loop()
|
||||
self.assertTrue(Path(self._tmp.name).exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for deploy_playstore.py."""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
import deploy_playstore
|
||||
|
||||
|
||||
def _make_session(
|
||||
edit_id="edit-42",
|
||||
version_code=7,
|
||||
upload_side_effects=None,
|
||||
):
|
||||
"""Return a mock AuthorizedSession with sensible defaults."""
|
||||
session = MagicMock()
|
||||
|
||||
# POST /edits → create edit
|
||||
edit_resp = MagicMock()
|
||||
edit_resp.json.return_value = {"id": edit_id}
|
||||
session.post.return_value = edit_resp
|
||||
|
||||
# POST resumable-init → Location header
|
||||
init_resp = MagicMock()
|
||||
init_resp.headers = {"Location": "https://upload.example.com/session"}
|
||||
|
||||
# PUT upload → bundle JSON
|
||||
upload_resp = MagicMock()
|
||||
upload_resp.json.return_value = {"versionCode": version_code}
|
||||
|
||||
if upload_side_effects is not None:
|
||||
# Use side_effect list: first call is edit create, rest are upload inits
|
||||
# We override the PUT side effects via _upload_aab_resumable mock instead
|
||||
pass
|
||||
|
||||
return session, init_resp, upload_resp
|
||||
|
||||
|
||||
class TestMainEnvChecks(unittest.TestCase):
|
||||
def test_missing_env_exits(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with self.assertRaises(SystemExit) as ctx:
|
||||
deploy_playstore.main()
|
||||
self.assertEqual(ctx.exception.code, 1)
|
||||
|
||||
def test_missing_aab_exits(self):
|
||||
fake_config = '{"type": "service_account"}'
|
||||
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}):
|
||||
with patch("deploy_playstore.os.path.exists", return_value=False):
|
||||
with self.assertRaises(SystemExit) as ctx:
|
||||
deploy_playstore.main()
|
||||
self.assertEqual(ctx.exception.code, 1)
|
||||
|
||||
|
||||
class TestMainHappyPath(unittest.TestCase):
|
||||
def _run_main(self, fake_config='{"type":"service_account"}'):
|
||||
mock_session = MagicMock()
|
||||
# POST for edit create and commit
|
||||
post_responses = [
|
||||
MagicMock(**{"json.return_value": {"id": "edit-42"}}), # create edit
|
||||
MagicMock(), # commit
|
||||
]
|
||||
mock_session.post.side_effect = post_responses
|
||||
# PUT for track update
|
||||
mock_session.put.return_value = MagicMock()
|
||||
|
||||
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}):
|
||||
with patch("deploy_playstore.os.path.exists", return_value=True):
|
||||
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
|
||||
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
|
||||
with patch(
|
||||
"deploy_playstore._upload_aab_resumable",
|
||||
return_value={"versionCode": 7},
|
||||
):
|
||||
deploy_playstore.main()
|
||||
|
||||
return mock_session
|
||||
|
||||
def test_creates_edit(self):
|
||||
session = self._run_main()
|
||||
create_call = session.post.call_args_list[0]
|
||||
self.assertIn("/edits", create_call[0][0])
|
||||
|
||||
def test_commits_edit(self):
|
||||
session = self._run_main()
|
||||
commit_call = session.post.call_args_list[1]
|
||||
self.assertIn(":commit", commit_call[0][0])
|
||||
|
||||
def test_updates_track(self):
|
||||
session = self._run_main()
|
||||
track_call = session.put.call_args_list[0]
|
||||
self.assertIn("/tracks/", track_call[0][0])
|
||||
|
||||
|
||||
class TestUploadRetry(unittest.TestCase):
|
||||
def _run_main(self, upload_side_effects, sleep_mock=None):
|
||||
mock_session = MagicMock()
|
||||
post_responses = [
|
||||
MagicMock(**{"json.return_value": {"id": "edit-1"}}),
|
||||
MagicMock(),
|
||||
]
|
||||
mock_session.post.side_effect = post_responses
|
||||
mock_session.put.return_value = MagicMock()
|
||||
|
||||
patches = [
|
||||
patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}),
|
||||
patch("deploy_playstore.os.path.exists", return_value=True),
|
||||
patch("deploy_playstore.service_account.Credentials.from_service_account_info"),
|
||||
patch("deploy_playstore.AuthorizedSession", return_value=mock_session),
|
||||
patch("deploy_playstore._upload_aab_resumable", side_effect=upload_side_effects),
|
||||
patch("deploy_playstore.time.sleep"),
|
||||
]
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
deploy_playstore.main()
|
||||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
|
||||
def test_succeeds_on_first_attempt(self):
|
||||
with patch("deploy_playstore._upload_aab_resumable", return_value={"versionCode": 5}) as mock_upload:
|
||||
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
|
||||
with patch("deploy_playstore.os.path.exists", return_value=True):
|
||||
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
|
||||
mock_session = MagicMock()
|
||||
mock_session.post.side_effect = [
|
||||
MagicMock(**{"json.return_value": {"id": "e1"}}),
|
||||
MagicMock(),
|
||||
]
|
||||
mock_session.put.return_value = MagicMock()
|
||||
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
|
||||
deploy_playstore.main()
|
||||
mock_upload.assert_called_once()
|
||||
|
||||
def test_retries_once_on_error_then_succeeds(self):
|
||||
self._run_main([ValueError("transient"), {"versionCode": 9}])
|
||||
|
||||
def test_raises_after_all_attempts_exhausted(self):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
self._run_main([ValueError("err"), ValueError("err"), ValueError("err")])
|
||||
self.assertIn(str(deploy_playstore._MAX_UPLOAD_ATTEMPTS), str(ctx.exception))
|
||||
|
||||
def test_backoff_delays_are_10s_then_20s(self):
|
||||
mock_session = MagicMock()
|
||||
mock_session.post.side_effect = [
|
||||
MagicMock(**{"json.return_value": {"id": "e1"}}),
|
||||
MagicMock(),
|
||||
]
|
||||
mock_session.put.return_value = MagicMock()
|
||||
|
||||
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
|
||||
with patch("deploy_playstore.os.path.exists", return_value=True):
|
||||
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
|
||||
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
|
||||
with patch(
|
||||
"deploy_playstore._upload_aab_resumable",
|
||||
side_effect=[ValueError("e"), ValueError("e"), {"versionCode": 3}],
|
||||
):
|
||||
with patch("deploy_playstore.time.sleep") as mock_sleep:
|
||||
deploy_playstore.main()
|
||||
|
||||
mock_sleep.assert_has_calls([call(10), call(20)])
|
||||
|
||||
|
||||
class TestUploadAabResumable(unittest.TestCase):
|
||||
def test_initiates_and_uploads(self):
|
||||
mock_session = MagicMock()
|
||||
init_resp = MagicMock()
|
||||
init_resp.headers = {"Location": "https://upload.example.com/sess"}
|
||||
upload_resp = MagicMock()
|
||||
upload_resp.json.return_value = {"versionCode": 42}
|
||||
mock_session.post.return_value = init_resp
|
||||
mock_session.put.return_value = upload_resp
|
||||
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||
f.write(b"fake-aab-content")
|
||||
aab_path = f.name
|
||||
|
||||
try:
|
||||
result = deploy_playstore._upload_aab_resumable(
|
||||
mock_session, "com.example.app", "edit-1", aab_path
|
||||
)
|
||||
finally:
|
||||
os.unlink(aab_path)
|
||||
|
||||
self.assertEqual(result["versionCode"], 42)
|
||||
mock_session.post.assert_called_once()
|
||||
mock_session.put.assert_called_once()
|
||||
put_call = mock_session.put.call_args
|
||||
self.assertEqual(put_call[0][0], "https://upload.example.com/sess")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -288,6 +288,8 @@ class _FakeLogs implements SyncLogRepository {
|
||||
required String accountId,
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool isPermanent = false,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart' show MissingPluginException;
|
||||
import 'package:mockito/annotations.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/core/repositories/account_repository.dart';
|
||||
@@ -30,6 +32,40 @@ void main() {
|
||||
// This is hard to test without real loops, but we can verify it doesn't crash.
|
||||
manager.syncNow('unknown');
|
||||
});
|
||||
|
||||
// Regression test for issue #200: when flutter_secure_storage throws
|
||||
// MissingPluginException (channel unavailable on the device), the IMAP sync
|
||||
// loop must stop permanently instead of retrying indefinitely with backoff.
|
||||
test(
|
||||
'MissingPluginException from secure storage stops IMAP sync loop permanently',
|
||||
() async {
|
||||
final syncLog = FakeSyncLogRepository();
|
||||
|
||||
final m = AccountSyncManager(
|
||||
_AccountRepositoryWithMissingPlugin(),
|
||||
FakeMailboxRepositoryWithInbox(),
|
||||
FakeEmailRepository(),
|
||||
syncLog: syncLog,
|
||||
);
|
||||
|
||||
m.start();
|
||||
|
||||
// Allow the first sync cycle to run and fail.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(syncLog.logs, hasLength(1));
|
||||
expect(syncLog.logs.first.success, isFalse);
|
||||
|
||||
// Kicking the loop should have no effect once it has stopped permanently.
|
||||
m.syncNow('1');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
// Before the fix: kick triggers a retry → 2 log entries.
|
||||
// After the fix: loop is permanently stopped → still exactly 1 entry.
|
||||
expect(syncLog.logs, hasLength(1));
|
||||
|
||||
m.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
class FakeEmailRepository implements EmailRepository {
|
||||
@@ -145,6 +181,8 @@ class FakeSyncLogRepository implements SyncLogRepository {
|
||||
required String accountId,
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool isPermanent = false,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
@@ -187,3 +225,34 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
|
||||
@override
|
||||
Future<void> clearForResync(String accountId) async {}
|
||||
}
|
||||
|
||||
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
|
||||
static const _account = Account(
|
||||
id: '1',
|
||||
displayName: 'Test',
|
||||
email: 'test@example.com',
|
||||
);
|
||||
|
||||
@override
|
||||
Stream<List<Account>> observeAccounts() => Stream.value([_account]);
|
||||
|
||||
@override
|
||||
Future<Account?> getAccount(String id) async => _account;
|
||||
|
||||
@override
|
||||
Future<String> getPassword(String accountId) => Future.error(
|
||||
MissingPluginException(
|
||||
'No implementation found for method read on channel '
|
||||
'plugins.it.nomads.com/flutter_secure_storage',
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> addAccount(Account account, String password) async {}
|
||||
|
||||
@override
|
||||
Future<void> updateAccount(Account account, {String? password}) async {}
|
||||
|
||||
@override
|
||||
Future<void> removeAccount(String id) async {}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -129,5 +130,27 @@ void main() {
|
||||
);
|
||||
});
|
||||
},
|
||||
// The Android fallback runs only on Android, so on the host machine the
|
||||
// exception is still thrown after all retries. Skip on Android to avoid
|
||||
// depending on /data/user/0/... being absent in the test environment.
|
||||
skip: Platform.isAndroid,
|
||||
);
|
||||
|
||||
// Regression test for issue #192: _androidFallbackPath must return null when
|
||||
// the process cmdline does not look like an Android package name (e.g. on
|
||||
// the host test machine where the process is the Dart executable).
|
||||
test(
|
||||
'_androidFallbackPath returns null when process name is not a package name',
|
||||
() async {
|
||||
// On non-Android platforms the host process cmdline is a file-system path
|
||||
// (starts with '/'), which the fallback correctly rejects. On Android
|
||||
// the process IS named after the package — the fallback is free to
|
||||
// succeed or return null depending on the device state; we do not assert
|
||||
// here so as not to constrain Android behaviour.
|
||||
if (!Platform.isAndroid) {
|
||||
final result = await androidFallbackPathForTesting();
|
||||
expect(result, isNull);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ void main() {
|
||||
group('Migration', () {
|
||||
test('schemaVersion matches expected value', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
expect(db.schemaVersion, 32);
|
||||
expect(db.schemaVersion, 33);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
@@ -194,6 +194,11 @@ void main() {
|
||||
// v32: local_sieve_applied table.
|
||||
await db.customSelect('SELECT count(*) FROM local_sieve_applied').get();
|
||||
|
||||
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
||||
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
||||
expect(syncLogColumns, contains('error_stack_trace'));
|
||||
expect(syncLogColumns, contains('is_permanent'));
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
@@ -381,11 +386,16 @@ void main() {
|
||||
await _tableColumns(db, 'sync_log_mailboxes');
|
||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||
|
||||
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
||||
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
||||
expect(syncLogColumns, contains('error_stack_trace'));
|
||||
expect(syncLogColumns, contains('is_permanent'));
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
|
||||
test('fresh install creates all tables at schemaVersion 32', () async {
|
||||
test('fresh install creates all tables at schemaVersion 33', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
await db.select(db.accounts).get();
|
||||
|
||||
@@ -426,6 +436,11 @@ void main() {
|
||||
await _tableColumns(db, 'sync_log_mailboxes');
|
||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||
|
||||
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
||||
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
||||
expect(syncLogColumns, contains('error_stack_trace'));
|
||||
expect(syncLogColumns, contains('is_permanent'));
|
||||
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -170,6 +170,8 @@ class _FakeSyncLog implements SyncLogRepository {
|
||||
required String accountId,
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool isPermanent = false,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
|
||||
@@ -126,4 +126,34 @@ void main() {
|
||||
expect(rows.first.result, 'error');
|
||||
expect(rows.first.errorMessage, 'Connection refused');
|
||||
});
|
||||
|
||||
test('stores and retrieves stackTrace and isPermanent on error entries',
|
||||
() async {
|
||||
final repo = SyncLogRepositoryImpl(db);
|
||||
final start = DateTime(2024, 3, 1, 9);
|
||||
final end = DateTime(2024, 3, 1, 9, 0, 1);
|
||||
const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)';
|
||||
|
||||
await repo.log(
|
||||
accountId: 'acc1',
|
||||
success: false,
|
||||
errorMessage: 'MissingPluginException',
|
||||
stackTrace: fakeTrace,
|
||||
isPermanent: true,
|
||||
protocol: 'imap',
|
||||
emailsFetched: 0,
|
||||
emailsSkipped: 0,
|
||||
mailboxesSynced: 0,
|
||||
pendingFlushed: 0,
|
||||
bytesTransferred: 0,
|
||||
startedAt: start,
|
||||
finishedAt: end,
|
||||
);
|
||||
|
||||
final entries = await repo.observeSyncLogs('acc1').first;
|
||||
final entry = entries.firstWhere((e) => e.startedAt == start);
|
||||
expect(entry.stackTrace, fakeTrace);
|
||||
expect(entry.isPermanent, true);
|
||||
expect(entry.errorMessage, 'MissingPluginException');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,6 +151,10 @@ void main() {
|
||||
expect(clipboardText, contains('Dark Mode'));
|
||||
expect(clipboardText, contains('IMAP Accounts'));
|
||||
expect(clipboardText, contains('JMAP Accounts'));
|
||||
expect(
|
||||
clipboardText,
|
||||
contains('[sharedinbox.de](https://sharedinbox.de)'),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('AboutScreen create-issue button opens Codeberg URL', (
|
||||
|
||||
@@ -123,6 +123,154 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen shows git hash as clickable link above stacktrace',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
final mock = MockUrlLauncher();
|
||||
UrlLauncherPlatform.instance = mock;
|
||||
|
||||
const exception = 'TestException: git hash test';
|
||||
final stackTrace = StackTrace.current;
|
||||
const testHash = 'abc1234';
|
||||
|
||||
await tester.pumpWidget(
|
||||
CrashScreen(
|
||||
exception: exception,
|
||||
stackTrace: stackTrace,
|
||||
gitHash: testHash,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Git hash link should be present
|
||||
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
|
||||
expect(gitLinkFinder, findsOneWidget);
|
||||
|
||||
// Link must appear above the stack trace
|
||||
final stackTraceFinder = find.text('Stack Trace:');
|
||||
expect(
|
||||
tester.getTopLeft(gitLinkFinder).dy,
|
||||
lessThan(tester.getTopLeft(stackTraceFinder).dy),
|
||||
);
|
||||
|
||||
// Tapping the link should open the Codeberg commit URL
|
||||
await tester.tap(gitLinkFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
mock.launchedUrl,
|
||||
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen shows app version as clickable link when git hash is set',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
final mock = MockUrlLauncher();
|
||||
UrlLauncherPlatform.instance = mock;
|
||||
|
||||
const exception = 'TestException: version link test';
|
||||
final stackTrace = StackTrace.current;
|
||||
const testHash = 'abc1234';
|
||||
|
||||
await tester.pumpWidget(
|
||||
CrashScreen(
|
||||
exception: exception,
|
||||
stackTrace: stackTrace,
|
||||
gitHash: testHash,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// App version link should be present (mocked as 1.0.0+42)
|
||||
final versionLinkFinder = find.textContaining('App Version: 1.0.0+42');
|
||||
expect(versionLinkFinder, findsOneWidget);
|
||||
|
||||
// It must appear above the git hash link
|
||||
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
|
||||
expect(
|
||||
tester.getTopLeft(versionLinkFinder).dy,
|
||||
lessThan(tester.getTopLeft(gitLinkFinder).dy),
|
||||
);
|
||||
|
||||
// Tapping it should open the Codeberg commit URL
|
||||
await tester.tap(versionLinkFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
mock.launchedUrl,
|
||||
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen copy-to-clipboard includes app version as markdown link when git hash is set',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
String? clipboardText;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(MethodCall call) async {
|
||||
if (call.method == 'Clipboard.setData') {
|
||||
clipboardText =
|
||||
(call.arguments as Map<dynamic, dynamic>)['text'] as String?;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||
);
|
||||
|
||||
const exception = 'TestException: version link clipboard test';
|
||||
final stackTrace = StackTrace.current;
|
||||
const testHash = 'abc1234';
|
||||
|
||||
await tester.pumpWidget(
|
||||
CrashScreen(
|
||||
exception: exception,
|
||||
stackTrace: stackTrace,
|
||||
gitHash: testHash,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Copy to Clipboard'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(clipboardText, isNotNull);
|
||||
// App Version must be a markdown link pointing to the commit
|
||||
expect(
|
||||
clipboardText,
|
||||
contains(
|
||||
'App Version: [1.0.0+42](https://codeberg.org/guettli/sharedinbox/commit/abc1234)',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
clipboardText,
|
||||
contains(
|
||||
'Git Commit: [abc1234](https://codeberg.org/guettli/sharedinbox/commit/abc1234)',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash',
|
||||
(tester) async {
|
||||
|
||||
@@ -105,6 +105,33 @@ void main() {
|
||||
expect(find.text('Edit account'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'try connection shows password required when no password stored', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(800, 1400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/edit',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
hasStoredPassword: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('editTryConnectionButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// App must not crash; password field shows a validation error.
|
||||
expect(find.text('Required'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('connection error shows error message', (tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
|
||||
@@ -44,11 +44,12 @@ import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class FakeAccountRepository implements AccountRepository {
|
||||
final List<Account> _accounts;
|
||||
|
||||
FakeAccountRepository([List<Account>? accounts])
|
||||
: _accounts = List.of(accounts ?? []);
|
||||
|
||||
final List<Account> _accounts;
|
||||
bool hasPassword = true;
|
||||
|
||||
@override
|
||||
Stream<List<Account>> observeAccounts() => Stream.value(List.of(_accounts));
|
||||
|
||||
@@ -75,7 +76,12 @@ class FakeAccountRepository implements AccountRepository {
|
||||
_accounts.removeWhere((a) => a.id == id);
|
||||
|
||||
@override
|
||||
Future<String> getPassword(String accountId) async => 'test-password';
|
||||
Future<String> getPassword(String accountId) async {
|
||||
if (!hasPassword) {
|
||||
throw StateError('No password stored for account $accountId');
|
||||
}
|
||||
return 'test-password';
|
||||
}
|
||||
}
|
||||
|
||||
class FakeShareKeyRepository implements ShareKeyRepository {
|
||||
@@ -511,10 +517,12 @@ List<Override> baseOverrides({
|
||||
List<Mailbox>? mailboxes,
|
||||
DiscoveryResult? discovery,
|
||||
Exception? connectionError,
|
||||
bool hasStoredPassword = true,
|
||||
}) =>
|
||||
[
|
||||
accountRepositoryProvider
|
||||
.overrideWithValue(FakeAccountRepository(accounts)),
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository(accounts)..hasPassword = hasStoredPassword,
|
||||
),
|
||||
mailboxRepositoryProvider
|
||||
.overrideWithValue(FakeMailboxRepository(mailboxes)),
|
||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||
|
||||
Reference in New Issue
Block a user