Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 e16dab6752 test: add agentloop code test comment to DEVELOPMENT.md
Closes #335

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 09:12:17 +02:00
Bot of Thomas Güttler d905cd653f fix: check Docker availability before falling back to local Dagger engine (#329) (#333) 2026-05-29 23:19:14 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e21cde0a3c fix: allow forgejo-actions as issue author in agent loop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 21:52:56 +02:00
Bot of Thomas Güttler 50a6678ec2 feat: reimplement user preferences, archive, configurable navigation (#315) (#324) 2026-05-29 19:08:12 +02:00
Bot of Thomas Güttler 91083218d4 fix: diff from last deployed SHA to catch all changes since last deploy (#320) (#332) 2026-05-29 17:34:21 +02:00
Bot of Thomas Güttler adc4eb6f6d feat: remove publish-website from deploy.yml, schedule website.yml hourly (#325) (#330) 2026-05-29 12:53:18 +02:00
Bot of Thomas Güttler 05d00bdf09 fix: move overflow actions into popup menu so three-dot menu is always visible (#312) (#323) 2026-05-28 07:19:11 +02:00
Bot of Thomas Güttler c45775be92 fix: move sync health report to own row below each account (#311) (#322) 2026-05-28 06:53:11 +02:00
47fc534a8d fix: disable github-actions manager to suppress GitHub token warning (#285) (#306)
## Summary

- Disables the `github-actions` Renovate manager in `renovate.json`
- Removes the previous `fileMatch` override that pointed Renovate at Forgejo workflow files
- Stops Renovate from scanning workflow YAML files for action version updates, eliminating GitHub API calls and the "GitHub token is required" warning

## Test plan

- [ ] Verify `renovate.json` is valid JSON (done locally with `python3 -m json.tool`)
- [ ] Confirm the next Renovate run no longer produces the GitHub token warning in its logs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/306
2026-05-28 05:03:02 +02:00
Bot of Thomas Güttler a5928c1aa6 fix: add _tea_get and merged-PR catch-up to close issues on merge (#305) (#310) 2026-05-28 00:07:13 +02:00
Bot of Thomas Güttler 7f3cd43d6e feat: add --dangerously-skip-permissions to claude --resume output (#304) (#309) 2026-05-27 23:48:12 +02:00
Bot of Thomas Güttler f0f210e5ab feat: configurable next action after single mail view (#300) (#308) 2026-05-27 23:33:14 +02:00
Bot of Thomas Güttler 41550eb4b5 feat: configurable menu bar position for mailbox view (#298) (#303) 2026-05-27 22:07:12 +02:00
Bot of Thomas Güttler 633fc5d9da fix: show full discrepancy details in account list (#296) (#301) 2026-05-27 21:20:19 +02:00
Bot of Thomas Güttler 14f64cd2a5 feat: show URL tooltip on long-press of unsubscribe chip (#294) (#295) 2026-05-27 21:02:30 +02:00
Bot of Thomas Güttler 5ddfe68467 feat: catch up Renovate PRs with passing CI in agent loop (#289) (#293) 2026-05-27 20:09:13 +02:00
Bot of Thomas Güttler f42522e6d0 Merge pull request 'chore(deps): update gradle to v8.14.5' (#274) from renovate/gradle-8.x into main 2026-05-27 20:02:49 +02:00
guettlibotandBot of Thomas Güttler db78d590ca chore(deps): update opentelemetry-go monorepo to v0.19.0 (#279) 2026-05-27 20:00:52 +02:00
Bot of Thomas Güttler dbb29fb76a fix: rename workflow to Update Website and guard verify step (#282) (#283) 2026-05-27 20:00:39 +02:00
guettlibotandBot of Thomas Güttler 2d2d12cc24 chore(deps): update dependency flutter to v3.44.0 (#278) 2026-05-27 20:00:08 +02:00
guettlibotandBot of Thomas Güttler 3f0b3e5096 fix(deps): update dependency com.android.tools:desugar_jdk_libs to v2.1.5 (#275) 2026-05-27 19:59:21 +02:00
guettlibotandBot of Thomas Güttler 38fab3f5fc chore(deps): update gradle to v8.14.5 (#274) 2026-05-27 19:58:36 +02:00
Bot of Thomas Güttler e2b08e07b7 fix: prevent HTML email content from being cut off (#288) (#292) 2026-05-27 19:52:14 +02:00
Bot of Thomas Güttler c0dd13be5d feat: align single and multi-mail actions, add archive (#287) (#291) 2026-05-27 19:36:13 +02:00
Bot of Thomas Güttler 4e32984ecc fix: prompt to create or pick folder when archive is missing (#286) (#290) 2026-05-27 19:06:37 +02:00
Bot of Thomas Güttler 2f975829e5 feat: auto-merge safe Renovate PRs via CI (#277) (#284) 2026-05-27 09:37:15 +02:00
Bot of Thomas Güttler 73bbfd2694 fix: add explicit note that app settings are never uploaded (#280) (#281) 2026-05-27 08:25:20 +02:00
Thomas SharedInbox 49e6b335d9 better err msg in agent-loop. 2026-05-27 08:14:42 +02:00
guettlibot 96bd351512 chore(deps): update gradle to v8.14.5 2026-05-27 06:06:19 +00:00
Thomas SharedInboxandClaude Sonnet 4.6 e8234981c5 fix(renovate): run sed as root to patch read-only dist files
The /usr/local/renovate/dist directory is owned by root.
Temporarily switch to root for the sed patch, then back to ubuntu.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:55:31 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 cf94c7c1fb fix(renovate): patch forgejo+gitea pr-cache.js at /dist/ path
Files are under dist/ not lib/, and we need to patch both
forgejo and gitea platform caches since platform=forgejo is set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:39:13 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 92183a3eb2 chore(renovate): diagnostic step to find pr-cache.js location
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:29:09 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 4e8a5ff968 fix(renovate): use find to locate pr-cache.js before patching
The file is not at the assumed path; use find to locate it first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:19:48 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 33f1c5a9d4 fix(renovate): patch pr-cache.js to use limit=10 for Codeberg
Codeberg's API times out (504) on GET /pulls?state=all&limit=100
but completes in ~9s at limit=10. Patch the compiled pr-cache.js
in the renovate:43 image before running to replace the hardcoded
20/100 page sizes with 10.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:18:02 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 0552b7a48c fix(renovate): pre-seed PR cache to avoid Codeberg 504 on initial sync
Codeberg's API times out (504) when fetching 100 closed PRs
(GET /pulls?state=all&limit=100), but succeeds with limit=20.
Renovate uses limit=100 on the first run and limit=20 on incremental
syncs. Pre-seeding the repository cache with one dummy entry tricks
Renovate into using the limit=20 incremental path from the start.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:09:41 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2f0da5b475 fix(renovate): upgrade to renovate:43 with forgejo platform
renovate/renovate:39 did not support "forgejo" as a platform name;
v43 does. Upgrade the image and restore the correct platform name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:28:15 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 a1f8bb5994 fix: use RENOVATE_PLATFORM=gitea for renovate/renovate:39
renovate/renovate:39 does not recognise "forgejo" as a platform name;
the correct value is "gitea", which covers Forgejo/Gitea instances
including Codeberg.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:27:15 +02:00
Bot of Thomas Güttler 6714e330cc Merge pull request 'feat: run Firebase tests once daily via dedicated workflow (#272)' (#273) from issue-272-fix into main 2026-05-26 17:20:37 +02:00
Thomas SharedInbox a8d6ec5861 fix: use commit_sha instead of head_sha to detect already-deployed commits
Forgejo's API returns head_sha=null in workflow run objects; the correct
field is commit_sha. The skip-check always got None, so every hourly
schedule triggered a full redeploy of the same commit.
2026-05-26 15:22:23 +02:00
Thomas SharedInbox 491a220fbb fix: use commit_sha instead of head_sha to detect already-deployed commits
Forgejo's API returns head_sha=null in workflow run objects; the correct
field is commit_sha. The skip-check always got None, so every hourly
schedule triggered a full redeploy of the same commit.
2026-05-26 15:21:50 +02:00
Thomas SharedInbox e22c4aa88d fix: use Dagger for website deploy and record Renovate Bot completion (#267, #268) 2026-05-26 15:09:59 +02:00
Thomas SharedInbox 4bc24072f0 feat: run Firebase tests once daily via dedicated workflow (#272) 2026-05-26 15:09:55 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 720c54433a feat: run Firebase tests once daily via dedicated workflow (#272)
Move Android Firebase instrumented tests out of deploy.yml into a new
firebase-tests.yml workflow that runs once per day (3 AM UTC) and only
when Firebase-relevant files changed in the last 24 hours. On failure,
the workflow automatically creates a Forgejo issue labelled "Ready" with
instructions to find the root cause and fix it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 08:48:10 +02:00
Thomas SharedInbox dd26086220 docs: record Renovate Bot completion and close issue #257 (#268)
All required components (renovate.json, ci/main.go Renovate() function,
.forgejo/workflows/renovate.yml, Taskfile.yml renovate task) were already
in main. Closed issue #257.
2026-05-26 08:19:49 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2747ff0dca fix: use Dagger for website deploy instead of bare hugo call (#267)
Replace `task website-deploy` (which calls `hugo` directly and fails
because Hugo is not installed on the CI runner) with the Dagger-based
`task publish-website`, matching the pattern used by other jobs in
deploy.yml. Also adds Dagger remote engine setup, runner tool checks,
SSH_KNOWN_HOSTS secret, a timeout, and TLS credential cleanup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 08:01:37 +02:00
Bot of Thomas Güttler f57a8c502d feat: syncLog add Copy button, stack trace, isPermanent (#266) (#269) 2026-05-26 07:55:07 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 c4efb56a0c feat: syncLog add Copy button, stack trace, isPermanent, Android device info (#266)
- Schema v33: add error_stack_trace and is_permanent columns to sync_logs
- SyncLogEntry gains stackTrace and isPermanent fields; SyncLogRepository.log()
  gains matching optional parameters; IMAP and JMAP sync loops forward the
  stack trace string and isPermanent flag when writing error entries
- New lib/ui/utils/about_markdown.dart utility shared by AboutScreen and the
  sync log copy feature; builds the markdown table including device info
- AboutScreen uses the utility (refactored to remove duplicate _buildMarkdown)
- SyncLogScreen: subtitle shows "Error (permanent)" for permanent errors;
  expanded view shows stack trace in red monospace; each tile has a Copy
  button that copies a markdown summary of the entry plus the About section
- Migration test updated for v33; new repo test for stackTrace/isPermanent
- check_coverage.dart excludes lib/ui/utils/about_markdown.dart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 07:49:56 +02:00
c97e3d505f fix: skip deploy when HEAD already successfully deployed (#264) (#265)
## Summary

- The hourly `deploy.yml` schedule re-deployed the same commit repeatedly because it always diffed `HEAD~1..HEAD` — once a commit touching `lib/`/`pubspec.*` became HEAD, every hourly tick would detect "android changes" and deploy again.
- Fix: at the start of the `check-changes` job, query the Forgejo workflow runs API for the last successful `deploy.yml` run. If its `head_sha` matches current HEAD, output `android=false` / `linux=false` immediately, skipping all downstream jobs.
- `workflow_dispatch` bypasses this check (always deploys), matching the existing behaviour.

## Test plan

- [ ] Verify the `check-changes` job exits early on the next scheduled run after a successful deploy of the same commit
- [ ] Verify a new commit still triggers deployment normally
- [ ] Verify `workflow_dispatch` still deploys unconditionally

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/265
2026-05-26 07:35:18 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2bb7ac11df feat: add runner tools check and LOG_LEVEL to Renovate Bot (#257)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 06:24:47 +02:00
Bot of Thomas Güttler 8709e9f38d feat: add Locale, Text Scale, DB Schema Version, Device Model to About page (#258) (#263) 2026-05-25 22:18:09 +02:00
Bot of Thomas Güttler 7997ff0980 feat: Reply All dialog on Reply button, add Mark as Spam (#260) (#261) 2026-05-25 21:51:08 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2359c7d586 feat: run Renovate via Dagger on daily schedule (#257, #216)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:27:01 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 4ada3798b6 feat: run Renovate via Dagger on daily schedule (#257, #216)
Adds a Renovate() Dagger function using the forgejo platform and a
.forgejo/workflows/renovate.yml workflow triggered at 06:00 UTC daily.
Uses RENOVATE_FORGEJO_TOKEN secret; no dedicated Renovate service account needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:26:44 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 07ac73dcb2 feat: add Renovate Bot configuration (#216)
Adds renovate.json to enable automated dependency updates for
pub (pubspec.yaml), Dockerfile, and Forgejo Actions workflows.
The github-actions manager fileMatch is extended to cover
.forgejo/workflows/ in addition to the default .github/ path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:25:51 +02:00
Bot of Thomas Güttler bb475a2350 fix: auto-resolve merge failures instead of asking for manual merge (#253) (#256) 2026-05-25 19:38:07 +02:00
Thomas SharedInbox 63f7463ced feat: add Gradle cache to Android release builds (#251) (#252) 2026-05-25 19:27:06 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 0175c9e5a5 feat: add Gradle cache to Android release builds (#251)
Introduce androidBase() and firebaseBase() helpers that wrap setup() with
the Gradle named-cache volume, mirroring the pattern already used in
BuildAndroidDebugApks(). Use these in BuildAndroidRelease(), setupKeystore(),
and BuildAndroidDebugApks() so Gradle dependencies survive Dagger
execution-cache misses instead of being re-downloaded on every source change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 19:26:17 +02:00
Bot of Thomas Güttler 9f9bf14bbe feat: inject GIT_HASH into Dagger builds so About page shows git hash (#249) (#250) 2026-05-25 15:10:12 +02:00
Bot of Thomas Güttler a7783d46cf fix: disable Save button when no password available; fix changelog fetch-depth (#246, #229) (#248) 2026-05-25 14:47:25 +02:00
Bot of Thomas Güttler 3868c160d3 fix: disable Try connection button when no password is available (#235) (#247) 2026-05-25 14:30:13 +02:00
Bot of Thomas Güttler 50fc012e81 Merge pull request 'fix: show password required error instead of crashing when no stored password (#235)' (#238) from issue-235-fix into main 2026-05-25 13:00:44 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 94b20f50be style: format edit_account_screen_test.dart
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 12:49:29 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 885906b204 fix: show password required error instead of crashing when no stored password (#235)
During _load(), check whether a password exists in secure storage and track the result
in _hasStoredPassword. The password field validator now requires user input when no
password is stored, so _tryConnection() fails fast at form validation instead of
throwing an unhandled StateError.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 12:49:29 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 06df3ee200 feat: monitor agent loop health every 2 hours (#217)
- Track a heartbeat timestamp in ~/.sharedinbox-agent-heartbeat at the
  start of each _run_loop() invocation so we can tell when it last ran.
- Add `agent_loop.py monitor` subcommand that exits 1 with a WARNING
  message if the heartbeat is missing, corrupted, or older than 2 hours.
- Add .forgejo/workflows/monitor.yml scheduled workflow that runs the
  monitor check every 2 hours on the self-hosted runner; a CI failure
  serves as the warning when the loop is stalled.
- Add 7 unit tests covering all monitor / heartbeat scenarios.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 12:48:45 +02:00
Bot of Thomas Güttler e03c7708ba feat: show app version as link on crash screen and in MD report (#236) (#245) 2026-05-25 11:40:53 +02:00
27bef3356e fix: skip catch-up merge retry when issue has State/Question (#239) (#242)
When a catch-up PR merge fails (PR stays open after the merge command), the loop sets the issue to State/Question and comments on it. But on the next cron tick the same PR is still open with passing CI, so it tries again — spamming the issue with identical comments every minute.

Fix: before attempting a catch-up merge, fetch the issue's current labels via `_get_issue_labels()`. If `State/Question` is already set, skip the PR entirely.

Closes #239

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/242
2026-05-25 09:21:23 +02:00
Bot of Thomas Güttler 32ba916cbf fix: trigger deploy on script changes, add changelog dep, deepen fetch (#228) (#233) 2026-05-24 21:05:10 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 86e12ffe72 fix: add fgj to nix store PATH in deploy.sh
fgj is in the nix store but was not included in the PATH glob loop,
causing `FileNotFoundError: 'fgj'` on every cron run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:02:13 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 f4a052bedc feat: add State/ToPlan planning phase to agent loop
Issues labelled State/ToPlan are now picked up by a dedicated planning
agent before any implementation happens. The agent posts a plan as an
issue comment, then the loop transitions the label to State/Planned and
leaves a resume command in a follow-up comment. A human reviews the plan
and manually promotes the issue to State/Ready to trigger implementation.

Planning agents run at higher priority than Ready issues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:56:46 +02:00
Thomas SharedInbox b2c11e0c63 Revert "feat: keep secrets in sync via age-encrypted master key (#208) (#223)"
This reverts commit 96b1660b59.
2026-05-24 18:39:23 +02:00
Bot of Thomas Güttler 09c90c244b fix: load changelog via DefaultAssetBundle for testability (#214) (#225) 2026-05-24 17:50:10 +02:00
Bot of Thomas Güttler 357ed9af31 fix: about page version unknown and link crash on Android (#213) (#224) 2026-05-24 17:20:09 +02:00
Bot of Thomas Güttler 96b1660b59 feat: keep secrets in sync via age-encrypted master key (#208) (#223) 2026-05-24 16:35:10 +02:00
Bot of Thomas Güttler e7ff9243c9 feat: add build mode, Dart version, timestamp to crash report (#205) (#222) 2026-05-24 16:10:09 +02:00
Bot of Thomas Güttler d51e67ddcc fix: probe scanner method channel to detect MissingPluginException (#204) (#221) 2026-05-24 15:55:08 +02:00
Bot of Thomas Güttler 43068509d2 fix: show live countdown with seconds on receive account screen (#203) (#220) 2026-05-24 15:15:12 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 d9b8748631 fix: filter _latest_main_ci_run by workflow_id == ci.yml
Forgejo reports deploy.yml (scheduled/dispatch) runs with event=push
and prettyref=main, identical to ci.yml push runs. The event-only
filter was insufficient — adding workflow_id == "ci.yml" prevents
deploy.yml runs from blocking or triggering false CI fix agents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:07:00 +02:00
Bot of Thomas Güttler 50ae7df8a3 fix: fall back to text input when mobile_scanner plugin is unavailable (#202) (#219) 2026-05-24 14:55:07 +02:00
Bot of Thomas Güttler 7dd5800064 perf: cache Linux engine artifacts via flutter precache --linux (#129) (#218) 2026-05-24 14:30:07 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 77e581299d fix: filter out schedule/deploy workflow runs in CI checks
_latest_main_ci_run() was using event != pull_request which still
matched deploy.yml schedule runs when their prettyref == "main",
blocking the loop from picking up new issues.

_latest_ci_run_for_branch() had the same issue: the else branch matched
any non-pull_request event including schedule runs.

Both functions now explicitly filter for event == "push" only.

Tests updated: rename _latest_ci_run → _latest_main_ci_run, mock
_open_issue_prs to prevent real API calls in unit tests, and update
_find_pr_for_branch side_effect to reflect the upstream post-merge
PR-still-open verification check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:08:13 +02:00
Bot of Thomas Güttler 37eca207c6 fix: pin SSH host key via known_hosts instead of StrictHostKeyChecking=no (#161) (#181) 2026-05-24 13:00:04 +02:00
Bot of Thomas Güttler 5925cee4f2 fix: show git hash as clickable link above stacktrace (#201) (#211) 2026-05-24 12:56:27 +02:00
Bot of Thomas Güttler a8603edfc3 fix: verify PID belongs to claude before SIGKILL (#160) (#163) 2026-05-24 12:55:08 +02:00
84 changed files with 4890 additions and 711 deletions
+48
View File
@@ -109,3 +109,51 @@ jobs:
- name: Cleanup TLS credentials - name: Cleanup TLS credentials
if: always() if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
merge-renovate:
name: Auto-merge Renovate PR
needs: [check]
if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'renovate/')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Merge if automerge label is set
env:
FORGEJO_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error, sys
token = os.environ["FORGEJO_TOKEN"]
url_base = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
pr_number = os.environ["PR_NUMBER"]
api = f"{url_base}/api/v1/repos/{repo}"
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
req = urllib.request.Request(f"{api}/issues/{pr_number}/labels", headers=headers)
with urllib.request.urlopen(req) as r:
labels = [l["name"] for l in json.loads(r.read())]
if "automerge" not in labels:
print(f"PR #{pr_number}: no 'automerge' label — major update, skipping")
sys.exit(0)
body = json.dumps({"Do": "merge"}).encode()
req = urllib.request.Request(
f"{api}/pulls/{pr_number}/merge",
data=body, headers=headers, method="POST"
)
try:
with urllib.request.urlopen(req) as r:
print(f"PR #{pr_number} merged successfully")
except urllib.error.HTTPError as e:
err = e.read().decode()
if "already been merged" in err or "has been merged" in err:
print(f"PR #{pr_number} already merged — OK")
else:
print(f"Merge failed: {err}")
sys.exit(1)
PYEOF
+54 -91
View File
@@ -17,11 +17,13 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 2 fetch-depth: 0
- name: Detect Android and Linux changes - name: Detect Android and Linux changes
id: diff id: diff
shell: bash shell: bash
env:
FORGEJO_TOKEN: ${{ github.token }}
run: | run: |
# On workflow_dispatch always build everything # On workflow_dispatch always build everything
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
@@ -30,15 +32,54 @@ jobs:
exit 0 exit 0
fi fi
# Diff the HEAD commit against its parent; fall back to listing HEAD's files HEAD_SHA=$(git rev-parse HEAD)
# when the parent is unavailable (initial commit, shallow clone).
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \ # Skip if this exact commit was already successfully deployed (prevents
|| git show --name-only --format= HEAD) # hourly schedule from redeploying the same commit on every tick).
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
import json, os, sys, urllib.request
token = os.environ.get("FORGEJO_TOKEN", "")
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
url = f"{server}/api/v1/repos/{repo}/actions/runs?workflow_id=deploy.yml&status=success&limit=5"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req) as r:
data = json.loads(r.read())
runs = [
r for r in data.get("workflow_runs", [])
if r.get("status") == "success"
]
print(runs[0].get("commit_sha") or "")
except Exception as e:
print(f"API check failed: {e}", file=sys.stderr)
print("")
PYEOF
)
if [ -n "$LAST_DEPLOYED_SHA" ] && [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
echo "HEAD $HEAD_SHA already successfully deployed — skipping"
echo "android=false" >> "$GITHUB_OUTPUT"
echo "linux=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Diff from the last successfully deployed commit to catch all changes since
# that deploy, not just the most recent commit. Falls back to HEAD~1 when
# LAST_DEPLOYED_SHA is unknown or not in local history.
if [ -n "$LAST_DEPLOYED_SHA" ] && git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
else
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
fi
echo "Changed files:" echo "Changed files:"
echo "$CHANGED" echo "$CHANGED"
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/)' android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)' linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
echo "$CHANGED" | grep -qE "$android_re" \ echo "$CHANGED" | grep -qE "$android_re" \
@@ -49,44 +90,6 @@ jobs:
&& echo "linux=true" >> "$GITHUB_OUTPUT" \ && echo "linux=true" >> "$GITHUB_OUTPUT" \
|| echo "linux=false" >> "$GITHUB_OUTPUT" || echo "linux=false" >> "$GITHUB_OUTPUT"
test-android-firebase:
name: Android Instrumented Tests (Firebase Test Lab)
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.android == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Run Android Tests on Firebase Test Lab
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
env:
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
DAGGER_NO_NAG: "1"
run: task test-android-firebase
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
deploy-playstore: deploy-playstore:
name: Build & Deploy to Play Store name: Build & Deploy to Play Store
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -97,7 +100,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 100
- name: Check runner tools - name: Check runner tools
run: | run: |
@@ -136,7 +139,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 100
- name: Check runner tools - name: Check runner tools
run: | run: |
@@ -156,6 +159,7 @@ jobs:
if: ${{ secrets.SSH_PRIVATE_KEY != '' }} if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }} SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }} SSH_HOST: ${{ secrets.SSH_HOST }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
@@ -177,7 +181,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 100
- name: Check runner tools - name: Check runner tools
run: | run: |
@@ -197,6 +201,7 @@ jobs:
if: ${{ secrets.SSH_PRIVATE_KEY != '' }} if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }} SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }} SSH_HOST: ${{ secrets.SSH_HOST }}
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
@@ -206,54 +211,12 @@ jobs:
if: always() if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
publish-website:
name: Publish Website Build History
runs-on: ubuntu-latest
needs: [build-linux, deploy-playstore, deploy-apk]
if: |
always() &&
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Generate build history and deploy website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
DAGGER_NO_NAG: "1"
run: task publish-website
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
label-deploy-health: label-deploy-health:
name: Update Deploy Health Label name: Update Deploy Health Label
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux] needs: [deploy-playstore, deploy-apk, build-linux]
if: | if: |
always() && vars.DEPLOY_HEALTH_ISSUE != '' && ( always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'failure' ||
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' || needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' || needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure' needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
@@ -266,7 +229,7 @@ jobs:
FORGEJO_TOKEN: ${{ github.token }} FORGEJO_TOKEN: ${{ github.token }}
FORGEJO_URL: ${{ github.server_url }} FORGEJO_URL: ${{ github.server_url }}
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }} DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
ALL_SUCCEEDED: ${{ (needs.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') }} ALL_SUCCEEDED: ${{ (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
run: | run: |
python3 - << 'PYEOF' python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error import os, json, urllib.request, urllib.error
+132
View File
@@ -0,0 +1,132 @@
name: Firebase Tests
on:
schedule:
- cron: '0 3 * * *' # once per day at 3 AM
workflow_dispatch:
jobs:
check-changes:
name: Detect Firebase-Relevant Changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
has_changes: ${{ steps.diff.outputs.has_changes }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect Firebase-relevant changes in last 24 hours
id: diff
shell: bash
run: |
# On workflow_dispatch always run
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi
SINCE=$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%S')
CHANGED=$(git log --since="$SINCE" --name-only --format= -- \
'android/' 'integration_test/' 'lib/' 'pubspec.yaml' 'pubspec.lock' 'drift_schemas/' \
| sort -u | grep -v '^$')
if [ -n "$CHANGED" ]; then
echo "Firebase-relevant files changed since $SINCE:"
echo "$CHANGED"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
else
echo "No Firebase-relevant changes in the last 24 hours — skipping tests"
echo "has_changes=false" >> "$GITHUB_OUTPUT"
fi
test-android-firebase:
name: Android Instrumented Tests (Firebase Test Lab)
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.has_changes == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Run Android Tests on Firebase Test Lab
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
env:
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
DAGGER_NO_NAG: "1"
run: task test-android-firebase
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
- name: Create issue on test failure
if: failure()
env:
FORGEJO_TOKEN: ${{ github.token }}
FORGEJO_URL: ${{ github.server_url }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error
token = os.environ["FORGEJO_TOKEN"]
url_base = os.environ["FORGEJO_URL"].rstrip("/")
run_url = os.environ["RUN_URL"]
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
api = f"{url_base}/api/v1/repos/guettli/sharedinbox"
def api_get(path):
req = urllib.request.Request(f"{api}{path}", headers=headers)
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
def api_post(path, body):
data = json.dumps(body).encode()
req = urllib.request.Request(f"{api}{path}", data=data, headers=headers, method="POST")
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
repo_labels = api_get("/labels")
label_map = {l["name"]: l["id"] for l in repo_labels}
label_ids = [label_map["Ready"]] if "Ready" in label_map else []
title = "Firebase Tests failed — find root cause and fix"
body = (
"Firebase instrumented tests failed in the daily run.\n\n"
f"**Failed run:** {run_url}\n\n"
"## Steps to resolve\n\n"
"1. **Find the root cause**: Check the test run logs linked above and identify which test(s) failed and why.\n"
"2. **Fix if possible**: If the failure is caused by a code bug, create a fix. If it is a flaky or infrastructure issue, document the findings.\n"
"3. Close this issue once the root cause is resolved and the tests pass.\n"
)
issue = api_post("/issues", {
"title": title,
"body": body,
"labels": label_ids,
})
print(f"Created issue #{issue['number']}: {issue['html_url']}")
PYEOF
+18
View File
@@ -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
+39
View File
@@ -0,0 +1,39 @@
name: Renovate
on:
schedule:
- cron: '0 6 * * *'
workflow_dispatch:
jobs:
renovate:
name: Renovate
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Run Renovate
env:
DAGGER_NO_NAG: "1"
RENOVATE_FORGEJO_TOKEN: ${{ secrets.RENOVATE_FORGEJO_TOKEN }}
run: task renovate
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
+29 -4
View File
@@ -1,6 +1,8 @@
name: Deploy Website name: Update Website
on: on:
schedule:
- cron: '0 * * * *' # every hour on the hour
push: push:
branches: [main] branches: [main]
paths: paths:
@@ -11,22 +13,45 @@ on:
jobs: jobs:
deploy: deploy:
name: Build & Deploy Website name: Build & Update Website
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
submodules: recursive submodules: recursive
- name: Build & Deploy Website - name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Build & Update Website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }} SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }} SSH_HOST: ${{ secrets.SSH_HOST }}
run: task website-deploy DAGGER_NO_NAG: "1"
run: task publish-website
- name: Verify Website - name: Verify Website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env: env:
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }} SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
run: scripts/website-verify.sh run: scripts/website-verify.sh
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"flutter": "3.41.6" "flutter": "3.44.0"
} }
+7 -6
View File
@@ -202,6 +202,8 @@ jobs:
mkdir -p ~/.ssh mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.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 - name: Build Linux release
run: | run: |
@@ -215,20 +217,20 @@ jobs:
REMOTE_DIR="public_html/builds/$DATE_PATH" REMOTE_DIR="public_html/builds/$DATE_PATH"
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz" TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR" ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL" scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$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 '{}'") "cat public_html/latest.json 2>/dev/null || echo '{}'")
WINDOWS_URL=$(echo "$EXISTING" | \ WINDOWS_URL=$(echo "$EXISTING" | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" \ python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" \
2>/dev/null || true) 2>/dev/null || true)
if [ -n "$WINDOWS_URL" ]; then if [ -n "$WINDOWS_URL" ]; then
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \ 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 else
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \ 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 fi
- name: Generate build history pages - name: Generate build history pages
@@ -244,6 +246,5 @@ jobs:
rsync -avz --delete \ rsync -avz --delete \
--exclude='*.apk' \ --exclude='*.apk' \
--exclude='*.tar.gz' \ --exclude='*.tar.gz' \
-e "ssh -o StrictHostKeyChecking=no" \
website/public/ \ website/public/ \
"$SSH_USER@$SSH_HOST:public_html/" "$SSH_USER@$SSH_HOST:public_html/"
+2 -1
View File
@@ -28,7 +28,8 @@ android/.gradle/
android/local.properties android/local.properties
android/app/google-services.json android/app/google-services.json
android/key.properties android/key.properties
android/app/src/main/java/io/flutter/plugins/ # android/app/src/main/java/io/flutter/plugins/ intentionally tracked so that
# GeneratedPluginRegistrant.java (catch Throwable) is committed and used by CI.
.android/ .android/
Android/ Android/
.gradle/ .gradle/
+2 -2
View File
@@ -33,12 +33,12 @@ repos:
- id: ci-no-direct-dagger - id: ci-no-direct-dagger
name: check for direct dagger calls in workflows (use Task instead) name: check for direct dagger calls in workflows (use Task instead)
language: system language: system
entry: "bash -c 'git grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'" entry: "bash -c 'git --no-pager grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'"
pass_filenames: false pass_filenames: false
always_run: true always_run: true
- id: dagger-progress-plain - id: dagger-progress-plain
name: ensure all dagger calls use --progress=plain name: ensure all dagger calls use --progress=plain
language: system language: system
entry: "bash -c 'git grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'" entry: "bash -c 'git --no-pager grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
pass_filenames: false pass_filenames: false
always_run: true always_run: true
+20 -6
View File
@@ -10,9 +10,21 @@ CLI tool `fgj` is available to query issues/PRs/actions.
We use issues, follow this label state machine: We use issues, follow this label state machine:
- **State/Ready** — Issue is available to pick up - **State/ToPlan** — Issue needs a plan written by an agent before implementation
- **State/InProgress** — Set this when you start working on an issue - **State/Planned** — Plan has been posted as a comment; awaiting human review
- **State/Question** — Set this when you hit a blocker or need clarification - **State/Ready** — Issue is approved and ready for implementation
- **State/InProgress** — Set while an agent (or human) is actively working
- **State/Question** — Agent hit a blocker or needs clarification
Full lifecycle:
```
State/ToPlan → State/Planned (automated: agent_loop.py runs a planning agent)
State/Planned → State/Ready (manual: human reviews the plan and approves)
State/Ready → State/InProgress (automated: agent_loop.py before starting implementation)
State/InProgress → closed (automated: after PR is merged and CI passes)
any state → State/Question (automated or manual: when blocked)
```
List open issues ready to pick up: List open issues ready to pick up:
@@ -22,9 +34,11 @@ fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/
Rules: Rules:
- Never start work on an issue without `State/Ready` - Never start implementation on an issue without `State/Ready`
- When working via the agent loop: `State/Ready``State/InProgress` is set automatically - Planning agents only post a plan comment — they do NOT write code or open PRs
by `agent_loop.py` before the agent starts — do **not** set it yourself. - After `State/Planned`, a human must review the plan and manually add `State/Ready`
- When working via the agent loop: label transitions are set automatically
by `agent_loop.py` — do **not** set them yourself.
- When working manually: switch to `State/InProgress` as your **first action**: - When working manually: switch to `State/InProgress` as your **first action**:
```bash ```bash
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress" fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
+2
View File
@@ -188,3 +188,5 @@ Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks.
## Daily Workflow ## Daily Workflow
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands. Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
<!-- agentloop code test passed -->
+53 -19
View File
@@ -215,14 +215,16 @@ tasks:
preconditions: preconditions:
- sh: test -n "$SSH_PRIVATE_KEY" - sh: test -n "$SSH_PRIVATE_KEY"
msg: "SSH_PRIVATE_KEY is not set" msg: "SSH_PRIVATE_KEY is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds: cmds:
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --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: build-android-bundle:
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
cmds: cmds:
- mkdir -p build/app/outputs/bundle/release - mkdir -p build/app/outputs/bundle/release
- dagger call --progress=plain -q -m ci --source=. build-android-release -o build/app/outputs/bundle/release/app-release.aab - HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
upload-android-bundle: upload-android-bundle:
desc: Upload AAB from build/ to Play Store via Dagger desc: Upload AAB from build/ to Play Store via Dagger
@@ -236,6 +238,7 @@ tasks:
publish-android: publish-android:
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
deps: [generate-changelog]
preconditions: preconditions:
- sh: test -n "$PLAY_STORE_CONFIG_JSON" - sh: test -n "$PLAY_STORE_CONFIG_JSON"
msg: "PLAY_STORE_CONFIG_JSON is not set" msg: "PLAY_STORE_CONFIG_JSON is not set"
@@ -244,24 +247,31 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD" - sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set" msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds: cmds:
- dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD - HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
deploy-apk: deploy-apk:
desc: Build and deploy Android APK via Dagger desc: Build and deploy Android APK via Dagger
preconditions: preconditions:
- sh: test -n "$SSH_PRIVATE_KEY" - sh: test -n "$SSH_PRIVATE_KEY"
msg: "SSH_PRIVATE_KEY is not set" 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" - sh: test -n "$ANDROID_KEYSTORE_BASE64"
msg: "ANDROID_KEYSTORE_BASE64 is not set" msg: "ANDROID_KEYSTORE_BASE64 is not set"
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD" - sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set" msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds: cmds:
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --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: publish-website:
desc: Build and publish website via Dagger 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: 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: check-dagger:
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available) desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
@@ -284,7 +294,7 @@ tasks:
for attempt in 1 2 3; do for attempt in 1 2 3; do
run_dagger "$@" && return 0 run_dagger "$@" && return 0
RC=$? RC=$?
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|invalid return status code" "$DAGGER_OUT"; then if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|context canceled|context deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; then
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2 echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2 echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
@@ -326,6 +336,14 @@ tasks:
- | - |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
renovate:
desc: Run Renovate bot against the repository via Dagger
preconditions:
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
msg: "RENOVATE_FORGEJO_TOKEN is not set"
cmds:
- dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
integration-android: integration-android:
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2) desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
deps: [_preflight, _android-sdk-check, _android-avd-setup] deps: [_preflight, _android-sdk-check, _android-avd-setup]
@@ -373,25 +391,29 @@ tasks:
msg: "SSH_USER is not set" msg: "SSH_USER is not set"
- sh: test -n "$SSH_HOST" - sh: test -n "$SSH_HOST"
msg: "SSH_HOST is not set" msg: "SSH_HOST is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds: cmds:
- | - |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
HASH=$(git rev-parse --short HEAD) HASH=$(git rev-parse --short HEAD)
DATE_PATH=$(date -u +%Y/%m/%d) DATE_PATH=$(date -u +%Y/%m/%d)
REMOTE_DIR="public_html/builds/$DATE_PATH" REMOTE_DIR="public_html/builds/$DATE_PATH"
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz" TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR" ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL" scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL" DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
# Merge with any existing latest.json so we don't overwrite the windows key # 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) 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 if [ -n "$WINDOWS_URL" ]; then
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \ 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 else
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \ 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 fi
echo "Uploaded $TARBALL and updated latest.json" echo "Uploaded $TARBALL and updated latest.json"
@@ -416,24 +438,28 @@ tasks:
msg: "SSH_USER is not set" msg: "SSH_USER is not set"
- sh: test -n "$SSH_HOST" - sh: test -n "$SSH_HOST"
msg: "SSH_HOST is not set" msg: "SSH_HOST is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds: cmds:
- | - |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
HASH=$(git rev-parse --short HEAD) HASH=$(git rev-parse --short HEAD)
DATE_PATH=$(date -u +%Y/%m/%d) DATE_PATH=$(date -u +%Y/%m/%d)
REMOTE_DIR="public_html/builds/$DATE_PATH" REMOTE_DIR="public_html/builds/$DATE_PATH"
ZIPFILE="sharedinbox-windows-x64-$HASH.zip" ZIPFILE="sharedinbox-windows-x64-$HASH.zip"
cd build/windows/x64/runner && zip -r /tmp/$ZIPFILE Release/ && cd - cd build/windows/x64/runner && zip -r /tmp/$ZIPFILE Release/ && cd -
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR" ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp -o StrictHostKeyChecking=no /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE" scp /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$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) 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 if [ -n "$LINUX_URL" ]; then
echo "{\"version\":\"$HASH\",\"linux\":\"$LINUX_URL\",\"windows\":\"$DOWNLOAD_URL\"}" | \ 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 else
echo "{\"version\":\"$HASH\",\"windows\":\"$DOWNLOAD_URL\"}" | \ 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 fi
echo "Uploaded $ZIPFILE and updated latest.json" echo "Uploaded $ZIPFILE and updated latest.json"
@@ -583,14 +609,18 @@ tasks:
msg: "SSH_USER is not set" msg: "SSH_USER is not set"
- sh: test -n "$SSH_HOST" - sh: test -n "$SSH_HOST"
msg: "SSH_HOST is not set" msg: "SSH_HOST is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds: cmds:
- | - |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
HASH=$(git rev-parse --short HEAD) HASH=$(git rev-parse --short HEAD)
DATE_PATH=$(date -u +%Y/%m/%d) DATE_PATH=$(date -u +%Y/%m/%d)
REMOTE_DIR="public_html/builds/$DATE_PATH" REMOTE_DIR="public_html/builds/$DATE_PATH"
APK_NAME="sharedinbox-mua-$HASH.apk" APK_NAME="sharedinbox-mua-$HASH.apk"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR" ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp -o StrictHostKeyChecking=no \ scp \
build/app/outputs/flutter-apk/app-release.apk \ build/app/outputs/flutter-apk/app-release.apk \
"$SSH_USER@$SSH_HOST:$REMOTE_DIR/$APK_NAME" "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$APK_NAME"
echo "Uploaded $APK_NAME to $REMOTE_DIR" echo "Uploaded $APK_NAME to $REMOTE_DIR"
@@ -619,12 +649,16 @@ tasks:
website-deploy: website-deploy:
desc: Deploy the website via rsync to public_html desc: Deploy the website via rsync to public_html
deps: [website-build] deps: [website-build]
preconditions:
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds: cmds:
- | - |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
rsync -avz --delete \ rsync -avz --delete \
--exclude='*.apk' \ --exclude='*.apk' \
--exclude='*.tar.gz' \ --exclude='*.tar.gz' \
-e "ssh -o StrictHostKeyChecking=no" \
website/public/ \ website/public/ \
${SSH_USER}@${SSH_HOST}:public_html/ ${SSH_USER}@${SSH_HOST}:public_html/
-1
View File
@@ -4,7 +4,6 @@ gradle-wrapper.jar
/gradlew /gradlew
/gradlew.bat /gradlew.bat
/local.properties /local.properties
GeneratedPluginRegistrant.java
.cxx/ .cxx/
# Remember to never publicly share your keystore. # Remember to never publicly share your keystore.
+1 -1
View File
@@ -67,7 +67,7 @@ flutter {
dependencies { dependencies {
// Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26. // Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26.
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
// integration_test is a dev dependency; the Flutter plugin loader adds it as // integration_test is a dev dependency; the Flutter plugin loader adds it as
// debugImplementation only, but GeneratedPluginRegistrant.java (in src/main) // debugImplementation only, but GeneratedPluginRegistrant.java (in src/main)
// references its class in all variants. Make it available for release compilation // references its class in all variants. Make it available for release compilation
@@ -0,0 +1,89 @@
package io.flutter.plugins;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import io.flutter.Log;
import io.flutter.embedding.engine.FlutterEngine;
/**
* Generated file. Do not edit.
* This file is generated by the Flutter tool based on the
* plugins that support the Android platform.
*/
@Keep
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin device_info_plus, dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.steenbakker.mobile_scanner.MobileScannerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin mobile_scanner, dev.steenbakker.mobile_scanner.MobileScannerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.crazecoder.openfile.OpenFilePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin open_filex, com.crazecoder.openfile.OpenFilePlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.share.SharePlusPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin share_plus, dev.fluttercommunity.plus.share.SharePlusPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e);
}
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip
+4 -4
View File
@@ -44,10 +44,10 @@ require (
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0
replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.16.0 replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.19.0
replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.16.0 replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.19.0
+102 -27
View File
@@ -195,7 +195,8 @@ func (m *Ci) toolchain() *dagger.Container {
WithUser("ci"). WithUser("ci").
WithExec([]string{"/bin/sh", "-c", WithExec([]string{"/bin/sh", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`}) `yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`}).
WithExec([]string{"flutter", "precache", "--linux", "--no-android", "--no-ios"})
} }
// Base is the Flutter toolchain container with mutable cache mounts attached. // Base is the Flutter toolchain container with mutable cache mounts attached.
@@ -285,6 +286,21 @@ func (m *Ci) firebaseSrc() *dagger.Directory {
}) })
} }
// androidBase wraps setup(androidSrc()) with the Gradle named-cache so that
// Gradle dependencies survive across Dagger execution-cache misses.
func (m *Ci) androidBase() *dagger.Container {
return m.setup(m.androidSrc()).
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
}
// firebaseBase wraps setup(firebaseSrc()) with the Gradle named-cache.
func (m *Ci) firebaseBase() *dagger.Container {
return m.setup(m.firebaseSrc()).
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
}
// linuxSrc is the source subset for Linux builds and integration tests. // linuxSrc is the source subset for Linux builds and integration tests.
func (m *Ci) linuxSrc() *dagger.Directory { func (m *Ci) linuxSrc() *dagger.Directory {
return m.Source.Filter(dagger.DirectoryFilterOpts{ return m.Source.Filter(dagger.DirectoryFilterOpts{
@@ -318,12 +334,13 @@ func (m *Ci) Hugo() *dagger.Container {
} }
// Deploy container for rsync/ssh // 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(). return dag.Container().
From("alpine:3.21"). From("alpine:3.21").
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}). WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}). 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. // Stalwart mail server service for backend and integration tests.
@@ -514,6 +531,7 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
func (m *Ci) GenerateBuildHistory( func (m *Ci) GenerateBuildHistory(
ctx context.Context, ctx context.Context,
sshKey *dagger.Secret, sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string, sshUser string,
sshHost string, sshHost string,
) *dagger.Directory { ) *dagger.Directory {
@@ -525,7 +543,7 @@ func (m *Ci) GenerateBuildHistory(
From("python:3.12-alpine"). From("python:3.12-alpine").
WithExec([]string{"apk", "add", "--no-cache", "openssh-client"}). WithExec([]string{"apk", "add", "--no-cache", "openssh-client"}).
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}). 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_USER", sshUser).
WithEnvVariable("SSH_HOST", sshHost). WithEnvVariable("SSH_HOST", sshHost).
WithDirectory("/src", scriptSource). WithDirectory("/src", scriptSource).
@@ -538,10 +556,11 @@ func (m *Ci) GenerateBuildHistory(
func (m *Ci) BuildWebsite( func (m *Ci) BuildWebsite(
ctx context.Context, ctx context.Context,
sshKey *dagger.Secret, sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string, sshUser string,
sshHost string, sshHost string,
) *dagger.Directory { ) *dagger.Directory {
buildHistory := m.GenerateBuildHistory(ctx, sshKey, sshUser, sshHost) buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{ websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{
Include: []string{"website/"}, Include: []string{"website/"},
@@ -558,12 +577,13 @@ func (m *Ci) BuildWebsite(
func (m *Ci) PublishWebsite( func (m *Ci) PublishWebsite(
ctx context.Context, ctx context.Context,
sshKey *dagger.Secret, sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string, sshUser string,
sshHost string, sshHost string,
) (string, error) { ) (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). WithDirectory("/public", public).
WithExec([]string{"rsync", "-avz", "--delete", WithExec([]string{"rsync", "-avz", "--delete",
"--exclude=*.apk", "--exclude=*.tar.gz", "--exclude=*.apk", "--exclude=*.tar.gz",
@@ -579,9 +599,17 @@ func (m *Ci) BuildLinux() *dagger.Directory {
} }
// BuildLinuxRelease builds the Linux release bundle. // BuildLinuxRelease builds the Linux release bundle.
func (m *Ci) BuildLinuxRelease() *dagger.Directory { func (m *Ci) BuildLinuxRelease(
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) *dagger.Directory {
args := []string{"flutter", "build", "linux", "--release"}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
return m.setup(m.linuxSrc()). return m.setup(m.linuxSrc()).
WithExec([]string{"flutter", "build", "linux", "--release"}). WithExec(args).
Directory("build/linux/x64/release/bundle") Directory("build/linux/x64/release/bundle")
} }
@@ -589,36 +617,48 @@ func (m *Ci) BuildLinuxRelease() *dagger.Directory {
func (m *Ci) DeployLinux( func (m *Ci) DeployLinux(
ctx context.Context, ctx context.Context,
sshKey *dagger.Secret, sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string, sshUser string,
sshHost string, sshHost string,
commitHash string, commitHash string,
) (string, error) { ) (string, error) {
bundle := m.BuildLinuxRelease() bundle := m.BuildLinuxRelease(commitHash)
datePath := time.Now().Format("2006/01/02") datePath := time.Now().Format("2006/01/02")
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath) remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
tarball := fmt.Sprintf("sharedinbox-linux-amd64-%s.tar.gz", commitHash) tarball := fmt.Sprintf("sharedinbox-linux-amd64-%s.tar.gz", commitHash)
return m.Deployer(sshKey). return m.Deployer(sshKey, knownHosts).
WithDirectory("/bundle", bundle). WithDirectory("/bundle", bundle).
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("tar -czf /tmp/%s -C /bundle .", tarball)}). 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{"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 -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}). 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) Stdout(ctx)
} }
// setupKeystore decodes the base64 keystore into the android build container. // setupKeystore decodes the base64 keystore into the android build container.
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container { func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
return m.setup(m.androidSrc()). return m.androidBase().
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64). WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword). WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`}) WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
} }
// BuildAndroidApk builds a release APK signed with the upload key. // BuildAndroidApk builds a release APK signed with the upload key.
func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string) *dagger.File { func (m *Ci) BuildAndroidApk(
keystoreBase64 *dagger.Secret,
keystorePassword *dagger.Secret,
buildNumber string,
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) *dagger.File {
args := []string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
return m.setupKeystore(keystoreBase64, keystorePassword). return m.setupKeystore(keystoreBase64, keystorePassword).
WithExec([]string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}). WithExec(args).
File("build/app/outputs/flutter-apk/app-release.apk") File("build/app/outputs/flutter-apk/app-release.apk")
} }
@@ -626,6 +666,7 @@ func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *da
func (m *Ci) DeployApk( func (m *Ci) DeployApk(
ctx context.Context, ctx context.Context,
sshKey *dagger.Secret, sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string, sshUser string,
sshHost string, sshHost string,
commitHash string, commitHash string,
@@ -633,24 +674,23 @@ func (m *Ci) DeployApk(
keystorePassword *dagger.Secret, keystorePassword *dagger.Secret,
buildNumber string, buildNumber string,
) (string, error) { ) (string, error) {
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber) apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber, commitHash)
datePath := time.Now().Format("2006/01/02") datePath := time.Now().Format("2006/01/02")
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath) remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
apkName := fmt.Sprintf("sharedinbox-mua-%s.apk", commitHash) apkName := fmt.Sprintf("sharedinbox-mua-%s.apk", commitHash)
return m.Deployer(sshKey). return m.Deployer(sshKey, knownHosts).
WithFile("/tmp/app.apk", apk). 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{"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 -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}). 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) Stdout(ctx)
} }
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab. // BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk. // Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory { func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
built := m.setup(m.firebaseSrc()). built := m.firebaseBase().
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"}).
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}). WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
WithWorkdir("/src/android"). WithWorkdir("/src/android").
// --no-daemon avoids connecting to a stale daemon whose registry file was // --no-daemon avoids connecting to a stale daemon whose registry file was
@@ -709,9 +749,17 @@ func (m *Ci) TestAndroidFirebase(
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it. // BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle. // versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
func (m *Ci) BuildAndroidRelease() *dagger.File { func (m *Ci) BuildAndroidRelease(
return m.setup(m.androidSrc()). // Git commit hash injected as GIT_HASH dart-define so the About page can display it.
WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}). // +optional
commitHash string,
) *dagger.File {
args := []string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
return m.androidBase().
WithExec(args).
File("build/app/outputs/bundle/release/app-release.aab") File("build/app/outputs/bundle/release/app-release.aab")
} }
@@ -783,14 +831,41 @@ func (m *Ci) PublishAndroid(
playStoreConfig *dagger.Secret, playStoreConfig *dagger.Secret,
keystoreBase64 *dagger.Secret, keystoreBase64 *dagger.Secret,
keystorePassword *dagger.Secret, keystorePassword *dagger.Secret,
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) (string, error) { ) (string, error) {
versionCode := int(time.Now().Unix()) versionCode := int(time.Now().Unix())
aab := m.BuildAndroidRelease() aab := m.BuildAndroidRelease(commitHash)
stamped := m.StampAndroidVersionCode(aab, versionCode) stamped := m.StampAndroidVersionCode(aab, versionCode)
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword) signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
return m.UploadToPlayStore(ctx, signed, playStoreConfig) return m.UploadToPlayStore(ctx, signed, playStoreConfig)
} }
// Renovate runs Renovate bot against the repository on Forgejo/Codeberg.
func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string, error) {
// Codeberg's GET /pulls?state=all&limit=100 times out with a 504, but limit=10
// completes in ~9 s. Patch the compiled pr-cache.js to use 10 instead of the
// hardcoded 20/100 values before launching renovate.
const patchCmd = `for f in \
/usr/local/renovate/dist/modules/platform/forgejo/pr-cache.js \
/usr/local/renovate/dist/modules/platform/gitea/pr-cache.js; do \
sed -i 's/limit: this\.items\.length ? 20 : 100/limit: this.items.length ? 10 : 10/' "$f" && echo "patched $f"; \
done`
return dag.Container().
From("renovate/renovate:43").
WithSecretVariable("RENOVATE_TOKEN", renovateToken).
WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
WithEnvVariable("LOG_LEVEL", "info").
WithUser("root").
WithExec([]string{"/bin/sh", "-c", patchCmd}).
WithUser("ubuntu").
WithExec([]string{"renovate"}).
Stdout(ctx)
}
// Graph returns a Mermaid diagram of the CI pipeline structure. // Graph returns a Mermaid diagram of the CI pipeline structure.
// Paste the output into any Mermaid renderer (codeberg, github, mermaid.live) // Paste the output into any Mermaid renderer (codeberg, github, mermaid.live)
// or save it as a .md file to get a rendered diagram. // or save it as a .md file to get a rendered diagram.
@@ -804,7 +879,7 @@ func (m *Ci) Graph() string {
` + "```" + `mermaid ` + "```" + `mermaid
flowchart TD flowchart TD
subgraph dagger ["Dagger · Check pipeline"] subgraph dagger ["Dagger · Check pipeline"]
toolchain["toolchain\nflutter:3.41.6 + NDK + apt"] toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"]
pubGet["pubGetLayer\nflutter pub get"] pubGet["pubGetLayer\nflutter pub get"]
codegen["codegenBase\nbuild_runner build\n(shared cache)"] codegen["codegenBase\nbuild_runner build\n(shared cache)"]
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"]) stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
+1 -1
View File
@@ -13,7 +13,7 @@ export SSH_PRIVATE_KEY=$(cat "$HOME/.ssh/id_ed25519")
# Add nix profile and nix store tools (task, dagger) to PATH # Add nix profile and nix store tools (task, dagger) to PATH
export PATH="$HOME/.nix-profile/bin:$PATH" export PATH="$HOME/.nix-profile/bin:$PATH"
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger"; do for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger" "*fgj-*/bin/fgj"; do
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1) bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH" [ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
done done
+22
View File
@@ -4,6 +4,28 @@ This file contains tasks which got implemented.
Tasks get moved from next.md to done.md Tasks get moved from next.md to done.md
## Tasks (2026-05-29)
- **Merge PR #307 — user preferences and configurable navigation (Issue #315)**: Confirmed that
all features from PR #307 (issue #299) were already merged into main via separate PRs:
- Configurable menu bar position (bottom/top) for mailbox view — merged via #298/#303
- Configurable back button position for single mail view — merged via #299/#307 features in #300
- Configurable "after mail action" (next message / return to mailbox) — merged via #300/#308
- Archive button with `resolveMailboxByRole` helper — merged via #287/#291, #286/#290
- User preferences DB schema (v34v36: `user_preferences` table) — in main
- PR #307 and issue #299 closed.
- Issue #315 closed.
## Tasks (2026-05-26)
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
dependencies up to date. All required components are in main:
- `renovate.json` — Renovate configuration covering pub, Dockerfile, and Forgejo Actions
- `ci/main.go``Renovate()` Dagger function using Forgejo platform and Codeberg endpoint
- `.forgejo/workflows/renovate.yml` — daily cron (06:00 UTC) workflow
- `Taskfile.yml``renovate` task
- Issue #257 closed.
## Tasks (2026-05-11) ## Tasks (2026-05-11)
- **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering - **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering
+2 -2
View File
@@ -317,7 +317,7 @@ void main() {
// ── Check Sent folder ────────────────────────────────────────────────── // ── Check Sent folder ──────────────────────────────────────────────────
// Use the drawer to switch folders (no back button on Linux desktop). // Use the drawer to switch folders (no back button on Linux desktop).
await tester.tap(find.byTooltip('Open navigation menu')); await tester.tap(find.byTooltip('Open folders'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Sent')); await tester.tap(find.text('Sent'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -331,7 +331,7 @@ void main() {
expect(find.text(subject), findsOneWidget); expect(find.text(subject), findsOneWidget);
// ── Check Inbox ──────────────────────────────────────────────────────── // ── Check Inbox ────────────────────────────────────────────────────────
await tester.tap(find.byTooltip('Open navigation menu')); await tester.tap(find.byTooltip('Open folders'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('INBOX')); await tester.tap(find.text('INBOX'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
+1
View File
@@ -0,0 +1 @@
const int dbSchemaVersion = 36;
+14
View File
@@ -0,0 +1,14 @@
enum MenuPosition { bottom, top }
enum AfterMailViewAction { nextMessage, showMailbox }
class UserPreferences {
const UserPreferences({
this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage,
});
final MenuPosition menuPosition;
final MenuPosition mailViewButtonPosition;
final AfterMailViewAction afterMailViewAction;
}
@@ -11,4 +11,13 @@ abstract class MailboxRepository {
/// Deletes all locally-cached mailbox rows for [accountId]. /// Deletes all locally-cached mailbox rows for [accountId].
Future<void> clearForResync(String accountId); Future<void> clearForResync(String accountId);
/// Creates a new mailbox named [name] for [accountId] and tags it with
/// [role] in the local database. For JMAP accounts the role is also sent
/// to the server. Returns the newly created [Mailbox].
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
);
} }
@@ -19,6 +19,8 @@ class SyncLogEntry {
required this.id, required this.id,
required this.result, required this.result,
this.errorMessage, this.errorMessage,
this.stackTrace,
this.isPermanent = false,
required this.protocol, required this.protocol,
required this.emailsFetched, required this.emailsFetched,
required this.emailsSkipped, required this.emailsSkipped,
@@ -34,6 +36,8 @@ class SyncLogEntry {
final int id; final int id;
final String result; // 'ok' or 'error' final String result; // 'ok' or 'error'
final String? errorMessage; final String? errorMessage;
final String? stackTrace;
final bool isPermanent;
final String protocol; // 'imap' or 'jmap' final String protocol; // 'imap' or 'jmap'
final int emailsFetched; final int emailsFetched;
final int emailsSkipped; final int emailsSkipped;
@@ -54,6 +58,8 @@ abstract class SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -81,6 +87,8 @@ class NoOpSyncLogRepository implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -0,0 +1,8 @@
import 'package:sharedinbox/core/models/user_preferences.dart';
abstract class UserPreferencesRepository {
Stream<UserPreferences> observePreferences();
Future<void> updateMenuPosition(MenuPosition position);
Future<void> updateMailViewButtonPosition(MenuPosition position);
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
}
+4
View File
@@ -260,6 +260,8 @@ class _AccountSync implements _SyncLoop {
accountId: account.id, accountId: account.id,
success: false, success: false,
errorMessage: e.toString(), errorMessage: e.toString(),
stackTrace: st.toString(),
isPermanent: isPermanent,
protocol: 'imap', protocol: 'imap',
emailsFetched: 0, emailsFetched: 0,
emailsSkipped: 0, emailsSkipped: 0,
@@ -513,6 +515,8 @@ class _JmapAccountSync implements _SyncLoop {
accountId: account.id, accountId: account.id,
success: false, success: false,
errorMessage: e.toString(), errorMessage: e.toString(),
stackTrace: st.toString(),
isPermanent: isPermanent,
protocol: 'jmap', protocol: 'jmap',
emailsFetched: 0, emailsFetched: 0,
emailsSkipped: 0, emailsSkipped: 0,
+42 -1
View File
@@ -6,6 +6,7 @@ import 'package:drift/native.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sharedinbox/core/db_schema_version.dart';
part 'database.g.dart'; part 'database.g.dart';
@@ -192,6 +193,9 @@ class SyncLogs extends Table {
DateTimeColumn get finishedAt => dateTime()(); DateTimeColumn get finishedAt => dateTime()();
// Added in schema v13: raw protocol log when account.verbose == true. // Added in schema v13: raw protocol log when account.verbose == true.
TextColumn get protocolLog => text().nullable()(); 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. /// Per-mailbox breakdown for a single sync cycle.
@@ -303,6 +307,23 @@ class LocalSieveApplied extends Table {
Set<Column> get primaryKey => {accountId, messageId}; Set<Column> get primaryKey => {accountId, messageId};
} }
/// App-wide user preferences, stored as a singleton row (id always 1).
@DataClassName('UserPreferencesRow')
class UserPreferences extends Table {
IntColumn get id => integer()();
// 'bottom' (default) | 'top'
TextColumn get menuPosition => text().withDefault(const Constant('bottom'))();
// Added in schema v35: 'bottom' (default) | 'top'
TextColumn get mailViewButtonPosition =>
text().withDefault(const Constant('bottom'))();
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
TextColumn get afterMailViewAction =>
text().withDefault(const Constant('nextMessage'))();
@override
Set<Column> get primaryKey => {id};
}
// ── Database ────────────────────────────────────────────────────────────────── // ── Database ──────────────────────────────────────────────────────────────────
@DriftDatabase( @DriftDatabase(
@@ -323,13 +344,14 @@ class LocalSieveApplied extends Table {
LocalSieveScripts, LocalSieveScripts,
LocalSieveApplied, LocalSieveApplied,
ShareKeys, ShareKeys,
UserPreferences,
], ],
) )
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override @override
int get schemaVersion => 32; int get schemaVersion => dbSchemaVersion;
Future<void> _createEmailFts() async { Future<void> _createEmailFts() async {
await customStatement(''' await customStatement('''
@@ -570,6 +592,25 @@ class AppDatabase extends _$AppDatabase {
if (from < 32) { if (from < 32) {
await m.createTable(localSieveApplied); await m.createTable(localSieveApplied);
} }
if (from >= 7 && from < 33) {
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
await m.addColumn(syncLogs, syncLogs.isPermanent);
}
if (from < 34) {
await m.createTable(userPreferences);
}
if (from >= 34 && from < 35) {
await m.addColumn(
userPreferences,
userPreferences.mailViewButtonPosition,
);
}
if (from >= 34 && from < 36) {
await m.addColumn(
userPreferences,
userPreferences.afterMailViewAction,
);
}
}, },
); );
} }
@@ -79,6 +79,14 @@ class MailboxRepositoryImpl implements MailboxRepository {
); );
try { try {
final mailboxes = await client.listMailboxes(recursive: true); final mailboxes = await client.listMailboxes(recursive: true);
// Pre-load existing DB roles so we can preserve manually-set roles for
// folders the server doesn't tag with a special-use attribute.
final existingRows = await (_db.select(_db.mailboxes)
..where((t) => t.accountId.equals(account.id)))
.get();
final existingRoles = {for (final r in existingRows) r.id: r.role};
for (final mb in mailboxes) { for (final mb in mailboxes) {
final path = mb.path; final path = mb.path;
final id = '${account.id}:$path'; final id = '${account.id}:$path';
@@ -96,6 +104,12 @@ class MailboxRepositoryImpl implements MailboxRepository {
log('STATUS skipped for $path: $e'); log('STATUS skipped for $path: $e');
} }
// Use the server-assigned role when available; fall back to the
// existing DB role so that manually-created folders (e.g. a user
// who just created their Archive folder) keep their role across syncs
// when the IMAP server does not expose a special-use attribute.
final role = _imapRole(mb) ?? existingRoles[id];
await _db.into(_db.mailboxes).insertOnConflictUpdate( await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: id, id: id,
@@ -104,7 +118,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
name: mb.name, name: mb.name,
unreadCount: Value(unread), unreadCount: Value(unread),
totalCount: Value(total), totalCount: Value(total),
role: Value(_imapRole(mb)), role: Value(role),
), ),
); );
} }
@@ -310,4 +324,104 @@ class MailboxRepositoryImpl implements MailboxRepository {
..where((t) => t.accountId.equals(accountId))) ..where((t) => t.accountId.equals(accountId)))
.go(); .go();
} }
@override
Future<model.Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async {
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
switch (account.type) {
case account_model.AccountType.imap:
return _createMailboxWithRoleImap(account, password, name, role);
case account_model.AccountType.jmap:
return _createMailboxWithRoleJmap(account, password, name, role);
}
}
Future<model.Mailbox> _createMailboxWithRoleImap(
account_model.Account account,
String password,
String name,
String role,
) async {
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
await client.createMailbox(name);
} finally {
await client.logout();
}
final id = '${account.id}:$name';
await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: id,
accountId: account.id,
path: name,
name: name,
role: Value(role),
),
);
final row = await (_db.select(_db.mailboxes)..where((t) => t.id.equals(id)))
.getSingle();
return _toModel(row);
}
Future<model.Mailbox> _createMailboxWithRoleJmap(
account_model.Account account,
String password,
String name,
String role,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) {
throw Exception('JMAP account ${account.id} has no jmapUrl');
}
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
final responses = await jmap.call([
[
'Mailbox/set',
{
'accountId': jmap.accountId,
'create': {
'new-mailbox': {'name': name, 'role': role},
},
},
'0',
],
]);
final result = _responseArgs(responses, 0, 'Mailbox/set');
final created = result['created'] as Map<String, dynamic>?;
final newId =
(created?['new-mailbox'] as Map<String, dynamic>?)?['id'] as String?;
if (newId == null) {
throw Exception(
'Failed to create mailbox "$name": server returned no ID',
);
}
final dbId = '${account.id}:$newId';
await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: dbId,
accountId: account.id,
path: newId,
name: name,
role: Value(role),
),
);
final row = await (_db.select(_db.mailboxes)
..where((t) => t.id.equals(dbId)))
.getSingle();
return _toModel(row);
}
} }
@@ -13,6 +13,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -30,6 +32,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
accountId: accountId, accountId: accountId,
result: success ? 'ok' : 'error', result: success ? 'ok' : 'error',
errorMessage: Value(errorMessage), errorMessage: Value(errorMessage),
errorStackTrace: Value(stackTrace),
isPermanent: Value(isPermanent),
protocol: Value(protocol), protocol: Value(protocol),
itemsSynced: Value(emailsFetched), itemsSynced: Value(emailsFetched),
emailsSkipped: Value(emailsSkipped), emailsSkipped: Value(emailsSkipped),
@@ -75,6 +79,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
id: r.id, id: r.id,
result: r.result, result: r.result,
errorMessage: r.errorMessage, errorMessage: r.errorMessage,
stackTrace: r.errorStackTrace,
isPermanent: r.isPermanent,
protocol: r.protocol, protocol: r.protocol,
emailsFetched: r.itemsSynced, emailsFetched: r.itemsSynced,
emailsSkipped: r.emailsSkipped, emailsSkipped: r.emailsSkipped,
@@ -0,0 +1,68 @@
import 'package:drift/drift.dart';
import 'package:sharedinbox/core/models/user_preferences.dart' as pref;
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
import 'package:sharedinbox/data/db/database.dart';
class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
UserPreferencesRepositoryImpl(this._db);
final AppDatabase _db;
static const _rowId = 1;
@override
Stream<pref.UserPreferences> observePreferences() {
return (_db.select(_db.userPreferences)..where((t) => t.id.equals(_rowId)))
.watchSingleOrNull()
.map(_rowToModel);
}
@override
Future<void> updateMenuPosition(pref.MenuPosition position) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
menuPosition: Value(position.name),
),
);
}
@override
Future<void> updateMailViewButtonPosition(pref.MenuPosition position) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
mailViewButtonPosition: Value(position.name),
),
);
}
@override
Future<void> updateAfterMailViewAction(
pref.AfterMailViewAction action,
) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
afterMailViewAction: Value(action.name),
),
);
}
static pref.UserPreferences _rowToModel(UserPreferencesRow? row) {
if (row == null) return const pref.UserPreferences();
return pref.UserPreferences(
menuPosition: pref.MenuPosition.values.firstWhere(
(e) => e.name == row.menuPosition,
orElse: () => pref.MenuPosition.bottom,
),
mailViewButtonPosition: pref.MenuPosition.values.firstWhere(
(e) => e.name == row.mailViewButtonPosition,
orElse: () => pref.MenuPosition.bottom,
),
afterMailViewAction: pref.AfterMailViewAction.values.firstWhere(
(e) => e.name == row.afterMailViewAction,
orElse: () => pref.AfterMailViewAction.nextMessage,
),
);
}
}
+15 -1
View File
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/account.dart' as model; import 'package:sharedinbox/core/models/account.dart' as model;
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart'; import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
@@ -13,6 +14,7 @@ import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart'; import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart'; import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/repositories/undo_repository.dart'; import 'package:sharedinbox/core/repositories/undo_repository.dart';
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_service.dart'; import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
@@ -21,7 +23,8 @@ import 'package:sharedinbox/core/services/undo_service.dart';
import 'package:sharedinbox/core/storage/secure_storage.dart'; import 'package:sharedinbox/core/storage/secure_storage.dart';
import 'package:sharedinbox/core/sync/account_sync_manager.dart'; import 'package:sharedinbox/core/sync/account_sync_manager.dart';
import 'package:sharedinbox/core/sync/reliability_runner.dart'; import 'package:sharedinbox/core/sync/reliability_runner.dart';
import 'package:sharedinbox/data/db/database.dart' hide Email, EmailBody; import 'package:sharedinbox/data/db/database.dart'
hide Email, EmailBody, UserPreferences;
import 'package:sharedinbox/data/db/local_sieve_repository.dart'; import 'package:sharedinbox/data/db/local_sieve_repository.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart'; import 'package:sharedinbox/data/imap/imap_client_factory.dart';
import 'package:sharedinbox/data/jmap/sieve_repository.dart'; import 'package:sharedinbox/data/jmap/sieve_repository.dart';
@@ -33,6 +36,7 @@ import 'package:sharedinbox/data/repositories/search_history_repository_impl.dar
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart'; import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart'; import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart'; import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
import 'package:sharedinbox/data/repositories/user_preferences_repository_impl.dart';
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart'; import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
/// Swappable IMAP connection factory — override in tests to use plaintext. /// Swappable IMAP connection factory — override in tests to use plaintext.
@@ -227,3 +231,13 @@ final accountConnectionStatusProvider =
.read(connectionTestServiceProvider) .read(connectionTestServiceProvider)
.testConnection(account, password); .testConnection(account, password);
}); });
final userPreferencesRepositoryProvider =
Provider<UserPreferencesRepository>((ref) {
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
});
final userPreferencesProvider =
StreamProvider.autoDispose<UserPreferences>((ref) {
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
});
+5
View File
@@ -20,6 +20,7 @@ import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
import 'package:sharedinbox/ui/screens/sync_log_screen.dart'; import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart'; import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
import 'package:sharedinbox/ui/screens/undo_log_screen.dart'; import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
import 'package:sharedinbox/ui/widgets/undo_shell.dart'; import 'package:sharedinbox/ui/widgets/undo_shell.dart';
final router = GoRouter( final router = GoRouter(
@@ -56,6 +57,10 @@ final router = GoRouter(
path: 'about', path: 'about',
builder: (ctx, state) => const AboutScreen(), builder: (ctx, state) => const AboutScreen(),
), ),
GoRoute(
path: 'preferences',
builder: (ctx, state) => const UserPreferencesScreen(),
),
GoRoute( GoRoute(
path: ':accountId/edit', path: ':accountId/edit',
builder: (ctx, state) => EditAccountScreen( builder: (ctx, state) => EditAccountScreen(
+62 -55
View File
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -8,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
class AboutScreen extends ConsumerStatefulWidget { class AboutScreen extends ConsumerStatefulWidget {
@@ -19,57 +19,22 @@ class AboutScreen extends ConsumerStatefulWidget {
class _AboutScreenState extends ConsumerState<AboutScreen> { class _AboutScreenState extends ConsumerState<AboutScreen> {
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform(); final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
late final Future<String?> _deviceModelFuture;
late final Stream<List<Account>> _accountsStream; late final Stream<List<Account>> _accountsStream;
String? _deviceModel;
static const _gitHash = String.fromEnvironment('GIT_HASH');
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts(); _accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
_deviceModelFuture = getDeviceModel();
unawaited(
_deviceModelFuture.then((model) {
if (mounted) setState(() => _deviceModel = model);
}),
);
} }
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;
final gitCommitLine = _gitHash.isNotEmpty
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\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'
'| 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( Future<void> _copyToClipboard(
BuildContext context, BuildContext context,
int imapCount, int imapCount,
@@ -79,10 +44,20 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
try { try {
pkg = await _packageInfoFuture; pkg = await _packageInfoFuture;
} catch (_) {} } catch (_) {}
String? deviceModel;
try {
deviceModel = await _deviceModelFuture;
} catch (_) {}
if (!context.mounted) return; if (!context.mounted) return;
await Clipboard.setData( await Clipboard.setData(
ClipboardData( ClipboardData(
text: _buildMarkdown(context, pkg, imapCount, jmapCount), text: buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: deviceModel,
),
), ),
); );
if (context.mounted) { if (context.mounted) {
@@ -95,6 +70,30 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
} }
} }
Future<void> _launchUrl(BuildContext context, Uri url) async {
try {
final launched =
await launchUrl(url, mode: LaunchMode.externalApplication);
if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Could not open browser.'),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text('Error: $e'),
),
);
}
}
}
Future<void> _createIssue( Future<void> _createIssue(
BuildContext context, BuildContext context,
int imapCount, int imapCount,
@@ -104,9 +103,19 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
try { try {
pkg = await _packageInfoFuture; pkg = await _packageInfoFuture;
} catch (_) {} } catch (_) {}
String? deviceModel;
try {
deviceModel = await _deviceModelFuture;
} catch (_) {}
if (!context.mounted) return; if (!context.mounted) return;
final body = Uri.encodeComponent( final body = Uri.encodeComponent(
_buildMarkdown(context, pkg, imapCount, jmapCount), buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: deviceModel,
),
); );
final url = Uri.parse( final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body', 'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
@@ -157,20 +166,18 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
return Markdown( return Markdown(
data: _buildMarkdown( data: buildAboutMarkdown(
context, context: context,
snapshot.data, pkg: snapshot.data,
imapCount, imapCount: imapCount,
jmapCount, jmapCount: jmapCount,
deviceModel: _deviceModel,
), ),
selectable: true, selectable: true,
onTapLink: (text, href, title) { onTapLink: (text, href, title) {
if (href != null) { if (href != null) {
unawaited( unawaited(
launchUrl( _launchUrl(context, Uri.parse(href)),
Uri.parse(href),
mode: LaunchMode.externalApplication,
),
); );
} }
}, },
+112 -71
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -66,6 +67,14 @@ class AccountListScreen extends ConsumerWidget {
unawaited(context.push('/accounts/about')); unawaited(context.push('/accounts/about'));
}, },
), ),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Preferences'),
onTap: () {
Navigator.pop(context); // Close drawer
unawaited(context.push('/accounts/preferences'));
},
),
], ],
), ),
), ),
@@ -111,20 +120,80 @@ class _AccountTile extends ConsumerWidget {
final health = ref.watch(syncHealthProvider(account.id)); final health = ref.watch(syncHealthProvider(account.id));
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP'; final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
return ListTile( return Column(
leading: const Icon(Icons.account_circle), crossAxisAlignment: CrossAxisAlignment.start,
title: Text(account.displayName), children: [
subtitle: Column( ListTile(
crossAxisAlignment: CrossAxisAlignment.start, leading: const Icon(Icons.account_circle),
children: [ title: Text(account.displayName),
Text('${account.email}\n$typeLabel'), subtitle: Text('${account.email}\n$typeLabel'),
const SizedBox(height: 4), isThreeLine: true,
health.when( trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
status.when(
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
data: (_) =>
const Icon(Icons.check_circle, color: Colors.green),
error: (e, _) => Tooltip(
message: e.toString(),
child: const Icon(Icons.error_outline, color: Colors.red),
),
),
PopupMenuButton<_AccountAction>(
onSelected: (action) => _onAction(context, action),
itemBuilder: (_) => [
const PopupMenuItem(
value: _AccountAction.syncLog,
child: Text('Sync log'),
),
const PopupMenuItem(
value: _AccountAction.verifySync,
child: Text('Verify sync health'),
),
const PopupMenuItem(
value: _AccountAction.forceSync,
child: Text('Force full sync'),
),
const PopupMenuItem(
value: _AccountAction.edit,
child: Text('Edit'),
),
if (_sieveSupported(account))
const PopupMenuItem(
value: _AccountAction.emailFiltersRemote,
child: Text('Server email filters'),
),
const PopupMenuItem(
value: _AccountAction.emailFiltersLocal,
child: Text('Local email filters'),
),
const PopupMenuItem(
value: _AccountAction.send,
child: Text('Send accounts'),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: _AccountAction.delete,
child: Text('Delete'),
),
],
),
],
),
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
),
Padding(
padding: const EdgeInsets.fromLTRB(72, 0, 16, 8),
child: health.when(
data: (h) { data: (h) {
if (h == null) return const Text('Sync health: Not verified yet'); if (h == null) return const Text('Sync health: Not verified yet');
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0]; final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
return Row( return Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text('Sync health: '), const Text('Sync health: '),
Icon( Icon(
@@ -133,7 +202,13 @@ class _AccountTile extends ConsumerWidget {
color: h.isHealthy ? Colors.green : Colors.orange, color: h.isHealthy ? Colors.green : Colors.orange,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'), Expanded(
child: Text(
h.isHealthy
? 'Healthy'
: _formatDiscrepancies(h.discrepancySummary),
),
),
Text(' ($date)', style: const TextStyle(fontSize: 10)), Text(' ($date)', style: const TextStyle(fontSize: 10)),
], ],
); );
@@ -141,66 +216,8 @@ class _AccountTile extends ConsumerWidget {
loading: () => const Text('Sync health: checking...'), loading: () => const Text('Sync health: checking...'),
error: (e, _) => Text('Sync health error: $e'), error: (e, _) => Text('Sync health error: $e'),
), ),
], ),
), ],
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
status.when(
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
data: (_) => const Icon(Icons.check_circle, color: Colors.green),
error: (e, _) => Tooltip(
message: e.toString(),
child: const Icon(Icons.error_outline, color: Colors.red),
),
),
PopupMenuButton<_AccountAction>(
onSelected: (action) => _onAction(context, action),
itemBuilder: (_) => [
const PopupMenuItem(
value: _AccountAction.syncLog,
child: Text('Sync log'),
),
const PopupMenuItem(
value: _AccountAction.verifySync,
child: Text('Verify sync health'),
),
const PopupMenuItem(
value: _AccountAction.forceSync,
child: Text('Force full sync'),
),
const PopupMenuItem(
value: _AccountAction.edit,
child: Text('Edit'),
),
if (_sieveSupported(account))
const PopupMenuItem(
value: _AccountAction.emailFiltersRemote,
child: Text('Server email filters'),
),
const PopupMenuItem(
value: _AccountAction.emailFiltersLocal,
child: Text('Local email filters'),
),
const PopupMenuItem(
value: _AccountAction.send,
child: Text('Send accounts'),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: _AccountAction.delete,
child: Text('Delete'),
),
],
),
],
),
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
); );
} }
@@ -293,6 +310,30 @@ class _AccountTile extends ConsumerWidget {
} }
} }
String _formatDiscrepancies(String? summary) {
if (summary == null) return 'Discrepancies found';
try {
final decoded = jsonDecode(summary) as Map<String, dynamic>;
var missingLocally = 0;
var missingOnServer = 0;
var flagMismatches = 0;
for (final v in decoded.values) {
final m = v as Map<String, dynamic>;
missingLocally += (m['missingLocally'] as int? ?? 0);
missingOnServer += (m['missingOnServer'] as int? ?? 0);
flagMismatches += (m['flagMismatches'] as int? ?? 0);
}
final parts = <String>[];
if (missingLocally > 0) parts.add('missing locally: $missingLocally');
if (missingOnServer > 0) parts.add('missing on server: $missingOnServer');
if (flagMismatches > 0) parts.add('flag mismatches: $flagMismatches');
if (parts.isEmpty) return 'Discrepancies found';
return 'Discrepancies found (${parts.join(', ')})';
} catch (_) {
return 'Discrepancies found';
}
}
class _OnboardingView extends StatelessWidget { class _OnboardingView extends StatelessWidget {
const _OnboardingView(); const _OnboardingView();
+74 -8
View File
@@ -32,11 +32,15 @@ enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> { class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
_Step _step = _Step.generatingKey; _Step _step = _Step.generatingKey;
ShareKeyMaterial? _keyMaterial; ShareKeyMaterial? _keyMaterial;
DateTime? _keyExpiresAt;
String? _pubKeyQr; String? _pubKeyQr;
String? _errorMessage; String? _errorMessage;
bool _scannerActive = false; bool _scannerActive = false;
MobileScannerController? _scannerController; MobileScannerController? _scannerController;
// True when the scanner plugin fails to initialise at runtime (e.g.
// MissingPluginException on some Android builds).
bool _scannerFailed = false;
@override @override
void initState() { void initState() {
@@ -61,6 +65,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
); );
setState(() { setState(() {
_keyMaterial = material; _keyMaterial = material;
_keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
_pubKeyQr = qr; _pubKeyQr = qr;
_step = _Step.showingPubKey; _step = _Step.showingPubKey;
}); });
@@ -76,8 +81,37 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
setState(() { setState(() {
_step = _Step.scanning; _step = _Step.scanning;
_scannerActive = true; _scannerActive = true;
_scannerController = MobileScannerController();
}); });
if (_cameraScanSupported()) {
unawaited(_initScanner());
}
}
// Pre-flight: probe the scanner's permission-state method to verify the
// plugin is registered. MissingPluginException is thrown on Android builds
// where the plugin is not linked (issue #204). All other exceptions mean
// the plugin exists but something else failed — the MobileScanner widget
// will surface those via its own error builder.
Future<void> _initScanner() async {
bool available = false;
try {
await const MethodChannel(
'dev.steenbakker.mobile_scanner/scanner/method',
).invokeMethod<int>('state');
available = true;
} on MissingPluginException {
// Plugin not registered on this device; text fallback will be shown.
} catch (_) {
// Plugin registered but state check failed; let the scanner widget
// handle it via its errorBuilder.
available = true;
}
if (!mounted) return;
if (available) {
setState(() => _scannerController = MobileScannerController());
} else {
setState(() => _scannerFailed = true);
}
} }
Future<void> _onScanned(String rawValue) async { Future<void> _onScanned(String rawValue) async {
@@ -244,7 +278,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
}, },
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const _ExpiryHint(), _ExpiryHint(expiresAt: _keyExpiresAt!),
const SizedBox(height: 32), const SizedBox(height: 32),
if (_errorMessage != null) ...[ if (_errorMessage != null) ...[
Text( Text(
@@ -266,11 +300,14 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
} }
Widget _buildScannerView(BuildContext context) { Widget _buildScannerView(BuildContext context) {
// On platforms where the camera scanner is not available (Linux desktop), // Fall back to text input when the platform has no camera support or when
// fall back to a text-input field. // the scanner plugin fails to initialise at runtime (MissingPluginException).
if (!_cameraScanSupported()) { if (!_cameraScanSupported() || _scannerFailed) {
return _buildTextFallbackView(context); return _buildTextFallbackView(context);
} }
if (_scannerController == null) {
return const Center(child: CircularProgressIndicator());
}
return Stack( return Stack(
children: [ children: [
@@ -371,8 +408,37 @@ bool _cameraScanSupported() =>
Platform.isMacOS || Platform.isMacOS ||
Platform.isWindows; Platform.isWindows;
class _ExpiryHint extends StatelessWidget { class _ExpiryHint extends StatefulWidget {
const _ExpiryHint(); const _ExpiryHint({required this.expiresAt});
final DateTime expiresAt;
@override
State<_ExpiryHint> createState() => _ExpiryHintState();
}
class _ExpiryHintState extends State<_ExpiryHint> {
late Timer _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
String _formatRemaining() {
final remaining = widget.expiresAt.difference(DateTime.now().toUtc());
if (remaining.isNegative) return 'expired';
final minutes = remaining.inMinutes;
final seconds = remaining.inSeconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -382,7 +448,7 @@ class _ExpiryHint extends StatelessWidget {
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]), Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'This key expires in 20 minutes', 'This key expires in ${_formatRemaining()}',
style: TextStyle(fontSize: 12, color: Colors.grey[600]), style: TextStyle(fontSize: 12, color: Colors.grey[600]),
), ),
], ],
+35 -2
View File
@@ -45,12 +45,42 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
bool _scannerActive = true; bool _scannerActive = true;
MobileScannerController? _scannerController; MobileScannerController? _scannerController;
// True when the scanner plugin fails to initialise at runtime (e.g.
// MissingPluginException on some Android builds).
bool _scannerFailed = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (_cameraScanSupported()) { if (_cameraScanSupported()) {
_scannerController = MobileScannerController(); unawaited(_initScanner());
}
}
// Pre-flight: probe the scanner's permission-state method to verify the
// plugin is registered. MissingPluginException is thrown on Android builds
// where the plugin is not linked (issue #204). All other exceptions mean
// the plugin exists but something else failed — the MobileScanner widget
// will surface those via its own error builder.
Future<void> _initScanner() async {
bool available = false;
try {
await const MethodChannel(
'dev.steenbakker.mobile_scanner/scanner/method',
).invokeMethod<int>('state');
available = true;
} on MissingPluginException {
// Plugin not registered on this device; text fallback will be shown.
} catch (_) {
// Plugin registered but state check failed; let the scanner widget
// handle it via its errorBuilder.
available = true;
}
if (!mounted) return;
if (available) {
setState(() => _scannerController = MobileScannerController());
} else {
setState(() => _scannerFailed = true);
} }
} }
@@ -178,9 +208,12 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
} }
Widget _buildScanStep(BuildContext context) { Widget _buildScanStep(BuildContext context) {
if (!_cameraScanSupported()) { if (!_cameraScanSupported() || _scannerFailed) {
return _buildTextFallbackView(context); return _buildTextFallbackView(context);
} }
if (_scannerController == null) {
return const Center(child: CircularProgressIndicator());
}
return Stack( return Stack(
children: [ children: [
+2 -2
View File
@@ -1,7 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -13,7 +12,8 @@ class ChangeLogScreen extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('ChangeLog')), appBar: AppBar(title: const Text('ChangeLog')),
body: FutureBuilder<String>( body: FutureBuilder<String>(
future: rootBundle.loadString('assets/changelog.txt'), future:
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
+89 -40
View File
@@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
@@ -10,27 +11,45 @@ class CrashScreen extends StatelessWidget {
super.key, super.key,
required this.exception, required this.exception,
required this.stackTrace, required this.stackTrace,
this.gitHash = const String.fromEnvironment('GIT_HASH'),
}); });
final Object exception; final Object exception;
final StackTrace? stackTrace; final StackTrace? stackTrace;
final String gitHash;
static const _gitHash = String.fromEnvironment('GIT_HASH'); String get _buildMode {
if (kDebugMode) return 'debug';
if (kProfileMode) return 'profile';
return 'release';
}
Future<String> _buildReport() async { Future<String> _fetchVersion() async {
String version = 'unknown';
try { try {
final info = await PackageInfo.fromPlatform(); final info = await PackageInfo.fromPlatform();
version = '${info.version}+${info.buildNumber}'; return '${info.version}+${info.buildNumber}';
} catch (_) {} } catch (_) {
return 'unknown';
}
}
Future<String> _buildReport() async {
final version = await _fetchVersion();
final platform = final platform =
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}'; '${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
final gitLine = _gitHash.isNotEmpty final versionDisplay = gitHash.isNotEmpty
? 'Git Commit: [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)\n' ? '[$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' final timestamp = DateTime.now().toUtc().toIso8601String();
return 'App Version: $versionDisplay\n'
'Build Mode: $_buildMode\n'
'$gitLine' '$gitLine'
'Platform: $platform\n\n' 'Platform: $platform\n'
'Dart: ${Platform.version}\n'
'Timestamp: $timestamp\n\n'
'Error:\n```\n$exception\n```\n\n' 'Error:\n```\n$exception\n```\n\n'
'Stack Trace:\n```\n$stackTrace\n```'; 'Stack Trace:\n```\n$stackTrace\n```';
} }
@@ -56,13 +75,69 @@ class CrashScreen extends StatelessWidget {
style: Theme.of(ctx).textTheme.titleMedium, style: Theme.of(ctx).textTheme.titleMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
if (_gitHash.isNotEmpty) ...[ const SizedBox(height: 4),
const SizedBox(height: 8), FutureBuilder<String>(
const Text( future: _fetchVersion(),
'Git Commit: $_gitHash', builder: (context, snapshot) => Text(
style: TextStyle(fontSize: 12, color: Colors.grey), 'v${snapshot.data ?? ''}$_buildMode'
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
),
if (gitHash.isNotEmpty) ...[
const SizedBox(height: 8),
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), const SizedBox(height: 24),
const Text( const Text(
@@ -106,32 +181,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), const SizedBox(height: 24),
FilledButton.icon( FilledButton.icon(
onPressed: () async { onPressed: () async {
+21 -4
View File
@@ -38,6 +38,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
var _sieveSsl = true; var _sieveSsl = true;
var _verbose = false; var _verbose = false;
final _jmapUrlCtrl = TextEditingController(); final _jmapUrlCtrl = TextEditingController();
bool _hasStoredPassword = false;
// -- "Try connection" state ------------------------------------------------ // -- "Try connection" state ------------------------------------------------
bool _tryTesting = false; bool _tryTesting = false;
@@ -50,6 +51,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
_smtpHostCtrl.addListener(_rebuild); _smtpHostCtrl.addListener(_rebuild);
_sieveHostCtrl.addListener(_rebuild); _sieveHostCtrl.addListener(_rebuild);
_imapHostCtrl.addListener(_rebuild); _imapHostCtrl.addListener(_rebuild);
_passwordCtrl.addListener(_rebuild);
unawaited(_load()); unawaited(_load());
} }
@@ -63,6 +65,11 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
context.pop(); context.pop();
return; return;
} }
try {
await repo.getPassword(account.id);
_hasStoredPassword = true;
} catch (_) {}
if (!mounted) return;
_account = account; _account = account;
_displayNameCtrl.text = account.displayName; _displayNameCtrl.text = account.displayName;
_usernameCtrl.text = account.username; _usernameCtrl.text = account.username;
@@ -84,6 +91,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
_smtpHostCtrl.removeListener(_rebuild); _smtpHostCtrl.removeListener(_rebuild);
_sieveHostCtrl.removeListener(_rebuild); _sieveHostCtrl.removeListener(_rebuild);
_imapHostCtrl.removeListener(_rebuild); _imapHostCtrl.removeListener(_rebuild);
_passwordCtrl.removeListener(_rebuild);
for (final c in [ for (final c in [
_displayNameCtrl, _displayNameCtrl,
_usernameCtrl, _usernameCtrl,
@@ -267,10 +275,12 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
), ),
_field( _field(
_passwordCtrl, _passwordCtrl,
'New password (leave blank to keep)', _hasStoredPassword
? 'New password (leave blank to keep)'
: 'Password',
key: const Key('editPasswordField'), key: const Key('editPasswordField'),
obscure: true, obscure: true,
required: false, required: !_hasStoredPassword,
), ),
if (account.type == AccountType.jmap) ...[ if (account.type == AccountType.jmap) ...[
const Divider(height: 32), const Divider(height: 32),
@@ -345,10 +355,17 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
testing: _tryTesting, testing: _tryTesting,
okMessage: _tryOk, okMessage: _tryOk,
errorMessage: _tryErr, errorMessage: _tryErr,
onPressed: _tryConnection, onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
? _tryConnection
: null,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
FilledButton(onPressed: _save, child: const Text('Save')), FilledButton(
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
? _save
: null,
child: const Text('Save'),
),
], ],
), ),
), ),
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
enum _MissingFolderChoice { chooseExisting, createNew }
/// Resolves a mailbox by role, prompting the user to choose or create one when
/// the role is not found. Returns the target [Mailbox], or null if cancelled.
Future<Mailbox?> resolveMailboxByRole(
BuildContext context,
MailboxRepository mailboxRepo,
String accountId,
String currentMailboxPath,
String role, {
required String dialogTitle,
required String createFolderName,
}) async {
Mailbox? mailbox = await mailboxRepo.findMailboxByRole(accountId, role);
if (!context.mounted) return null;
if (mailbox != null) return mailbox;
final choice = await showDialog<_MissingFolderChoice>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(dialogTitle),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(ctx, _MissingFolderChoice.chooseExisting),
child: const Text('Choose existing folder'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, _MissingFolderChoice.createNew),
child: Text('Create "$createFolderName"'),
),
],
),
);
if (!context.mounted || choice == null) return null;
switch (choice) {
case _MissingFolderChoice.chooseExisting:
final mailboxes = await mailboxRepo.observeMailboxes(accountId).first;
if (!context.mounted) return null;
final chosen = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => ListView(
shrinkWrap: true,
children: [
const ListTile(
title: Text(
'Move to…',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
for (final m
in mailboxes.where((m) => m.path != currentMailboxPath))
ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(m.name),
onTap: () => Navigator.pop(ctx, m.path),
),
],
),
);
if (chosen == null || !context.mounted) return null;
mailbox = mailboxes.firstWhere((m) => m.path == chosen);
case _MissingFolderChoice.createNew:
mailbox = await mailboxRepo.createMailboxWithRole(
accountId,
createFolderName,
role,
);
if (!context.mounted) return null;
}
return mailbox;
}
+329 -56
View File
@@ -13,9 +13,11 @@ import 'package:share_plus/share_plus.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/format_utils.dart'; import 'package:sharedinbox/core/utils/format_utils.dart';
import 'package:sharedinbox/core/utils/html_utils.dart'; import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart'; import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -70,61 +72,25 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
onPressed: header == null onPressed: header == null
? null ? null
: () { : () {
unawaited(_reply(context, header, body, replyAll: false)); unawaited(
_replyWithRecipientDialog(context, header, body),
);
}, },
), ),
IconButton( IconButton(
icon: const Icon(Icons.reply_all), icon: const Icon(Icons.archive),
tooltip: 'Reply all', tooltip: 'Archive',
onPressed: header == null onPressed: header == null
? null ? null
: () { : () {
unawaited(_reply(context, header, body, replyAll: true)); unawaited(_archive(context, header));
}, },
), ),
IconButton(
icon: const Icon(Icons.forward),
tooltip: 'Forward',
onPressed: header == null
? null
: () {
unawaited(_forward(context, header, body));
},
),
IconButton(
icon: const Icon(Icons.mark_email_unread_outlined),
tooltip: 'Mark as unread',
onPressed: () async {
await repo.setFlag(widget.emailId, seen: false);
if (context.mounted) context.pop();
},
),
IconButton(
icon: Icon(
_isFlagged ? Icons.star : Icons.star_border,
color: _isFlagged ? Colors.amber : null,
),
tooltip: _isFlagged ? 'Unflag' : 'Flag',
onPressed: () async {
final next = !_isFlagged;
await repo.setFlag(widget.emailId, flagged: next);
if (mounted) setState(() => _isFlagged = next);
},
),
IconButton(
icon: const Icon(Icons.drive_file_move_outline),
tooltip: 'Move to folder',
onPressed: header == null ? null : () => _moveTo(context, header),
),
IconButton(
icon: const Icon(Icons.access_time),
tooltip: 'Snooze',
onPressed: header == null ? null : () => _snooze(context, header),
),
IconButton( IconButton(
icon: const Icon(Icons.delete), icon: const Icon(Icons.delete),
tooltip: 'Delete', tooltip: 'Delete',
onPressed: () async { onPressed: () async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
final destPath = await repo.deleteEmail(widget.emailId); final destPath = await repo.deleteEmail(widget.emailId);
if (header != null) { if (header != null) {
@@ -143,11 +109,44 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
); );
} }
if (context.mounted) context.pop(); if (context.mounted) _navigateTo(context, header, nextEmailId);
},
),
IconButton(
icon: Icon(
_isFlagged ? Icons.star : Icons.star_border,
color: _isFlagged ? Colors.amber : null,
),
tooltip: _isFlagged ? 'Unflag' : 'Flag',
onPressed: () async {
final next = !_isFlagged;
await repo.setFlag(widget.emailId, flagged: next);
if (mounted) setState(() => _isFlagged = next);
}, },
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
itemBuilder: (ctx) => [ itemBuilder: (ctx) => [
const PopupMenuItem(
value: 'forward',
child: Text('Forward'),
),
const PopupMenuItem(
value: 'move',
child: Text('Move to folder'),
),
const PopupMenuItem(
value: 'snooze',
child: Text('Snooze'),
),
const PopupMenuItem(
value: 'spam',
child: Text('Mark as spam'),
),
const PopupMenuItem(
value: 'mark_unread',
child: Text('Mark as unread'),
),
const PopupMenuDivider(),
const PopupMenuItem( const PopupMenuItem(
value: 'headers', value: 'headers',
child: Text('Show Mail Headers'), child: Text('Show Mail Headers'),
@@ -161,8 +160,20 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
child: Text('Show Raw Email'), child: Text('Show Raw Email'),
), ),
], ],
onSelected: (value) { onSelected: (value) async {
if (value == 'headers' && body != null) { if (value == 'forward' && header != null) {
unawaited(_forward(context, header, body));
} else if (value == 'move' && header != null) {
unawaited(_moveTo(context, header));
} else if (value == 'snooze' && header != null) {
unawaited(_snooze(context, header));
} else if (value == 'spam' && header != null) {
unawaited(_markAsSpam(context, header));
} else if (value == 'mark_unread') {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
await repo.setFlag(widget.emailId, seen: false);
if (context.mounted) _navigateTo(context, header, nextEmailId);
} else if (value == 'headers' && body != null) {
_showHeaders(context, body); _showHeaders(context, body);
} else if (value == 'structure' && body != null) { } else if (value == 'structure' && body != null) {
_showStructure(context, body); _showStructure(context, body);
@@ -241,6 +252,39 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
); );
} }
Future<String?> _getNextEmailIdIfNeeded(Email? header) async {
if (header == null) return null;
final prefs = ref.read(userPreferencesProvider).value;
final action =
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
if (action != AfterMailViewAction.nextMessage) return null;
final threads = await ref
.read(emailRepositoryProvider)
.observeThreads(header.accountId, header.mailboxPath)
.first;
final currentIndex =
threads.indexWhere((t) => t.emailIds.contains(widget.emailId));
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
return threads[currentIndex + 1].latestEmailId;
}
return null;
}
void _navigateTo(BuildContext context, Email? header, String? nextEmailId) {
if (!context.mounted) return;
if (nextEmailId != null && header != null) {
context.go(
'/accounts/${header.accountId}'
'/mailboxes/${Uri.encodeComponent(header.mailboxPath)}'
'/emails/${Uri.encodeComponent(nextEmailId)}',
);
} else {
context.pop();
}
}
Future<void> _downloadAndOpen(EmailAttachment att) async { Future<void> _downloadAndOpen(EmailAttachment att) async {
setState(() => _downloading.add(att.filename)); setState(() => _downloading.add(att.filename));
try { try {
@@ -303,17 +347,78 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
return '\n\n— On $date, $from wrote:\n$quoted'; return '\n\n— On $date, $from wrote:\n$quoted';
} }
Future<void> _reply( Future<void> _replyWithRecipientDialog(
BuildContext context,
Email header,
EmailBody? body,
) async {
final account =
await ref.read(accountRepositoryProvider).getAccount(header.accountId);
final ownEmail = account?.email.toLowerCase() ?? '';
final seen = <String>{};
final candidates = <_Candidate>[];
void addIfNew(EmailAddress addr, _Placement defaultPlacement) {
final key = addr.email.toLowerCase();
if (key == ownEmail || seen.contains(key)) return;
seen.add(key);
candidates.add(_Candidate(addr, defaultPlacement));
}
for (final addr in header.from) {
addIfNew(addr, _Placement.to);
}
for (final addr in header.to) {
addIfNew(addr, _Placement.to);
}
for (final addr in header.cc) {
addIfNew(addr, _Placement.cc);
}
if (!context.mounted) return;
if (candidates.length <= 1) {
final to = candidates
.where((c) => c.placement == _Placement.to)
.map((c) => c.address.email)
.join(', ');
final cc = candidates
.where((c) => c.placement == _Placement.cc)
.map((c) => c.address.email)
.join(', ');
await _composeReply(context, header, body, to: to, cc: cc);
return;
}
final confirmed = await showDialog<List<_Candidate>>(
context: context,
builder: (ctx) => _ReplyAllDialog(candidates: candidates),
);
if (confirmed == null || !context.mounted) return;
final to = confirmed
.where((c) => c.placement == _Placement.to)
.map((c) => c.address.email)
.join(', ');
final cc = confirmed
.where((c) => c.placement == _Placement.cc)
.map((c) => c.address.email)
.join(', ');
await _composeReply(context, header, body, to: to, cc: cc);
}
Future<void> _composeReply(
BuildContext context, BuildContext context,
Email header, Email header,
EmailBody? body, { EmailBody? body, {
required bool replyAll, required String to,
required String cc,
}) async { }) async {
final to = header.from.isNotEmpty ? header.from.first.email : '';
final subject = (header.subject?.startsWith('Re:') ?? false) final subject = (header.subject?.startsWith('Re:') ?? false)
? header.subject! ? header.subject!
: 'Re: ${header.subject ?? ''}'; : 'Re: ${header.subject ?? ''}';
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
final quoted = await _quotedBody(header, body); final quoted = await _quotedBody(header, body);
if (!context.mounted) return; if (!context.mounted) return;
unawaited( unawaited(
@@ -330,6 +435,78 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
); );
} }
Future<void> _archive(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final mailbox = await resolveMailboxByRole(
context,
ref.read(mailboxRepositoryProvider),
header.accountId,
header.mailboxPath,
'archive',
dialogTitle: 'No archive folder found',
createFolderName: 'Archive',
);
if (mailbox == null || !context.mounted) return;
await ref
.read(emailRepositoryProvider)
.moveEmail(widget.emailId, mailbox.path);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.move,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: mailbox.path,
),
),
);
if (context.mounted) _navigateTo(context, header, nextEmailId);
}
Future<void> _markAsSpam(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final mailbox = await resolveMailboxByRole(
context,
ref.read(mailboxRepositoryProvider),
header.accountId,
header.mailboxPath,
'junk',
dialogTitle: 'No spam folder found',
createFolderName: 'Junk',
);
if (mailbox == null || !context.mounted) return;
await ref
.read(emailRepositoryProvider)
.moveEmail(widget.emailId, mailbox.path);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.move,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: mailbox.path,
),
),
);
if (context.mounted) _navigateTo(context, header, nextEmailId);
}
Future<void> _forward( Future<void> _forward(
BuildContext context, BuildContext context,
Email header, Email header,
@@ -352,6 +529,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
} }
Future<void> _moveTo(BuildContext context, Email header) async { Future<void> _moveTo(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
final mailboxRepo = ref.read(mailboxRepositoryProvider); final mailboxRepo = ref.read(mailboxRepositoryProvider);
final mailboxes = final mailboxes =
await mailboxRepo.observeMailboxes(header.accountId).first; await mailboxRepo.observeMailboxes(header.accountId).first;
@@ -400,10 +579,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
); );
if (context.mounted) context.pop(); if (context.mounted) _navigateTo(context, header, nextEmailId);
} }
Future<void> _snooze(BuildContext context, Email header) async { Future<void> _snooze(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final until = await showModalBottomSheet<DateTime>( final until = await showModalBottomSheet<DateTime>(
context: context, context: context,
builder: (ctx) => const SnoozePicker(), builder: (ctx) => const SnoozePicker(),
@@ -431,7 +613,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
), ),
); );
context.pop(); _navigateTo(context, header, nextEmailId);
} }
} }
@@ -670,6 +852,94 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
} }
} }
enum _Placement { to, cc, skip }
class _Candidate {
_Candidate(this.address, this.placement);
final EmailAddress address;
_Placement placement;
}
class _ReplyAllDialog extends StatefulWidget {
const _ReplyAllDialog({required this.candidates});
final List<_Candidate> candidates;
@override
State<_ReplyAllDialog> createState() => _ReplyAllDialogState();
}
class _ReplyAllDialogState extends State<_ReplyAllDialog> {
late final List<_Candidate> _candidates;
@override
void initState() {
super.initState();
_candidates = [
for (final c in widget.candidates) _Candidate(c.address, c.placement),
];
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Reply All'),
content: SizedBox(
width: double.maxFinite,
child: ListView(
shrinkWrap: true,
children: [
for (final c in _candidates)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(
child: Text(
c.address.toString(),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
SegmentedButton<_Placement>(
showSelectedIcon: false,
segments: const [
ButtonSegment(
value: _Placement.to,
label: Text('To'),
),
ButtonSegment(
value: _Placement.cc,
label: Text('Cc'),
),
ButtonSegment(
value: _Placement.skip,
label: Text('Skip'),
),
],
selected: {c.placement},
onSelectionChanged: (s) =>
setState(() => c.placement = s.first),
),
],
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, _candidates),
child: const Text('Reply'),
),
],
);
}
}
class _MimeRow { class _MimeRow {
const _MimeRow(this.depth, this.label); const _MimeRow(this.depth, this.label);
final int depth; final int depth;
@@ -712,10 +982,13 @@ class _UnsubscribeChip extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final uri = _parseUnsubscribeUri(header); final uri = _parseUnsubscribeUri(header);
if (uri == null) return const SizedBox.shrink(); if (uri == null) return const SizedBox.shrink();
return ActionChip( return Tooltip(
avatar: const Icon(Icons.unsubscribe_outlined, size: 16), message: uri.toString(),
label: const Text('Unsubscribe'), child: ActionChip(
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication), avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
label: const Text('Unsubscribe'),
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
),
); );
} }
} }
+57 -24
View File
@@ -8,8 +8,10 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart'; import 'package:sharedinbox/ui/widgets/email_tile.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
@@ -147,16 +149,21 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final repo = ref.watch(emailRepositoryProvider); final repo = ref.watch(emailRepositoryProvider);
final accountAsync = ref.watch(accountByIdProvider(widget.accountId)); final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
return Scaffold( return Scaffold(
appBar: _buildAppBar(repo, accountAsync), appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom),
drawer: _selecting drawer: _selecting
? null ? null
: FolderDrawer( : FolderDrawer(
accountId: widget.accountId, accountId: widget.accountId,
currentMailboxPath: widget.mailboxPath, currentMailboxPath: widget.mailboxPath,
), ),
bottomNavigationBar: _selecting ? _selectionBottomBar() : null, bottomNavigationBar: _selecting
? _selectionBottomBar()
: (menuAtBottom ? _folderNavBottomBar() : null),
body: Column( body: Column(
children: [ children: [
_buildSyncErrorBanner(), _buildSyncErrorBanner(),
@@ -172,12 +179,14 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
PreferredSizeWidget _buildAppBar( PreferredSizeWidget _buildAppBar(
EmailRepository emailRepo, EmailRepository emailRepo,
AsyncValue<Account?> accountAsync, AsyncValue<Account?> accountAsync, {
) { required bool menuAtBottom,
}) {
final selectionCount = final selectionCount =
_searching ? _selectedSearchIds.length : _selectedThreadIds.length; _searching ? _selectedSearchIds.length : _selectedThreadIds.length;
return AppBar( return AppBar(
automaticallyImplyLeading: !menuAtBottom,
leading: _selecting leading: _selecting
? IconButton( ? IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
@@ -300,6 +309,22 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
); );
} }
Widget _folderNavBottomBar() {
return BottomAppBar(
child: Row(
children: [
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(context).openDrawer(),
),
),
],
),
);
}
Widget _selectionBottomBar() { Widget _selectionBottomBar() {
return BottomAppBar( return BottomAppBar(
child: Row( child: Row(
@@ -420,24 +445,26 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
); );
} }
Future<void> _batchMoveToRole(String role, String notFoundMessage) async { Future<void> _batchMoveToRole(
String role, {
required String dialogTitle,
required String createFolderName,
}) async {
final ids = _selectedEmailIds; final ids = _selectedEmailIds;
_clearSelection(); _clearSelection();
final mailbox = await ref
.read(mailboxRepositoryProvider) final mailbox = await resolveMailboxByRole(
.findMailboxByRole(widget.accountId, role); context,
if (!mounted) return; ref.read(mailboxRepositoryProvider),
if (mailbox == null) { widget.accountId,
ScaffoldMessenger.of( widget.mailboxPath,
context, role,
).showSnackBar( dialogTitle: dialogTitle,
SnackBar( createFolderName: createFolderName,
duration: const Duration(seconds: 5), );
content: Text(notFoundMessage),
), if (!mounted || mailbox == null) return;
);
return;
}
final repo = ref.read(emailRepositoryProvider); final repo = ref.read(emailRepositoryProvider);
// Fetch full email data before moving so we can restore them if user clicks Undo. // Fetch full email data before moving so we can restore them if user clicks Undo.
@@ -463,8 +490,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
} }
Future<void> _batchArchive() => Future<void> _batchArchive() => _batchMoveToRole(
_batchMoveToRole('archive', 'No archive folder found'); 'archive',
dialogTitle: 'No archive folder found',
createFolderName: 'Archive',
);
Future<void> _refreshSearchAndPopIfEmpty() async { Future<void> _refreshSearchAndPopIfEmpty() async {
if (!mounted || !_searching) return; if (!mounted || !_searching) return;
@@ -543,8 +573,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
} }
} }
Future<void> _batchMarkSpam() => Future<void> _batchMarkSpam() => _batchMoveToRole(
_batchMoveToRole('junk', 'No spam folder found'); 'junk',
dialogTitle: 'No spam folder found',
createFolderName: 'Junk',
);
Future<void> _batchMove() async { Future<void> _batchMove() async {
final ids = _selectedEmailIds; final ids = _selectedEmailIds;
+18
View File
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
@@ -17,8 +18,12 @@ class MailboxListScreen extends ConsumerWidget {
final mailboxRepo = ref.watch(mailboxRepositoryProvider); final mailboxRepo = ref.watch(mailboxRepositoryProvider);
final emailRepo = ref.watch(emailRepositoryProvider); final emailRepo = ref.watch(emailRepositoryProvider);
final accountAsync = ref.watch(accountByIdProvider(accountId)); final accountAsync = ref.watch(accountByIdProvider(accountId));
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: !menuAtBottom,
title: const Text('Folders'), title: const Text('Folders'),
actions: [ actions: [
IconButton( IconButton(
@@ -42,6 +47,19 @@ class MailboxListScreen extends ConsumerWidget {
], ],
), ),
drawer: FolderDrawer(accountId: accountId), drawer: FolderDrawer(accountId: accountId),
bottomNavigationBar: menuAtBottom
? BottomAppBar(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(context).openDrawer(),
),
],
),
)
: null,
body: Column( body: Column(
children: [ children: [
// ── Failed-mutation banner ─────────────────────────────────────── // ── Failed-mutation banner ───────────────────────────────────────
+139 -5
View File
@@ -1,11 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.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/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
final _timeFmt = DateFormat('MMM d, HH:mm:ss'); final _timeFmt = DateFormat('MMM d, HH:mm:ss');
@@ -21,6 +25,57 @@ String _fmtBytes(int bytes) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; 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 { class SyncLogScreen extends ConsumerStatefulWidget {
const SyncLogScreen({super.key, required this.accountId}); const SyncLogScreen({super.key, required this.accountId});
@@ -69,6 +124,41 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
ref.read(syncManagerProvider).syncNow(widget.accountId); 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 deviceModel = await getDeviceModel();
if (!context.mounted) return;
final syncMd = _buildSyncEntryMarkdown(entry);
final aboutMd = buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: deviceModel,
);
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -96,16 +186,20 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
? const Center(child: Text('No sync entries yet')) ? const Center(child: Text('No sync entries yet'))
: ListView.builder( : ListView.builder(
itemCount: _entries.length, 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 { class _SyncLogTile extends StatelessWidget {
const _SyncLogTile({required this.entry}); const _SyncLogTile({required this.entry, required this.onCopy});
final SyncLogEntry entry; final SyncLogEntry entry;
final VoidCallback onCopy;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -115,6 +209,12 @@ class _SyncLogTile extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final errorColor = theme.colorScheme.error; 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( return ExpansionTile(
leading: Icon( leading: Icon(
entry.isOk ? Icons.check_circle : Icons.error_outline, entry.isOk ? Icons.check_circle : Icons.error_outline,
@@ -125,11 +225,20 @@ class _SyncLogTile extends StatelessWidget {
style: entry.isOk ? null : TextStyle(color: errorColor), style: entry.isOk ? null : TextStyle(color: errorColor),
), ),
subtitle: Text( subtitle: Text(
entry.isOk subtitleText,
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
: 'Error · took $durationLabel',
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor), 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: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(72, 0, 16, 12), padding: const EdgeInsets.fromLTRB(72, 0, 16, 12),
@@ -171,6 +280,31 @@ class _SyncLogTile extends StatelessWidget {
style: TextStyle(color: errorColor, fontSize: 12), 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) ...[ if (entry.protocolLog != null) ...[
const Padding( const Padding(
padding: EdgeInsets.only(top: 6, bottom: 2), padding: EdgeInsets.only(top: 6, bottom: 2),
+23 -1
View File
@@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/html_utils.dart'; import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart'; import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
@@ -28,9 +29,16 @@ class ThreadDetailScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final repo = ref.watch(emailRepositoryProvider); final repo = ref.watch(emailRepositoryProvider);
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final buttonAtBottom = prefs.mailViewButtonPosition == MenuPosition.bottom;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Thread')), appBar: AppBar(
title: const Text('Thread'),
automaticallyImplyLeading: !buttonAtBottom,
),
bottomNavigationBar: buttonAtBottom ? _buildBackButtonBar(context) : null,
body: StreamBuilder<List<Email>>( body: StreamBuilder<List<Email>>(
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId), stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -60,6 +68,20 @@ class ThreadDetailScreen extends ConsumerWidget {
), ),
); );
} }
Widget _buildBackButtonBar(BuildContext context) {
return BottomAppBar(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Back',
onPressed: () => context.pop(),
),
],
),
);
}
} }
class _EmailMessageCard extends ConsumerStatefulWidget { class _EmailMessageCard extends ConsumerStatefulWidget {
+145
View File
@@ -0,0 +1,145 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/di.dart';
class UserPreferencesScreen extends ConsumerWidget {
const UserPreferencesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefsAsync = ref.watch(userPreferencesProvider);
return Scaffold(
appBar: AppBar(title: const Text('Preferences')),
body: prefsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) =>
const Center(child: Text('Error loading preferences')),
data: (prefs) => ListView(
children: [
ListTile(
title: Text(
'Menu bar position',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Where the folder navigation menu is shown in the mailbox view.',
),
),
RadioGroup<MenuPosition>(
groupValue: prefs.menuPosition,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateMenuPosition(value),
);
},
child: const Column(
children: [
RadioListTile<MenuPosition>(
title: Text('Bottom (default)'),
subtitle: Text(
'Open folder navigation from a button at the bottom of the screen.',
),
value: MenuPosition.bottom,
),
RadioListTile<MenuPosition>(
title: Text('Top'),
subtitle: Text(
'Open folder navigation from the hamburger icon in the top bar.',
),
value: MenuPosition.top,
),
],
),
),
const Divider(),
ListTile(
title: Text(
'Single mail view button position',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Where the back button is shown in the single mail view.',
),
),
RadioGroup<MenuPosition>(
groupValue: prefs.mailViewButtonPosition,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateMailViewButtonPosition(value),
);
},
child: const Column(
children: [
RadioListTile<MenuPosition>(
title: Text('Bottom (default)'),
subtitle: Text(
'Show the back button at the bottom of the screen.',
),
value: MenuPosition.bottom,
),
RadioListTile<MenuPosition>(
title: Text('Top'),
subtitle: Text(
'Show the back button in the top bar.',
),
value: MenuPosition.top,
),
],
),
),
const Divider(),
ListTile(
title: Text(
'After mail action',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'What to show after deleting, archiving, or otherwise handling a message.',
),
),
RadioGroup<AfterMailViewAction>(
groupValue: prefs.afterMailViewAction,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateAfterMailViewAction(value),
);
},
child: const Column(
children: [
RadioListTile<AfterMailViewAction>(
title: Text('Next message (default)'),
subtitle: Text(
'Show the next message in the mailbox.',
),
value: AfterMailViewAction.nextMessage,
),
RadioListTile<AfterMailViewAction>(
title: Text('Return to mailbox'),
subtitle: Text(
'Return to the message list.',
),
value: AfterMailViewAction.showMailbox,
),
],
),
),
],
),
),
);
}
}
+75
View File
@@ -0,0 +1,75 @@
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';
import 'package:sharedinbox/core/db_schema_version.dart';
const _gitHash = String.fromEnvironment('GIT_HASH');
/// Builds the About markdown table used in [AboutScreen] and sync log copies.
String buildAboutMarkdown({
required BuildContext context,
PackageInfo? pkg,
required int imapCount,
required int jmapCount,
String? deviceModel,
}) {
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 locale = Localizations.localeOf(context).toString();
final textScale =
MediaQuery.of(context).textScaler.scale(1.0).toStringAsFixed(1);
final gitCommitLine = _gitHash.isNotEmpty
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
: '';
final deviceModelLine =
deviceModel != null ? '| Device Model | $deviceModel |\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'
'$deviceModelLine'
'| 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'
'| Locale | $locale |\n'
'| Text Scale | $textScale× |\n'
'| DB Schema Version | $dbSchemaVersion |\n'
'| IMAP Accounts | $imapCount |\n'
'| JMAP Accounts | $jmapCount |\n';
}
/// Fetches device model string, or null when unavailable.
Future<String?> getDeviceModel() async {
try {
final info = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await info.androidInfo;
return '${android.manufacturer} / ${android.model}';
} else if (Platform.isIOS) {
final ios = await info.iosInfo;
return ios.utsname.machine;
}
} catch (_) {}
return null;
}
String _capitalize(String s) =>
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
+5 -2
View File
@@ -31,10 +31,13 @@ String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
<meta name="color-scheme" content="light"> <meta name="color-scheme" content="light">
<meta http-equiv="Content-Security-Policy" content="$csp"> <meta http-equiv="Content-Security-Policy" content="$csp">
<style> <style>
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; color-scheme: light; background-color: #ffffff; color: #000000; } body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; overflow-x: hidden; color-scheme: light; background-color: #ffffff; color: #000000; }
img { max-width: 100%; height: auto; } img { max-width: 100%; height: auto; }
a { color: #1976D2; } a { color: #1976D2; }
* { box-sizing: border-box; } * { box-sizing: border-box; max-width: 100%; }
table { width: 100%; border-collapse: collapse; }
td, th { overflow-wrap: break-word; word-break: break-word; }
pre { white-space: pre-wrap; word-break: break-word; overflow-x: auto; }
</style> </style>
</head> </head>
<body> <body>
+35 -11
View File
@@ -249,6 +249,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.12" 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: drift:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -643,10 +659,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
mime: mime:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1072,26 +1088,26 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: test name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.30.0" version: "1.31.0"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.11"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.16" version: "0.6.17"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
@@ -1117,13 +1133,13 @@ packages:
source: hosted source: hosted
version: "6.3.2" version: "6.3.2"
url_launcher_android: url_launcher_android:
dependency: transitive dependency: "direct overridden"
description: description:
name: url_launcher_android name: url_launcher_android
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.30" version: "6.3.24"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
@@ -1284,6 +1300,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.0" 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: workmanager:
dependency: "direct main" dependency: "direct main"
description: description:
+5
View File
@@ -61,6 +61,7 @@ dependencies:
# App version metadata for crash reports # App version metadata for crash reports
package_info_plus: ^10.1.0 package_info_plus: ^10.1.0
share_plus: ^13.1.0 share_plus: ^13.1.0
device_info_plus: ^13.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -89,3 +90,7 @@ dependency_overrides:
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses # (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
# stable Pigeon and is known to work reliably. # stable Pigeon and is known to work reliably.
path_provider_android: ">=2.2.0 <2.2.21" path_provider_android: ">=2.2.0 <2.2.21"
# url_launcher_android 6.3.25 updated to Pigeon 26, which causes a
# channel-error on launchUrl on some Android devices (same root cause as
# path_provider_android). Pin to <6.3.25 which uses stable Pigeon.
url_launcher_android: ">=6.3.0 <6.3.25"
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"labels": ["dependencies"],
"github-actions": {
"enabled": false
},
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"],
"addLabels": ["automerge"]
}
]
}
+480 -63
View File
@@ -8,24 +8,34 @@ Flow
a. Age > 1 h → kill it, set its issue to State/Question, exit 1 a. Age > 1 h → kill it, set its issue to State/Question, exit 1
b. Age ≤ 1 h → print status, exit 0 (let it keep working) b. Age ≤ 1 h → print status, exit 0 (let it keep working)
2. No agent running → extract pending_issue from state (if any), then check CI 2. No agent running → extract pending_issue from state (if any), then check CI
a. CI is running → save pending-ci state, exit 0 a. pending_issue type=="plan" → post resume comment, set State/Planned, exit 0
b. Latest CI failed → start fix-CI agent (preserving pending_issue), exit 0 b. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
c. CI ok + pending_issue → close the issue (CI passed), exit 0 c. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
d. CI ok (or no run yet) → find oldest Ready issue, start issue agent, d. Catch-up: close issues for PRs already merged (e.g., merged manually after
save state, exit 0 State/Question was set because CI path filter didn't trigger) → exit 0
e. No Ready issues → print "nothing to do", exit 0 e. Catch-up: Renovate PRs with passing CI → merge them
f. Main CI running → save pending-ci state, exit 0
g. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
h. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
section 2b always returns first)
i. Main CI ok (or no run yet) → find oldest ToPlan issue, start plan agent,
save state, exit 0
j. No ToPlan issues → find oldest Ready issue, start issue agent,
save state, exit 0
k. No Ready issues → print "nothing to do", exit 0
Issue agents must NOT close the issue themselves; the loop closes it after CI passes. Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
Plan agents must NOT write any code or create PRs; they only post a plan comment.
State file: ~/.sharedinbox-agent-state.json State file: ~/.sharedinbox-agent-state.json
{ "pid": 12345, "issue": 91, { "pid": 12345, "issue": 91,
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue" } "started_at": "2026-05-15T12:00:00+00:00", "type": "issue|plan|ci-fix|pending-ci" }
Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log. Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
To resume the Claude conversation, look up the session UUID first: To resume the Claude conversation, look up the session UUID first:
scripts/agent_loop.py list # shows NAME and UUID columns scripts/agent_loop.py list # shows NAME and UUID columns
claude --resume <uuid> # use the UUID, NOT the session name claude --resume <uuid> --dangerously-skip-permissions # use the UUID, NOT the session name
""" """
import argparse import argparse
@@ -35,10 +45,13 @@ import re
import shlex import shlex
import subprocess import subprocess
import sys import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
# Cron runs with a minimal PATH; ensure Nix profile binaries (tea, claude) and ~/go/bin (fgj) are found. # Cron runs with a minimal PATH; ensure Nix profile binaries (claude) and ~/go/bin (fgj) are found.
os.environ["PATH"] = ( os.environ["PATH"] = (
f"{Path.home()}/.nix-profile/bin" f"{Path.home()}/.nix-profile/bin"
f":{Path.home()}/go/bin" f":{Path.home()}/go/bin"
@@ -50,7 +63,9 @@ os.environ["PATH"] = (
REPO = "guettli/sharedinbox" REPO = "guettli/sharedinbox"
REPO_URL = f"https://codeberg.org/{REPO}" REPO_URL = f"https://codeberg.org/{REPO}"
STATE_FILE = Path.home() / ".sharedinbox-agent-state.json" STATE_FILE = Path.home() / ".sharedinbox-agent-state.json"
HEARTBEAT_FILE = Path.home() / ".sharedinbox-agent-heartbeat"
MAX_AGENT_AGE_SECONDS = 3600 # 1 hour MAX_AGENT_AGE_SECONDS = 3600 # 1 hour
MAX_HEARTBEAT_AGE_SECONDS = 7200 # 2 hours
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" / ( CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" / (
"-" + str(Path.home())[1:].replace("/", "-") "-" + str(Path.home())[1:].replace("/", "-")
) )
@@ -60,9 +75,11 @@ LABEL_READY = "State/Ready"
LABEL_IN_PROGRESS = "State/InProgress" LABEL_IN_PROGRESS = "State/InProgress"
LABEL_QUESTION = "State/Question" LABEL_QUESTION = "State/Question"
LABEL_PRIO_HIGH = "Prio/High" LABEL_PRIO_HIGH = "Prio/High"
LABEL_TO_PLAN = "State/ToPlan"
LABEL_PLANNED = "State/Planned"
# Only pick up issues filed by these accounts. # Only pick up issues filed by these accounts.
ALLOWED_ISSUE_AUTHORS = {"guettli", "guettlibot", "guettlibot2"} ALLOWED_ISSUE_AUTHORS = {"guettli", "guettlibot", "guettlibot2", "forgejo-actions"}
# ── helpers ─────────────────────────────────────────────────────────────────── # ── helpers ───────────────────────────────────────────────────────────────────
@@ -85,22 +102,51 @@ def _fgj(*args: str) -> None:
) )
def _tea_get(path: str) -> dict | list | None: def _fgj_run_list(limit: int = 20) -> list[dict]:
"""Run a tea api GET and return parsed JSON. Only use for reads — tea PATCH/PUT """Return workflow runs via fgj actions run list."""
silently fails (exits 0) when unauthenticated, so writes must go via fgj.""" result = subprocess.run(
cmd = ["tea", "api", path] ["fgj", "--hostname", "codeberg.org", "actions", "run", "list",
result = subprocess.run(cmd, capture_output=True, text=True) "--repo", REPO, "--json", "-L", str(limit)],
capture_output=True, text=True,
)
if result.returncode != 0: if result.returncode != 0:
raise RuntimeError( raise RuntimeError(
f"tea api {path} failed:\n{result.stderr or result.stdout}" f"fgj actions run list failed:\n{result.stderr or result.stdout}"
) )
out = result.stdout.strip() out = result.stdout.strip()
if not out: if not out:
return None return []
data = json.loads(out) try:
if isinstance(data, dict) and "message" in data and "url" in data: data = json.loads(out)
raise RuntimeError(f"tea api {path} returned error: {data['message']}") except json.JSONDecodeError as exc:
return data raise RuntimeError(
f"fgj actions run list returned non-JSON:\n{out[:500]}"
) from exc
return data if isinstance(data, list) else []
def _tea_get(path: str) -> dict:
"""Make an authenticated GET request to the Codeberg API and return parsed JSON.
Tries FORGEJO_TOKEN env var first, then ``fgj auth token`` for the token.
"""
token = os.environ.get("FORGEJO_TOKEN", "")
if not token:
r = subprocess.run(
["fgj", "--hostname", "codeberg.org", "auth", "token"],
capture_output=True, text=True,
)
if r.returncode == 0:
token = r.stdout.strip()
url = f"https://codeberg.org/api/v1{path}"
req = urllib.request.Request(url)
if token:
req.add_header("Authorization", f"token {token}")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
raise RuntimeError(f"GET {path}: HTTP {e.code} {e.reason}") from e
def _set_labels(issue: int, add: list[str], remove: list[str]) -> None: def _set_labels(issue: int, add: list[str], remove: list[str]) -> None:
@@ -142,32 +188,61 @@ def _ready_issues() -> list[dict]:
return ready return ready
def _latest_ci_run() -> dict | None: def _to_plan_issues() -> list[dict]:
data = _tea_get(f"repos/{REPO}/actions/runs?limit=1") """Return open issues with State/ToPlan, Prio/High first, then oldest."""
runs = (data or {}).get("workflow_runs", []) result = subprocess.run(
return runs[0] if runs else None ["fgj", "--hostname", "codeberg.org", "issue", "list",
"--repo", REPO, "--state", "open", "--json"],
capture_output=True, text=True, check=True,
)
data = json.loads(result.stdout) if result.stdout.strip() else []
to_plan = [
i for i in data
if any(lbl["name"] == LABEL_TO_PLAN for lbl in i.get("labels", []))
and i.get("user", {}).get("login", "") in ALLOWED_ISSUE_AUTHORS
]
to_plan.sort(key=lambda i: (
0 if any(lbl["name"] == LABEL_PRIO_HIGH for lbl in i.get("labels", [])) else 1,
i["number"],
))
return to_plan
def _latest_main_ci_run() -> dict | None:
"""Return the latest ci.yml run on the main branch.
Forgejo reports scheduled/dispatch workflows (e.g. deploy.yml) with
event=push and prettyref=main, so filtering by event alone is not enough.
We also require workflow_id == "ci.yml".
"""
data = _tea_get(f"/repos/{REPO}/actions/runs?limit=20")
for run in data.get("workflow_runs", []):
if (run.get("event") == "push"
and run.get("prettyref") == "main"
and run.get("workflow_id") == "ci.yml"):
return run
return None
def _latest_ci_run_for_branch(branch: str) -> dict | None: def _latest_ci_run_for_branch(branch: str) -> dict | None:
"""Return the latest CI run for a specific branch, or None. """Return the latest CI run for a specific branch, or None.
Forgejo's workflow_runs API has no top-level head_branch field. For pull_request events the branch is embedded in the JSON ``event_payload``
For push events the branch is in ``prettyref``; for pull_request field; for push events it appears directly in ``prettyref``.
events it lives inside ``event_payload["pull_request"]["head"]["ref"]``.
""" """
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20") data = _tea_get(f"/repos/{REPO}/actions/runs?limit=20")
runs = (data or {}).get("workflow_runs", []) for run in data.get("workflow_runs", []):
for run in runs:
if run.get("event") == "pull_request": if run.get("event") == "pull_request":
try: payload_str = run.get("event_payload", "")
payload = json.loads(run.get("event_payload", "{}")) if payload_str:
if payload.get("pull_request", {}).get("head", {}).get("ref") == branch: try:
return run payload = json.loads(payload_str)
except (json.JSONDecodeError, AttributeError): if payload.get("pull_request", {}).get("head", {}).get("ref") == branch:
pass return run
else: except (json.JSONDecodeError, AttributeError):
if run.get("prettyref") == branch: pass
return run elif run.get("event") == "push" and run.get("prettyref") == branch:
return run
return None return None
@@ -209,25 +284,121 @@ def _open_issue_prs() -> list[dict]:
return issue_prs return issue_prs
def _open_renovate_prs() -> list[dict]:
"""Return all open PRs from Renovate (renovate/* 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)
renovate_prs = [
pr for pr in prs
if (pr.get("head", {}).get("ref") or "").startswith("renovate/")
]
renovate_prs.sort(key=lambda p: p["number"])
return renovate_prs
def _merged_issue_prs() -> list[dict]:
"""Return recently merged PRs with issue-{N}-fix branches, oldest-first.
Used for catch-up: if the loop set State/Question (e.g., no CI run detected)
but the PR was later merged manually, we still want to close the issue.
"""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "pr", "list",
"--repo", REPO, "--state", "closed", "--json"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return []
try:
prs = json.loads(result.stdout)
except json.JSONDecodeError:
return []
merged = []
for pr in prs:
if not pr.get("merged"):
continue
head = pr.get("head", {})
ref = head.get("ref") or head.get("label", "").split(":")[-1]
if re.match(r"^issue-\d+-fix$", ref or ""):
merged.append(pr)
merged.sort(key=lambda p: p["number"])
return merged
def _latest_ci_run_for_pr(pr_number: int) -> dict | None: def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
"""Return the latest CI run triggered by a pull_request event for the given PR number.""" """Return the latest CI run triggered by a pull_request event for the given PR number."""
data = _tea_get(f"repos/{REPO}/actions/runs?event=pull_request&limit=50") pr_ref = f"#{pr_number}"
runs = (data or {}).get("workflow_runs", []) for run in _fgj_run_list(limit=50):
for run in runs: if run.get("event") == "pull_request" and run.get("prettyref") == pr_ref:
try: return run
payload = json.loads(run.get("event_payload", "{}"))
if payload.get("pull_request", {}).get("number") == pr_number:
return run
except (json.JSONDecodeError, AttributeError):
pass
return None return None
def _get_issue_labels(issue: int) -> list[str]:
"""Return label names for an issue."""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "issue", "view", str(issue),
"--repo", REPO, "--json"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return []
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
return []
return [lbl["name"] for lbl in data.get("issue", {}).get("labels", [])]
def _merge_pr(pr_number: int) -> None: def _merge_pr(pr_number: int) -> None:
"""Squash-merge a PR via fgj.""" """Squash-merge a PR via fgj."""
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash") _fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
def _handle_pr_still_open_after_merge(pr_number: int, branch: str, issue_num: int | None) -> str:
"""Handle a PR that is still open after a successful _merge_pr() call.
Returns one of:
"rebase-spawned" — merge conflict detected; rebase agent started, state written
"merged" — PR closed after a retry
"fallback" — all options exhausted; caller should set State/Question
"""
try:
pr_data = _tea_get(f"/repos/{REPO}/pulls/{pr_number}")
except RuntimeError:
pr_data = {}
mergeable = pr_data.get("mergeable")
if mergeable is False:
prompt = (
f"Rebase branch `{branch}` onto main to resolve merge conflicts, then push. "
"Do not change any logic — only resolve conflicts and push."
)
session_name = f"rebase-pr-{pr_number}"
pid = _start_agent(prompt, session_name)
_write_state(pid, issue_num, "pending-ci", session_name=session_name)
print(f"PR #{pr_number} has merge conflicts — spawned rebase agent (pid={pid}).")
return "rebase-spawned"
for attempt in range(1, 3):
time.sleep(5)
try:
_merge_pr(pr_number)
except RuntimeError as e:
print(f"PR #{pr_number} merge retry {attempt} failed: {e}")
if not _find_pr_for_branch(branch):
print(f"PR #{pr_number} merged on retry {attempt}.")
return "merged"
return "fallback"
# ── state file ──────────────────────────────────────────────────────────────── # ── state file ────────────────────────────────────────────────────────────────
@@ -261,6 +432,12 @@ def _clear_state() -> None:
STATE_FILE.unlink(missing_ok=True) 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: def _find_session_uuid(session_name: str) -> str | None:
"""Return the Claude session UUID for *session_name*, or None if not found. """Return the Claude session UUID for *session_name*, or None if not found.
@@ -333,6 +510,15 @@ def _agent_alive(state: dict) -> bool:
return True 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: def _agent_age_seconds(state: dict) -> float:
"""Seconds elapsed since the agent was launched, from the state file timestamp.""" """Seconds elapsed since the agent was launched, from the state file timestamp."""
try: try:
@@ -367,11 +553,13 @@ def _git_summary() -> str:
def _kill_agent(state: dict) -> None: def _kill_agent(state: dict) -> None:
"""Forcefully stop the running agent.""" """Forcefully stop the running agent."""
pid = state.get("pid") pid = state.get("pid")
if pid: if pid and _is_claude_process(pid):
try: try:
os.kill(pid, 9) os.kill(pid, 9)
except ProcessLookupError: except ProcessLookupError:
pass pass
elif pid:
print(f"WARNING: pid {pid} is not a claude process — skipping kill to avoid hitting recycled PID")
# ── subcommands ─────────────────────────────────────────────────────────────── # ── subcommands ───────────────────────────────────────────────────────────────
@@ -409,7 +597,7 @@ def cmd_list() -> int:
sessions.sort(reverse=True) sessions.sort(reverse=True)
total = len(sessions) total = len(sessions)
print(f" {'DATE':<16} {'NAME':<20} UUID (use with: claude --resume <uuid>)") print(f" {'DATE':<16} {'NAME':<20} UUID (use with: claude --resume <uuid> --dangerously-skip-permissions)")
print(f" {'-'*16} {'-'*20} {'-'*36}") print(f" {'-'*16} {'-'*20} {'-'*36}")
for mtime, name, sid in sessions[:20]: for mtime, name, sid in sessions[:20]:
ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M") ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
@@ -419,12 +607,44 @@ def cmd_list() -> int:
return 0 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 ───────────────────────────────────────────────────────────────── # ── main flow ─────────────────────────────────────────────────────────────────
def _run_loop() -> int: def _run_loop() -> int:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
print(f"---------------------- Starting {now.strftime('%Y-%m-%d %H:%MZ')}") print(f"---------------------- Starting {now.strftime('%Y-%m-%d %H:%MZ')}")
_update_heartbeat()
state = _read_state() state = _read_state()
@@ -461,9 +681,9 @@ def _run_loop() -> int:
session_name = state.get("session_name") session_name = state.get("session_name")
uuid = _find_session_uuid(session_name) if session_name else None uuid = _find_session_uuid(session_name) if session_name else None
if uuid: if uuid:
resume_cmd = f"claude --resume {shlex.quote(uuid)}" resume_cmd = f"claude --resume {shlex.quote(uuid)} --dangerously-skip-permissions"
elif session_name: elif session_name:
resume_cmd = f"claude --resume <uuid> # run: scripts/agent_loop.py list" resume_cmd = f"claude --resume <uuid> --dangerously-skip-permissions # run: scripts/agent_loop.py list"
else: else:
resume_cmd = "" resume_cmd = ""
git_info = _git_summary() git_info = _git_summary()
@@ -479,13 +699,29 @@ def _run_loop() -> int:
# Agent not running (or no state) — extract any pending issue, then clean up. # Agent not running (or no state) — extract any pending issue, then clean up.
pending_issue: int | None = None pending_issue: int | None = None
pending_type: str | None = None
ci_run_id_at_start: int | None = None ci_run_id_at_start: int | None = None
if state: if state:
pending_issue = state.get("issue") pending_issue = state.get("issue")
pending_type = state.get("type")
ci_run_id_at_start = state.get("ci_run_id_at_start") ci_run_id_at_start = state.get("ci_run_id_at_start")
_clear_state() _clear_state()
# ── 2. Check for a PR opened by the agent ──────────────────────────────── # ── 2a. Finished planning agent ───────────────────────────────────────────
if pending_issue and pending_type == "plan":
session_name = f"plan-issue-{pending_issue}"
uuid = _find_session_uuid(session_name)
if uuid:
resume_cmd = f"claude --resume {shlex.quote(uuid)} --dangerously-skip-permissions"
_comment_issue(
pending_issue,
f"Planning complete. To resume this session:\n\n```\n{resume_cmd}\n```",
)
_set_labels(pending_issue, add=[LABEL_PLANNED], remove=[LABEL_IN_PROGRESS])
print(f"Planning done for {_issue_url(pending_issue)} — set State/Planned.")
return 0
# ── 2b. Check for a PR opened by the agent ───────────────────────────────
if pending_issue: if pending_issue:
branch = f"issue-{pending_issue}-fix" branch = f"issue-{pending_issue}-fix"
pr = _find_pr_for_branch(branch) pr = _find_pr_for_branch(branch)
@@ -509,6 +745,9 @@ def _run_loop() -> int:
"Fetch the CI logs using the task ci-logs command or the Codeberg API. " "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. " "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 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. " "Verify locally with 'task check' before pushing. "
"When done, stop." "When done, stop."
) )
@@ -547,7 +786,32 @@ def _run_loop() -> int:
# CI passed on the PR branch — squash-merge and close. # 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}.") 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):
merge_result = _handle_pr_still_open_after_merge(pr_number, branch, pending_issue)
if merge_result == "rebase-spawned":
return 0
if merge_result == "merged":
_close_issue(pending_issue)
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
return 0
print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.")
_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) _close_issue(pending_issue)
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.") print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
return 0 return 0
@@ -596,8 +860,40 @@ def _run_loop() -> int:
continue continue
if pr_run and pr_run.get("status") == "success": if pr_run and pr_run.get("status") == "success":
if issue_num and LABEL_QUESTION in _get_issue_labels(issue_num):
print(f"Catch-up: PR #{pr_number} — issue #{issue_num} is State/Question, skipping.")
continue
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.") print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
_merge_pr(pr_number) 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):
merge_result = _handle_pr_still_open_after_merge(pr_number, branch, issue_num)
if merge_result == "rebase-spawned":
return 0
if merge_result == "merged":
if issue_num:
_close_issue(issue_num)
print(f"Catch-up: merged PR #{pr_number} and closed issue #{issue_num} after retry.")
else:
print(f"Catch-up: merged PR #{pr_number} after retry.")
return 0
print(
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: if issue_num:
_close_issue(issue_num) _close_issue(issue_num)
print(f"Merged PR #{pr_number} and closed issue #{issue_num}.") print(f"Merged PR #{pr_number} and closed issue #{issue_num}.")
@@ -605,8 +901,68 @@ def _run_loop() -> int:
print(f"Merged PR #{pr_number}.") print(f"Merged PR #{pr_number}.")
return 0 return 0
# ── 3. Global CI check (agent pushed to main, or no pending issue) ──────── # ── 2c. Catch-up: close issues whose PRs were already merged ─────────────
run = _latest_ci_run() # Handles the case where State/Question was set (e.g., no CI run appeared
# because the changed paths didn't match ci.yml's path filter) but the PR
# was merged manually afterward. The next loop tick closes the issue.
for pr in _merged_issue_prs():
head = pr.get("head", {})
branch = head.get("ref") or head.get("label", "").split(":")[-1]
m = re.match(r"^issue-(\d+)-fix$", branch or "")
if not m:
continue
issue_num = int(m.group(1))
try:
issue_data = _tea_get(f"/repos/{REPO}/issues/{issue_num}")
except RuntimeError:
continue
if issue_data.get("state") != "open":
continue
pr_number = pr["number"]
print(f"Catch-up (merged PR): PR #{pr_number} for issue #{issue_num} was merged — closing.")
try:
_close_issue(issue_num)
except RuntimeError as e:
print(f"Catch-up (merged PR): could not close issue #{issue_num}: {e}")
continue
return 0
# ── 2d. Catch-up: merge Renovate PRs with passing CI ─────────────────────
# The merge-renovate CI job only fires on pull_request events. If a Renovate
# PR had CI run before that job was added (or the automerge label was absent),
# it stays open forever. Detect and merge those here.
for pr in _open_renovate_prs():
pr_number = pr["number"]
pr_url = f"{REPO_URL}/pulls/{pr_number}"
pr_run = _latest_ci_run_for_pr(pr_number)
if pr_run and pr_run.get("status") == "running":
print(f"Catch-up (Renovate): CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} still running. Waiting.")
return 0
if pr_run and pr_run.get("status") in ("failure", "error"):
print(f"Catch-up (Renovate): 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 (Renovate): CI passed on PR #{pr_number} ({pr_url}) — merging.")
try:
_merge_pr(pr_number)
except RuntimeError as e:
print(f"Catch-up (Renovate): merge of PR #{pr_number} failed: {e} — skipping.")
continue
branch = pr.get("head", {}).get("ref", "")
if _find_pr_for_branch(branch):
print(f"Catch-up (Renovate): PR #{pr_number} still open after merge — skipping.")
continue
print(f"Catch-up (Renovate): merged PR #{pr_number}.")
return 0
if pr_run is None:
print(f"Catch-up (Renovate): no CI run for PR #{pr_number} ({pr_url}) — skipping (needs manual review).")
# ── 3. Global CI check (main branch only) ────────────────────────────────
run = _latest_main_ci_run()
if run and run.get("status") == "running": if run and run.get("status") == "running":
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.") print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
@@ -615,17 +971,38 @@ def _run_loop() -> int:
return 0 return 0
if run and run.get("status") in ("failure", "error"): 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:
in_flight = [
r for r in _fgj_run_list(limit=5)
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.") print(f"CI run {_ci_run_url(run['id'])} failed — starting fix agent.")
prompt = ( 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']}. " f"The CI run ID is {run['id']}. "
"Fetch the CI logs using the task ci-logs command or the Codeberg API. " "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. " "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." "When done, stop."
) )
pid = _start_agent(prompt, "ci-fix") 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 return 0
# CI is ok (or no run). # CI is ok (or no run).
@@ -651,10 +1028,44 @@ def _run_loop() -> int:
print(f"CI passed{ci_run_part} — closed {_issue_url(pending_issue)}.") print(f"CI passed{ci_run_part} — closed {_issue_url(pending_issue)}.")
return 0 return 0
# Find a ToPlan issue — planning takes priority over implementation.
to_plan = _to_plan_issues()
if to_plan:
issue = to_plan[0]
issue_number = issue["number"]
issue_title = issue["title"]
issue_body = issue.get("body", "")
print(f"Starting planning agent for {_issue_url(issue_number)} {issue_title}")
_set_labels(issue_number, add=[LABEL_IN_PROGRESS], remove=[LABEL_TO_PLAN])
plan_prompt = f"""Analyze Codeberg issue #{issue_number} in the guettli/sharedinbox repository and write a detailed implementation plan.
Issue title: {issue_title}
Issue body:
{issue_body}
Instructions:
- Read and understand the issue thoroughly.
- Explore the relevant parts of the codebase to understand the current structure.
- Write a detailed implementation plan as a comment on the issue using:
fgj issue comment {issue_number} --repo {REPO} --body "..."
The plan should cover: which files to change, what approach to take, and any risks or open questions.
- Do NOT write any code, do NOT create any branches or PRs, do NOT modify any files.
- If the issue is unclear or you need more information, set the label to State/Question
and stop (do NOT close the issue).
- When you have posted the plan as an issue comment, stop.
"""
session_name = f"plan-issue-{issue_number}"
pid = _start_agent(plan_prompt, session_name)
_write_state(pid, issue_number, "plan", issue_title, session_name=session_name)
return 0
# Find a Ready issue. # Find a Ready issue.
issues = _ready_issues() issues = _ready_issues()
if not issues: if not issues:
print("No issues with State/Ready. Nothing to do.") print("No issues with State/ToPlan or State/Ready. Nothing to do.")
return 0 return 0
issue = issues[0] issue = issues[0]
@@ -684,7 +1095,10 @@ Instructions:
- Implement the required change, following the existing code style. - Implement the required change, following the existing code style.
- Write or update tests as appropriate. - Write or update tests as appropriate.
- Run 'task check' locally and fix any failures before committing. - 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: - 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 checkout -b issue-{issue_number}-fix
git push -u origin issue-{issue_number}-fix git push -u origin issue-{issue_number}-fix
@@ -707,10 +1121,13 @@ def main() -> int:
parser = argparse.ArgumentParser(prog="agent_loop") parser = argparse.ArgumentParser(prog="agent_loop")
sub = parser.add_subparsers(dest="cmd") sub = parser.add_subparsers(dest="cmd")
sub.add_parser("list", help="List recent agent sessions") 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() args = parser.parse_args()
if args.cmd == "list": if args.cmd == "list":
return cmd_list() return cmd_list()
if args.cmd == "monitor":
return cmd_monitor()
return _run_loop() return _run_loop()
+7
View File
@@ -11,6 +11,7 @@ const _minCoveragePercent = 80;
// Pure-abstract interfaces: no executable code, Dart VM never instruments them. // Pure-abstract interfaces: no executable code, Dart VM never instruments them.
const _noCode = { const _noCode = {
'lib/core/db_schema_version.dart',
'lib/core/repositories/account_repository.dart', 'lib/core/repositories/account_repository.dart',
'lib/core/repositories/draft_repository.dart', 'lib/core/repositories/draft_repository.dart',
'lib/core/repositories/email_repository.dart', 'lib/core/repositories/email_repository.dart',
@@ -19,7 +20,9 @@ const _noCode = {
'lib/core/repositories/sync_log_repository.dart', 'lib/core/repositories/sync_log_repository.dart',
'lib/core/repositories/undo_repository.dart', 'lib/core/repositories/undo_repository.dart',
'lib/core/repositories/search_history_repository.dart', 'lib/core/repositories/search_history_repository.dart',
'lib/core/repositories/user_preferences_repository.dart',
'lib/core/models/undo_action.dart', 'lib/core/models/undo_action.dart',
'lib/core/models/user_preferences.dart',
'lib/core/storage/secure_storage.dart', 'lib/core/storage/secure_storage.dart',
}; };
@@ -57,6 +60,8 @@ const _excluded = {
'lib/ui/widgets/try_connection_button.dart', 'lib/ui/widgets/try_connection_button.dart',
'lib/ui/widgets/undo_shell.dart', 'lib/ui/widgets/undo_shell.dart',
'lib/ui/screens/about_screen.dart', 'lib/ui/screens/about_screen.dart',
'lib/ui/screens/email_action_helpers.dart',
'lib/ui/utils/about_markdown.dart',
'lib/ui/widgets/email_tile.dart', 'lib/ui/widgets/email_tile.dart',
'lib/core/sync/account_sync_manager.dart', 'lib/core/sync/account_sync_manager.dart',
'lib/core/sync/background_sync.dart', 'lib/core/sync/background_sync.dart',
@@ -70,6 +75,8 @@ const _excluded = {
'lib/data/repositories/sync_log_repository_impl.dart', 'lib/data/repositories/sync_log_repository_impl.dart',
'lib/data/repositories/undo_repository_impl.dart', 'lib/data/repositories/undo_repository_impl.dart',
'lib/data/repositories/search_history_repository_impl.dart', 'lib/data/repositories/search_history_repository_impl.dart',
'lib/data/repositories/user_preferences_repository_impl.dart',
'lib/ui/screens/user_preferences_screen.dart',
'lib/core/services/update_service.dart', 'lib/core/services/update_service.dart',
}; };
-3
View File
@@ -33,9 +33,6 @@ def list_remote_files(ssh_user: str, ssh_host: str, pattern: str) -> list[str]:
result = subprocess.run( result = subprocess.run(
[ [
"ssh", "ssh",
"-v",
"-o", "StrictHostKeyChecking=no",
"-i", "/root/.ssh/id_ed25519",
f"{ssh_user}@{ssh_host}", f"{ssh_user}@{ssh_host}",
f"find {REMOTE_BUILDS_DIR} -name '{pattern}' -type f | sort", f"find {REMOTE_BUILDS_DIR} -name '{pattern}' -type f | sort",
], ],
+6
View File
@@ -24,6 +24,12 @@ for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
fi fi
if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then
echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts" echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts"
if ! docker info >/dev/null 2>&1; then
echo "Error: Remote Dagger engine is unavailable AND local Docker daemon is not running."
echo "Cannot proceed. Ensure either the remote server at $host:$port is accessible"
echo "or that Docker is running locally (check: sudo systemctl start docker)."
exit 1
fi
echo "Remote engine unavailable — CI will use the local Dagger engine." echo "Remote engine unavailable — CI will use the local Dagger engine."
exit 0 exit 0
fi fi
+361 -18
View File
@@ -6,6 +6,7 @@ import json
import os import os
import tempfile import tempfile
import unittest import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -88,21 +89,47 @@ class TestAgentAlive(unittest.TestCase):
self.assertFalse(agent_loop._agent_alive({"pid": None})) 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): class TestKillAgent(unittest.TestCase):
def test_kill_sends_sigkill(self): def test_kill_sends_sigkill(self):
with patch("agent_loop.os.kill") as mock_kill: with patch("agent_loop._is_claude_process", return_value=True):
agent_loop._kill_agent({"pid": 1234}) with patch("agent_loop.os.kill") as mock_kill:
mock_kill.assert_called_once_with(1234, 9) agent_loop._kill_agent({"pid": 1234})
mock_kill.assert_called_once_with(1234, 9)
def test_kill_ignores_missing_process(self): def test_kill_ignores_missing_process(self):
with patch("agent_loop.os.kill", side_effect=ProcessLookupError): with patch("agent_loop._is_claude_process", return_value=True):
agent_loop._kill_agent({"pid": 1234}) # Should not raise. 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): def test_kill_noop_when_no_pid(self):
with patch("agent_loop.os.kill") as mock_kill: with patch("agent_loop.os.kill") as mock_kill:
agent_loop._kill_agent({}) agent_loop._kill_agent({})
mock_kill.assert_not_called() 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): class TestStartAgent(unittest.TestCase):
def _make_mock_proc(self, pid=42): def _make_mock_proc(self, pid=42):
@@ -174,7 +201,9 @@ class TestMain(unittest.TestCase):
return 55 return 55
with patch("agent_loop._read_state", return_value=None), \ 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._merged_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._ready_issues", return_value=[self._make_issue(10)]), \
patch("agent_loop._set_labels", side_effect=fake_set_labels), \ patch("agent_loop._set_labels", side_effect=fake_set_labels), \
patch("agent_loop._start_agent", side_effect=fake_start_agent), \ patch("agent_loop._start_agent", side_effect=fake_start_agent), \
@@ -200,7 +229,9 @@ class TestMain(unittest.TestCase):
captured["remove"] = remove captured["remove"] = remove
with patch("agent_loop._read_state", return_value=None), \ 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._merged_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._ready_issues", return_value=[self._make_issue(7)]), \
patch("agent_loop._set_labels", side_effect=fake_set_labels), \ patch("agent_loop._set_labels", side_effect=fake_set_labels), \
patch("agent_loop._start_agent", return_value=99), \ patch("agent_loop._start_agent", return_value=99), \
@@ -213,7 +244,9 @@ class TestMain(unittest.TestCase):
def test_no_ready_issues_does_nothing(self): def test_no_ready_issues_does_nothing(self):
"""main() exits cleanly with 0 when there are no ready issues.""" """main() exits cleanly with 0 when there are no ready issues."""
with patch("agent_loop._read_state", return_value=None), \ 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._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \ patch("agent_loop._ready_issues", return_value=[]), \
patch("agent_loop._set_labels") as mock_labels, \ patch("agent_loop._set_labels") as mock_labels, \
patch("agent_loop._start_agent") as mock_start: patch("agent_loop._start_agent") as mock_start:
@@ -232,7 +265,9 @@ class TestMain(unittest.TestCase):
return 77 return 77
with patch("agent_loop._read_state", return_value=None), \ 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._merged_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._ready_issues", return_value=[self._make_issue(42)]), \
patch("agent_loop._set_labels"), \ patch("agent_loop._set_labels"), \
patch("agent_loop._start_agent", side_effect=fake_start_agent), \ patch("agent_loop._start_agent", side_effect=fake_start_agent), \
@@ -266,8 +301,9 @@ class TestPendingCi(unittest.TestCase):
def test_closes_issue_when_ci_passes_after_agent_finishes(self): 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.""" """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)), \ 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._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr") as mock_merge, \ patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._close_issue") as mock_close, \ patch("agent_loop._close_issue") as mock_close, \
@@ -282,7 +318,7 @@ class TestPendingCi(unittest.TestCase):
"""'CI passed' line includes the CI run URL when a run is available.""" """'CI passed' line includes the CI run URL when a run is available."""
buf = io.StringIO() buf = io.StringIO()
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \ 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._latest_ci_run_for_branch", return_value={"id": 4145144, "status": "success"}), \
patch("agent_loop._merge_pr"), \ patch("agent_loop._merge_pr"), \
patch("agent_loop._close_issue"), \ patch("agent_loop._close_issue"), \
@@ -392,7 +428,7 @@ class TestPendingCi(unittest.TestCase):
def test_closes_issue_after_ci_fix_and_ci_passes(self): 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.""" """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")), \ 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._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr") as mock_merge, \ patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._close_issue") as mock_close, \ patch("agent_loop._close_issue") as mock_close, \
@@ -409,7 +445,9 @@ class TestPendingCi(unittest.TestCase):
"pid": 999999999, "issue": None, "started_at": "2026-01-01T00:00:00+00:00", "pid": 999999999, "issue": None, "started_at": "2026-01-01T00:00:00+00:00",
"type": "ci-fix", "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._merged_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._close_issue") as mock_close, \
patch("agent_loop._ready_issues", return_value=[]), \ patch("agent_loop._ready_issues", return_value=[]), \
patch("agent_loop._clear_state"): patch("agent_loop._clear_state"):
@@ -425,7 +463,9 @@ class TestOutputFormat(unittest.TestCase):
def test_output_starts_with_header(self): def test_output_starts_with_header(self):
buf = io.StringIO() buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \ 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._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \ patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf): contextlib.redirect_stdout(buf):
agent_loop._run_loop() agent_loop._run_loop()
@@ -436,7 +476,9 @@ class TestOutputFormat(unittest.TestCase):
def test_no_agent_loop_prefix_in_output(self): def test_no_agent_loop_prefix_in_output(self):
buf = io.StringIO() buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \ 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._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \ patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf): contextlib.redirect_stdout(buf):
agent_loop._run_loop() agent_loop._run_loop()
@@ -446,7 +488,9 @@ class TestOutputFormat(unittest.TestCase):
run = {"id": 4145144, "status": "running"} run = {"id": 4145144, "status": "running"}
buf = io.StringIO() buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \ 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._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=run), \
contextlib.redirect_stdout(buf): contextlib.redirect_stdout(buf):
agent_loop._run_loop() agent_loop._run_loop()
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144", self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144",
@@ -456,7 +500,9 @@ class TestOutputFormat(unittest.TestCase):
issue = {"number": 128, "title": "Fix something", "body": "", "labels": []} issue = {"number": 128, "title": "Fix something", "body": "", "labels": []}
buf = io.StringIO() buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \ 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._merged_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._ready_issues", return_value=[issue]), \
patch("agent_loop._set_labels"), \ patch("agent_loop._set_labels"), \
patch("agent_loop._start_agent", return_value=99), \ patch("agent_loop._start_agent", return_value=99), \
@@ -468,6 +514,47 @@ class TestOutputFormat(unittest.TestCase):
self.assertIn("Fix something", output) self.assertIn("Fix something", output)
class TestLatestMainCiRun(unittest.TestCase):
"""_latest_main_ci_run() must return only ci.yml push-to-main runs."""
def _ci_run(self, run_id, status="success"):
return {"event": "push", "prettyref": "main", "workflow_id": "ci.yml",
"status": status, "id": run_id}
def _deploy_run(self, run_id, status="success"):
return {"event": "push", "prettyref": "main", "workflow_id": "deploy.yml",
"status": status, "id": run_id}
def test_skips_deploy_run_returns_ci_run(self):
# Forgejo reports deploy.yml schedule runs as event=push/prettyref=main;
# must be excluded by workflow_id filter.
runs = [self._deploy_run(1), self._ci_run(2)]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNotNone(result)
self.assertEqual(result["id"], 2)
def test_returns_none_when_only_deploy_runs_exist(self):
runs = [self._deploy_run(1)]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNone(result)
def test_returns_none_when_only_schedule_runs_exist(self):
runs = [{"event": "schedule", "prettyref": "main", "workflow_id": "deploy.yml",
"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_ci_push_to_main_run(self):
runs = [self._ci_run(42, status="running")]
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): class TestLatestCiRunForBranch(unittest.TestCase):
"""Tests for _latest_ci_run_for_branch — Forgejo API field mapping.""" """Tests for _latest_ci_run_for_branch — Forgejo API field mapping."""
@@ -636,7 +723,7 @@ class TestRunLoopResumeCommand(unittest.TestCase):
contextlib.redirect_stdout(buf): contextlib.redirect_stdout(buf):
agent_loop._run_loop() agent_loop._run_loop()
output = buf.getvalue() output = buf.getvalue()
self.assertIn(f"claude --resume {fake_uuid}", output) self.assertIn(f"claude --resume {fake_uuid} --dangerously-skip-permissions", output)
def test_resume_shows_list_hint_when_uuid_not_found(self): def test_resume_shows_list_hint_when_uuid_not_found(self):
buf = io.StringIO() buf = io.StringIO()
@@ -667,5 +754,261 @@ class TestRunLoopResumeCommand(unittest.TestCase):
self.assertNotIn("Resume:", output) self.assertNotIn("Resume:", output)
class TestCatchupSkipsQuestionIssues(unittest.TestCase):
"""Catch-up must not retry merging a PR whose issue is already State/Question."""
def _make_pr(self, pr_number=50, branch="issue-10-fix"):
return {"number": pr_number, "head": {"ref": branch}}
def test_skips_merge_when_issue_has_question_label(self):
pr = self._make_pr()
ci_run = {"id": 999, "status": "success"}
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[pr]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._comment_issue") as mock_comment, \
patch("agent_loop._set_labels") as mock_labels, \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_merge.assert_not_called()
mock_comment.assert_not_called()
mock_labels.assert_not_called()
def test_proceeds_with_merge_when_issue_lacks_question_label(self):
pr = self._make_pr()
ci_run = {"id": 999, "status": "success"}
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[pr]), \
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_IN_PROGRESS]), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._find_pr_for_branch", return_value=None), \
patch("agent_loop._close_issue"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_merge.assert_called_once_with(50)
class TestMergedPrCatchup(unittest.TestCase):
"""Catch-up closes issues whose PRs were already merged outside the normal flow."""
def _make_merged_pr(self, pr_number=283, branch="issue-282-fix"):
return {"number": pr_number, "merged": True, "head": {"ref": branch}}
def test_closes_issue_when_pr_was_merged(self):
"""When a merged issue-N-fix PR exists and the issue still has labels, close it."""
pr = self._make_merged_pr()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_called_once_with(282)
def test_skips_when_issue_has_no_labels(self):
"""When _get_issue_labels returns [] (likely already closed), skip the issue."""
pr = self._make_merged_pr()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
patch("agent_loop._get_issue_labels", return_value=[]), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_not_called()
def test_output_mentions_merged_pr_and_issue(self):
"""The catch-up log line names the PR number and issue number."""
pr = self._make_merged_pr(pr_number=283, branch="issue-282-fix")
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._close_issue"), \
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()
output = buf.getvalue()
self.assertIn("283", output)
self.assertIn("282", output)
def test_continues_on_close_error(self):
"""If _close_issue raises, the loop continues instead of crashing."""
pr = self._make_merged_pr()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._close_issue", side_effect=RuntimeError("already closed")), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
class TestMergeFailsOpen(unittest.TestCase):
"""Tests for auto-resolution when a PR is still open after the merge command."""
def _dead_state(self, issue: int, kind: str = "issue") -> dict:
return {
"pid": 999999999,
"issue": issue,
"started_at": "2026-01-01T00:00:00+00:00",
"type": kind,
}
def _open_pr(self, branch: str = "issue-10-fix") -> dict:
return {"number": 5, "head": {"ref": branch}, "created_at": "2026-01-01T00:00:00+00:00"}
def test_merge_fails_open_with_conflicts_spawns_rebase_agent(self):
"""mergeable=false → rebase agent spawned, state written as pending-ci."""
written_state = {}
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
written_state["pid"] = pid
written_state["issue"] = issue
written_state["kind"] = kind
written_state["session_name"] = session_name
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), self._open_pr()]), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr"), \
patch("agent_loop._tea_get", return_value={"mergeable": False}), \
patch("agent_loop._start_agent", return_value=77) as mock_start, \
patch("agent_loop._write_state", side_effect=fake_write_state), \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_start.assert_called_once()
prompt = mock_start.call_args[0][0]
self.assertIn("Rebase branch", prompt)
self.assertIn("issue-10-fix", prompt)
self.assertEqual(written_state.get("kind"), "pending-ci")
self.assertEqual(written_state.get("issue"), 10)
def test_merge_fails_open_no_conflicts_retries_and_succeeds(self):
"""mergeable=true, second attempt succeeds → issue closed."""
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch",
side_effect=[self._open_pr(), self._open_pr(), None]), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr"), \
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
patch("agent_loop.time.sleep"), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_called_once_with(10)
def test_merge_fails_open_no_conflicts_all_retries_exhausted(self):
"""All retries exhausted with PR still open → falls through to State/Question."""
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch",
side_effect=[self._open_pr(), self._open_pr(),
self._open_pr(), self._open_pr()]), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr"), \
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
patch("agent_loop.time.sleep"), \
patch("agent_loop._set_labels") as mock_labels, \
patch("agent_loop._comment_issue") as mock_comment, \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_not_called()
mock_labels.assert_called_once_with(
10,
add=[agent_loop.LABEL_QUESTION],
remove=[agent_loop.LABEL_IN_PROGRESS],
)
mock_comment.assert_called_once()
class TestHeartbeat(unittest.TestCase):
"""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._merged_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__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -149,6 +149,22 @@ class _FakeMailboxes implements MailboxRepository {
@override @override
Future<void> clearForResync(String accountId) async {} Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
} }
class _FakeEmails implements EmailRepository { class _FakeEmails implements EmailRepository {
@@ -288,6 +304,8 @@ class _FakeLogs implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
+17
View File
@@ -181,6 +181,8 @@ class FakeSyncLogRepository implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -222,6 +224,21 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
Future<Mailbox?> findMailboxByRole(String id, String role) async => null; Future<Mailbox?> findMailboxByRole(String id, String role) async => null;
@override @override
Future<void> clearForResync(String accountId) async {} Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
} }
class _AccountRepositoryWithMissingPlugin implements AccountRepository { class _AccountRepositoryWithMissingPlugin implements AccountRepository {
+195 -157
View File
@@ -3,16 +3,16 @@
// Do not manually edit this file. // Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes // ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i4; import 'dart:async' as _i5;
import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i6; import 'package:mockito/src/dummies.dart' as _i7;
import 'package:sharedinbox/core/models/account.dart' as _i5; import 'package:sharedinbox/core/models/account.dart' as _i6;
import 'package:sharedinbox/core/models/email.dart' as _i2; import 'package:sharedinbox/core/models/email.dart' as _i3;
import 'package:sharedinbox/core/models/mailbox.dart' as _i8; import 'package:sharedinbox/core/models/mailbox.dart' as _i2;
import 'package:sharedinbox/core/repositories/account_repository.dart' as _i3; import 'package:sharedinbox/core/repositories/account_repository.dart' as _i4;
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i9; import 'package:sharedinbox/core/repositories/email_repository.dart' as _i9;
import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i7; import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i8;
// ignore_for_file: type=lint // ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values // ignore_for_file: avoid_redundant_argument_values
@@ -29,8 +29,8 @@ import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i7;
// ignore_for_file: subtype_of_sealed_class // ignore_for_file: subtype_of_sealed_class
// ignore_for_file: invalid_use_of_internal_member // ignore_for_file: invalid_use_of_internal_member
class _FakeEmailBody_0 extends _i1.SmartFake implements _i2.EmailBody { class _FakeMailbox_0 extends _i1.SmartFake implements _i2.Mailbox {
_FakeEmailBody_0( _FakeMailbox_0(
Object parent, Object parent,
Invocation parentInvocation, Invocation parentInvocation,
) : super( ) : super(
@@ -39,9 +39,8 @@ class _FakeEmailBody_0 extends _i1.SmartFake implements _i2.EmailBody {
); );
} }
class _FakeSyncEmailsResult_1 extends _i1.SmartFake class _FakeEmailBody_1 extends _i1.SmartFake implements _i3.EmailBody {
implements _i2.SyncEmailsResult { _FakeEmailBody_1(
_FakeSyncEmailsResult_1(
Object parent, Object parent,
Invocation parentInvocation, Invocation parentInvocation,
) : super( ) : super(
@@ -50,9 +49,20 @@ class _FakeSyncEmailsResult_1 extends _i1.SmartFake
); );
} }
class _FakeReliabilityResult_2 extends _i1.SmartFake class _FakeSyncEmailsResult_2 extends _i1.SmartFake
implements _i2.ReliabilityResult { implements _i3.SyncEmailsResult {
_FakeReliabilityResult_2( _FakeSyncEmailsResult_2(
Object parent,
Invocation parentInvocation,
) : super(
parent,
parentInvocation,
);
}
class _FakeReliabilityResult_3 extends _i1.SmartFake
implements _i3.ReliabilityResult {
_FakeReliabilityResult_3(
Object parent, Object parent,
Invocation parentInvocation, Invocation parentInvocation,
) : super( ) : super(
@@ -64,32 +74,32 @@ class _FakeReliabilityResult_2 extends _i1.SmartFake
/// A class which mocks [AccountRepository]. /// A class which mocks [AccountRepository].
/// ///
/// See the documentation for Mockito's code generation for more information. /// See the documentation for Mockito's code generation for more information.
class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository { class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository {
MockAccountRepository() { MockAccountRepository() {
_i1.throwOnMissingStub(this); _i1.throwOnMissingStub(this);
} }
@override @override
_i4.Stream<List<_i5.Account>> observeAccounts() => (super.noSuchMethod( _i5.Stream<List<_i6.Account>> observeAccounts() => (super.noSuchMethod(
Invocation.method( Invocation.method(
#observeAccounts, #observeAccounts,
[], [],
), ),
returnValue: _i4.Stream<List<_i5.Account>>.empty(), returnValue: _i5.Stream<List<_i6.Account>>.empty(),
) as _i4.Stream<List<_i5.Account>>); ) as _i5.Stream<List<_i6.Account>>);
@override @override
_i4.Future<_i5.Account?> getAccount(String? id) => (super.noSuchMethod( _i5.Future<_i6.Account?> getAccount(String? id) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#getAccount, #getAccount,
[id], [id],
), ),
returnValue: _i4.Future<_i5.Account?>.value(), returnValue: _i5.Future<_i6.Account?>.value(),
) as _i4.Future<_i5.Account?>); ) as _i5.Future<_i6.Account?>);
@override @override
_i4.Future<void> addAccount( _i5.Future<void> addAccount(
_i5.Account? account, _i6.Account? account,
String? password, String? password,
) => ) =>
(super.noSuchMethod( (super.noSuchMethod(
@@ -100,13 +110,13 @@ class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository {
password, password,
], ],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<void> updateAccount( _i5.Future<void> updateAccount(
_i5.Account? account, { _i6.Account? account, {
String? password, String? password,
}) => }) =>
(super.noSuchMethod( (super.noSuchMethod(
@@ -115,65 +125,65 @@ class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository {
[account], [account],
{#password: password}, {#password: password},
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<void> removeAccount(String? id) => (super.noSuchMethod( _i5.Future<void> removeAccount(String? id) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#removeAccount, #removeAccount,
[id], [id],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<String> getPassword(String? accountId) => (super.noSuchMethod( _i5.Future<String> getPassword(String? accountId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#getPassword, #getPassword,
[accountId], [accountId],
), ),
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>( returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
this, this,
Invocation.method( Invocation.method(
#getPassword, #getPassword,
[accountId], [accountId],
), ),
)), )),
) as _i4.Future<String>); ) as _i5.Future<String>);
} }
/// A class which mocks [MailboxRepository]. /// A class which mocks [MailboxRepository].
/// ///
/// See the documentation for Mockito's code generation for more information. /// See the documentation for Mockito's code generation for more information.
class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository { class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository {
MockMailboxRepository() { MockMailboxRepository() {
_i1.throwOnMissingStub(this); _i1.throwOnMissingStub(this);
} }
@override @override
_i4.Stream<List<_i8.Mailbox>> observeMailboxes(String? accountId) => _i5.Stream<List<_i2.Mailbox>> observeMailboxes(String? accountId) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
#observeMailboxes, #observeMailboxes,
[accountId], [accountId],
), ),
returnValue: _i4.Stream<List<_i8.Mailbox>>.empty(), returnValue: _i5.Stream<List<_i2.Mailbox>>.empty(),
) as _i4.Stream<List<_i8.Mailbox>>); ) as _i5.Stream<List<_i2.Mailbox>>);
@override @override
_i4.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod( _i5.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#syncMailboxes, #syncMailboxes,
[accountId], [accountId],
), ),
returnValue: _i4.Future<int>.value(0), returnValue: _i5.Future<int>.value(0),
) as _i4.Future<int>); ) as _i5.Future<int>);
@override @override
_i4.Future<_i8.Mailbox?> findMailboxByRole( _i5.Future<_i2.Mailbox?> findMailboxByRole(
String? accountId, String? accountId,
String? role, String? role,
) => ) =>
@@ -185,18 +195,46 @@ class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository {
role, role,
], ],
), ),
returnValue: _i4.Future<_i8.Mailbox?>.value(), returnValue: _i5.Future<_i2.Mailbox?>.value(),
) as _i4.Future<_i8.Mailbox?>); ) as _i5.Future<_i2.Mailbox?>);
@override @override
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod( _i5.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#clearForResync, #clearForResync,
[accountId], [accountId],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override
_i5.Future<_i2.Mailbox> createMailboxWithRole(
String? accountId,
String? name,
String? role,
) =>
(super.noSuchMethod(
Invocation.method(
#createMailboxWithRole,
[
accountId,
name,
role,
],
),
returnValue: _i5.Future<_i2.Mailbox>.value(_FakeMailbox_0(
this,
Invocation.method(
#createMailboxWithRole,
[
accountId,
name,
role,
],
),
)),
) as _i5.Future<_i2.Mailbox>);
} }
/// A class which mocks [EmailRepository]. /// A class which mocks [EmailRepository].
@@ -208,13 +246,13 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
} }
@override @override
_i4.Stream<String> get onChangesQueued => (super.noSuchMethod( _i5.Stream<String> get onChangesQueued => (super.noSuchMethod(
Invocation.getter(#onChangesQueued), Invocation.getter(#onChangesQueued),
returnValue: _i4.Stream<String>.empty(), returnValue: _i5.Stream<String>.empty(),
) as _i4.Stream<String>); ) as _i5.Stream<String>);
@override @override
_i4.Stream<List<_i2.Email>> observeEmails( _i5.Stream<List<_i3.Email>> observeEmails(
String? accountId, String? accountId,
String? mailboxPath, { String? mailboxPath, {
int? limit = 50, int? limit = 50,
@@ -228,11 +266,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
{#limit: limit}, {#limit: limit},
), ),
returnValue: _i4.Stream<List<_i2.Email>>.empty(), returnValue: _i5.Stream<List<_i3.Email>>.empty(),
) as _i4.Stream<List<_i2.Email>>); ) as _i5.Stream<List<_i3.Email>>);
@override @override
_i4.Stream<List<_i2.EmailThread>> observeThreads( _i5.Stream<List<_i3.EmailThread>> observeThreads(
String? accountId, String? accountId,
String? mailboxPath, { String? mailboxPath, {
int? limit = 50, int? limit = 50,
@@ -246,11 +284,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
{#limit: limit}, {#limit: limit},
), ),
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(), returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
) as _i4.Stream<List<_i2.EmailThread>>); ) as _i5.Stream<List<_i3.EmailThread>>);
@override @override
_i4.Stream<List<_i2.Email>> observeEmailsInThread( _i5.Stream<List<_i3.Email>> observeEmailsInThread(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath,
String? threadId, String? threadId,
@@ -264,36 +302,36 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
threadId, threadId,
], ],
), ),
returnValue: _i4.Stream<List<_i2.Email>>.empty(), returnValue: _i5.Stream<List<_i3.Email>>.empty(),
) as _i4.Stream<List<_i2.Email>>); ) as _i5.Stream<List<_i3.Email>>);
@override @override
_i4.Future<_i2.Email?> getEmail(String? emailId) => (super.noSuchMethod( _i5.Future<_i3.Email?> getEmail(String? emailId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#getEmail, #getEmail,
[emailId], [emailId],
), ),
returnValue: _i4.Future<_i2.Email?>.value(), returnValue: _i5.Future<_i3.Email?>.value(),
) as _i4.Future<_i2.Email?>); ) as _i5.Future<_i3.Email?>);
@override @override
_i4.Future<_i2.EmailBody> getEmailBody(String? emailId) => _i5.Future<_i3.EmailBody> getEmailBody(String? emailId) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
#getEmailBody, #getEmailBody,
[emailId], [emailId],
), ),
returnValue: _i4.Future<_i2.EmailBody>.value(_FakeEmailBody_0( returnValue: _i5.Future<_i3.EmailBody>.value(_FakeEmailBody_1(
this, this,
Invocation.method( Invocation.method(
#getEmailBody, #getEmailBody,
[emailId], [emailId],
), ),
)), )),
) as _i4.Future<_i2.EmailBody>); ) as _i5.Future<_i3.EmailBody>);
@override @override
_i4.Future<_i2.SyncEmailsResult> syncEmails( _i5.Future<_i3.SyncEmailsResult> syncEmails(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath,
) => ) =>
@@ -306,7 +344,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
), ),
returnValue: returnValue:
_i4.Future<_i2.SyncEmailsResult>.value(_FakeSyncEmailsResult_1( _i5.Future<_i3.SyncEmailsResult>.value(_FakeSyncEmailsResult_2(
this, this,
Invocation.method( Invocation.method(
#syncEmails, #syncEmails,
@@ -316,10 +354,10 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
), ),
)), )),
) as _i4.Future<_i2.SyncEmailsResult>); ) as _i5.Future<_i3.SyncEmailsResult>);
@override @override
_i4.Future<void> setFlag( _i5.Future<void> setFlag(
String? emailId, { String? emailId, {
bool? seen, bool? seen,
bool? flagged, bool? flagged,
@@ -333,12 +371,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
#flagged: flagged, #flagged: flagged,
}, },
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<void> markAllAsRead( _i5.Future<void> markAllAsRead(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath,
) => ) =>
@@ -350,12 +388,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
mailboxPath, mailboxPath,
], ],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<void> moveEmail( _i5.Future<void> moveEmail(
String? emailId, String? emailId,
String? destMailboxPath, String? destMailboxPath,
) => ) =>
@@ -367,23 +405,23 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
destMailboxPath, destMailboxPath,
], ],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod( _i5.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#deleteEmail, #deleteEmail,
[emailId], [emailId],
), ),
returnValue: _i4.Future<String?>.value(), returnValue: _i5.Future<String?>.value(),
) as _i4.Future<String?>); ) as _i5.Future<String?>);
@override @override
_i4.Future<void> sendEmail( _i5.Future<void> sendEmail(
String? accountId, String? accountId,
_i2.EmailDraft? draft, _i3.EmailDraft? draft,
) => ) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
@@ -393,14 +431,14 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
draft, draft,
], ],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<String> downloadAttachment( _i5.Future<String> downloadAttachment(
String? emailId, String? emailId,
_i2.EmailAttachment? attachment, _i3.EmailAttachment? attachment,
) => ) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
@@ -410,7 +448,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
attachment, attachment,
], ],
), ),
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>( returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
this, this,
Invocation.method( Invocation.method(
#downloadAttachment, #downloadAttachment,
@@ -420,25 +458,25 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
), ),
)), )),
) as _i4.Future<String>); ) as _i5.Future<String>);
@override @override
_i4.Future<String> fetchRawRfc822(String? emailId) => (super.noSuchMethod( _i5.Future<String> fetchRawRfc822(String? emailId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#fetchRawRfc822, #fetchRawRfc822,
[emailId], [emailId],
), ),
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>( returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
this, this,
Invocation.method( Invocation.method(
#fetchRawRfc822, #fetchRawRfc822,
[emailId], [emailId],
), ),
)), )),
) as _i4.Future<String>); ) as _i5.Future<String>);
@override @override
_i4.Future<List<_i2.Email>> searchEmails( _i5.Future<List<_i3.Email>> searchEmails(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath,
String? query, String? query,
@@ -452,11 +490,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
query, query,
], ],
), ),
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]), returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i4.Future<List<_i2.Email>>); ) as _i5.Future<List<_i3.Email>>);
@override @override
_i4.Future<List<_i2.Email>> searchEmailsGlobal( _i5.Future<List<_i3.Email>> searchEmailsGlobal(
String? accountId, String? accountId,
String? query, String? query,
) => ) =>
@@ -468,11 +506,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
query, query,
], ],
), ),
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]), returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i4.Future<List<_i2.Email>>); ) as _i5.Future<List<_i3.Email>>);
@override @override
_i4.Future<List<_i2.Email>> getEmailsByAddress( _i5.Future<List<_i3.Email>> getEmailsByAddress(
String? accountId, String? accountId,
String? address, String? address,
) => ) =>
@@ -484,11 +522,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
address, address,
], ],
), ),
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]), returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i4.Future<List<_i2.Email>>); ) as _i5.Future<List<_i3.Email>>);
@override @override
_i4.Future<List<_i2.EmailAddress>> searchAddresses( _i5.Future<List<_i3.EmailAddress>> searchAddresses(
String? accountId, String? accountId,
String? query, { String? query, {
int? limit = 10, int? limit = 10,
@@ -503,11 +541,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
{#limit: limit}, {#limit: limit},
), ),
returnValue: returnValue:
_i4.Future<List<_i2.EmailAddress>>.value(<_i2.EmailAddress>[]), _i5.Future<List<_i3.EmailAddress>>.value(<_i3.EmailAddress>[]),
) as _i4.Future<List<_i2.EmailAddress>>); ) as _i5.Future<List<_i3.EmailAddress>>);
@override @override
_i4.Future<int> flushPendingChanges( _i5.Future<int> flushPendingChanges(
String? accountId, String? accountId,
String? password, String? password,
) => ) =>
@@ -519,42 +557,42 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
password, password,
], ],
), ),
returnValue: _i4.Future<int>.value(0), returnValue: _i5.Future<int>.value(0),
) as _i4.Future<int>); ) as _i5.Future<int>);
@override @override
_i4.Stream<List<_i2.FailedMutation>> observeFailedMutations( _i5.Stream<List<_i3.FailedMutation>> observeFailedMutations(
String? accountId) => String? accountId) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
#observeFailedMutations, #observeFailedMutations,
[accountId], [accountId],
), ),
returnValue: _i4.Stream<List<_i2.FailedMutation>>.empty(), returnValue: _i5.Stream<List<_i3.FailedMutation>>.empty(),
) as _i4.Stream<List<_i2.FailedMutation>>); ) as _i5.Stream<List<_i3.FailedMutation>>);
@override @override
_i4.Future<void> discardMutation(int? id) => (super.noSuchMethod( _i5.Future<void> discardMutation(int? id) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#discardMutation, #discardMutation,
[id], [id],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<void> retryMutation(int? id) => (super.noSuchMethod( _i5.Future<void> retryMutation(int? id) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#retryMutation, #retryMutation,
[id], [id],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<bool> cancelPendingChange( _i5.Future<bool> cancelPendingChange(
String? emailId, String? emailId,
String? changeType, String? changeType,
) => ) =>
@@ -566,11 +604,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
changeType, changeType,
], ],
), ),
returnValue: _i4.Future<bool>.value(false), returnValue: _i5.Future<bool>.value(false),
) as _i4.Future<bool>); ) as _i5.Future<bool>);
@override @override
_i4.Future<void> snoozeEmail( _i5.Future<void> snoozeEmail(
String? emailId, String? emailId,
DateTime? until, DateTime? until,
) => ) =>
@@ -582,32 +620,32 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
until, until,
], ],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod( _i5.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#wakeUpEmails, #wakeUpEmails,
[accountId], [accountId],
), ),
returnValue: _i4.Future<int>.value(0), returnValue: _i5.Future<int>.value(0),
) as _i4.Future<int>); ) as _i5.Future<int>);
@override @override
_i4.Future<void> restoreEmails(List<_i2.Email>? emails) => _i5.Future<void> restoreEmails(List<_i3.Email>? emails) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
#restoreEmails, #restoreEmails,
[emails], [emails],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<_i2.Email?> findEmailByMessageId( _i5.Future<_i3.Email?> findEmailByMessageId(
String? accountId, String? accountId,
String? messageId, String? messageId,
) => ) =>
@@ -619,20 +657,20 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
messageId, messageId,
], ],
), ),
returnValue: _i4.Future<_i2.Email?>.value(), returnValue: _i5.Future<_i3.Email?>.value(),
) as _i4.Future<_i2.Email?>); ) as _i5.Future<_i3.Email?>);
@override @override
_i4.Future<int> applySieveRules(String? accountId) => (super.noSuchMethod( _i5.Future<int> applySieveRules(String? accountId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#applySieveRules, #applySieveRules,
[accountId], [accountId],
), ),
returnValue: _i4.Future<int>.value(0), returnValue: _i5.Future<int>.value(0),
) as _i4.Future<int>); ) as _i5.Future<int>);
@override @override
_i4.Stream<void> watchJmapPush( _i5.Stream<void> watchJmapPush(
String? accountId, String? accountId,
String? password, String? password,
) => ) =>
@@ -644,11 +682,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
password, password,
], ],
), ),
returnValue: _i4.Stream<void>.empty(), returnValue: _i5.Stream<void>.empty(),
) as _i4.Stream<void>); ) as _i5.Stream<void>);
@override @override
_i4.Future<_i2.ReliabilityResult> verifySyncReliability( _i5.Future<_i3.ReliabilityResult> verifySyncReliability(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath,
) => ) =>
@@ -661,7 +699,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
), ),
returnValue: returnValue:
_i4.Future<_i2.ReliabilityResult>.value(_FakeReliabilityResult_2( _i5.Future<_i3.ReliabilityResult>.value(_FakeReliabilityResult_3(
this, this,
Invocation.method( Invocation.method(
#verifySyncReliability, #verifySyncReliability,
@@ -671,15 +709,15 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
), ),
)), )),
) as _i4.Future<_i2.ReliabilityResult>); ) as _i5.Future<_i3.ReliabilityResult>);
@override @override
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod( _i5.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#clearForResync, #clearForResync,
[accountId], [accountId],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
} }
+173
View File
@@ -14,6 +14,7 @@ import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
import 'account_repository_impl_test.dart' show MapSecureStorage; import 'account_repository_impl_test.dart' show MapSecureStorage;
import 'db_test_helper.dart'; import 'db_test_helper.dart';
import 'fake_imap.dart' show SnoozeSpyImapClient;
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
const _account = Account( const _account = Account(
@@ -432,5 +433,177 @@ void main() {
expect(result, isNotNull); expect(result, isNotNull);
expect(result!.role, 'inbox'); expect(result!.role, 'inbox');
}); });
group('createMailboxWithRole', () {
test('IMAP: creates mailbox on server and persists with role', () async {
final spy = SnoozeSpyImapClient();
final db = openTestDatabase();
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
final mailboxes = MailboxRepositoryImpl(
db,
accounts,
imapConnect: (_, __, ___) async => spy,
);
await accounts.addAccount(_account, 'pw');
final result = await mailboxes.createMailboxWithRole(
'acc-1',
'Archive',
'archive',
);
expect(spy.createdMailbox, 'Archive');
expect(result.name, 'Archive');
expect(result.role, 'archive');
expect(result.path, 'Archive');
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
expect(found, isNotNull);
expect(found!.name, 'Archive');
});
test('JMAP: creates mailbox on server and persists with role', () async {
final r = _makeRepos(
httpClient: _mockJmap(
apiResponses: [
{
'sessionState': 'sess1',
'methodResponses': [
[
'Mailbox/set',
{
'accountId': 'acct1',
'created': {
'new-mailbox': {'id': 'mbx-archive'},
},
},
'0',
],
],
},
],
),
);
await r.accounts.addAccount(_jmapAccount, 'pw');
final result = await r.mailboxes
.createMailboxWithRole('jmap-1', 'Archive', 'archive');
expect(result.name, 'Archive');
expect(result.role, 'archive');
expect(result.path, 'mbx-archive');
final found = await r.mailboxes.findMailboxByRole('jmap-1', 'archive');
expect(found, isNotNull);
expect(found!.name, 'Archive');
});
test(
'JMAP: throws when server returns no created ID',
() async {
final r = _makeRepos(
httpClient: _mockJmap(
apiResponses: [
{
'sessionState': 'sess1',
'methodResponses': [
[
'Mailbox/set',
{
'accountId': 'acct1',
'created': null,
'notCreated': {
'new-mailbox': {'type': 'serverFail'},
},
},
'0',
],
],
},
],
),
);
await r.accounts.addAccount(_jmapAccount, 'pw');
await expectLater(
r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'),
throwsA(isA<Exception>()),
);
},
);
});
group('syncMailboxes IMAP preserves manually-set role', () {
test('existing role is kept when server returns no special-use flag',
() async {
final spy = SnoozeSpyImapClient();
// Make listMailboxes return a plain folder without \Archive.
final db = openTestDatabase();
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
// Override listMailboxes to return one plain folder.
final fakeClient = _PlainArchiveImapClient();
final mailboxes = MailboxRepositoryImpl(
db,
accounts,
imapConnect: (_, __, ___) async => fakeClient,
);
await accounts.addAccount(_account, 'pw');
// Pre-seed the DB with role='archive' (as if user created the folder).
await db.into(db.mailboxes).insert(
MailboxesCompanion.insert(
id: 'acc-1:Archive',
accountId: 'acc-1',
path: 'Archive',
name: 'Archive',
role: const Value('archive'),
),
);
await mailboxes.syncMailboxes('acc-1');
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
expect(
found,
isNotNull,
reason: 'Manually-set role should be preserved after sync',
);
expect(found!.path, 'Archive');
// Suppress unused warning on spy.
expect(spy, isNotNull);
});
});
}); });
} }
/// Fake IMAP client that lists one mailbox named 'Archive' without any
/// special-use flags, and logs out cleanly.
class _PlainArchiveImapClient extends SnoozeSpyImapClient {
@override
Future<List<imap.Mailbox>> listMailboxes({
String path = '""',
bool recursive = false,
List<String>? mailboxPatterns,
List<String>? selectionOptions,
List<imap.ReturnOption>? returnOptions,
}) async =>
[
imap.Mailbox(
encodedName: 'Archive',
encodedPath: 'Archive',
pathSeparator: '/',
flags: [], // No \Archive special-use flag
),
];
@override
Future<imap.Mailbox> statusMailbox(
imap.Mailbox mailbox,
List<imap.StatusFlags> flags,
) async =>
mailbox;
@override
Future<dynamic> logout() async {}
}
+45 -2
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () { group('Migration', () {
test('schemaVersion matches expected value', () async { test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory()); final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 32); expect(db.schemaVersion, 36);
await db.close(); await db.close();
}); });
@@ -194,6 +194,21 @@ void main() {
// v32: local_sieve_applied table. // v32: local_sieve_applied table.
await db.customSelect('SELECT count(*) FROM local_sieve_applied').get(); 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'));
// v34: user_preferences table.
await db.customSelect('SELECT count(*) FROM user_preferences').get();
// v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close(); await db.close();
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
}); });
@@ -381,11 +396,26 @@ void main() {
await _tableColumns(db, 'sync_log_mailboxes'); await _tableColumns(db, 'sync_log_mailboxes');
expect(syncLogMailboxColumns, contains('duration_ms')); 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'));
// v34: user_preferences table.
await db.customSelect('SELECT count(*) FROM user_preferences').get();
// v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close(); await db.close();
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
}); });
test('fresh install creates all tables at schemaVersion 32', () async { test('fresh install creates all tables at schemaVersion 36', () async {
final db = AppDatabase(NativeDatabase.memory()); final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get(); await db.select(db.accounts).get();
@@ -412,6 +442,7 @@ void main() {
'local_sieve_scripts', // v29 'local_sieve_scripts', // v29
'share_keys', // v31 'share_keys', // v31
'local_sieve_applied', // v32 'local_sieve_applied', // v32
'user_preferences', // v34
]), ]),
); );
@@ -426,6 +457,18 @@ void main() {
await _tableColumns(db, 'sync_log_mailboxes'); await _tableColumns(db, 'sync_log_mailboxes');
expect(syncLogMailboxColumns, contains('duration_ms')); 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'));
// v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close(); await db.close();
}); });
}); });
@@ -62,6 +62,21 @@ class _FakeMailboxes implements MailboxRepository {
null; null;
@override @override
Future<void> clearForResync(String accountId) async {} Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
} }
class _FakeEmails implements EmailRepository { class _FakeEmails implements EmailRepository {
+17
View File
@@ -54,6 +54,21 @@ class _FakeMailboxes implements MailboxRepository {
null; null;
@override @override
Future<void> clearForResync(String accountId) async {} Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
} }
class _CountingEmails implements EmailRepository { class _CountingEmails implements EmailRepository {
@@ -170,6 +185,8 @@ class _FakeSyncLog implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -126,4 +126,34 @@ void main() {
expect(rows.first.result, 'error'); expect(rows.first.result, 'error');
expect(rows.first.errorMessage, 'Connection refused'); 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');
});
} }
+42
View File
@@ -27,6 +27,22 @@ class MockUrlLauncher extends Mock
} }
} }
class ThrowingUrlLauncher extends Mock
with MockPlatformInterfaceMixin
implements UrlLauncherPlatform {
@override
Future<bool> canLaunch(String? url) async => true;
@override
Future<bool> launchUrl(String? url, LaunchOptions? options) async {
throw PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: '
'"dev.flutter.pigeon.url_launcher_android.UrlLauncherApi.launchUrl".',
);
}
}
Widget _buildScreen({List<Account> accounts = const []}) { Widget _buildScreen({List<Account> accounts = const []}) {
return ProviderScope( return ProviderScope(
overrides: [ overrides: [
@@ -64,6 +80,9 @@ void main() {
expect(find.textContaining('Dark Mode'), findsWidgets); expect(find.textContaining('Dark Mode'), findsWidgets);
expect(find.textContaining('IMAP Accounts'), findsWidgets); expect(find.textContaining('IMAP Accounts'), findsWidgets);
expect(find.textContaining('JMAP Accounts'), findsWidgets); expect(find.textContaining('JMAP Accounts'), findsWidgets);
expect(find.textContaining('Locale'), findsWidgets);
expect(find.textContaining('Text Scale'), findsWidgets);
expect(find.textContaining('DB Schema Version'), findsWidgets);
// Buttons are in the body, not in the AppBar actions // Buttons are in the body, not in the AppBar actions
expect(find.byIcon(Icons.copy), findsOneWidget); expect(find.byIcon(Icons.copy), findsOneWidget);
expect(find.byIcon(Icons.bug_report), findsOneWidget); expect(find.byIcon(Icons.bug_report), findsOneWidget);
@@ -151,6 +170,9 @@ void main() {
expect(clipboardText, contains('Dark Mode')); expect(clipboardText, contains('Dark Mode'));
expect(clipboardText, contains('IMAP Accounts')); expect(clipboardText, contains('IMAP Accounts'));
expect(clipboardText, contains('JMAP Accounts')); expect(clipboardText, contains('JMAP Accounts'));
expect(clipboardText, contains('Locale'));
expect(clipboardText, contains('Text Scale'));
expect(clipboardText, contains('DB Schema Version'));
expect( expect(
clipboardText, clipboardText,
contains('[sharedinbox.de](https://sharedinbox.de)'), contains('[sharedinbox.de](https://sharedinbox.de)'),
@@ -180,4 +202,24 @@ void main() {
); );
expect(mock.launchedUrl, contains('1.2.3%2B99')); expect(mock.launchedUrl, contains('1.2.3%2B99'));
}); });
testWidgets(
'AboutScreen link tap with failed url_launcher shows error snackbar',
(tester) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
UrlLauncherPlatform.instance = ThrowingUrlLauncher();
await tester.pumpWidget(_buildScreen());
await tester.pumpAndSettle();
await tester.tap(find.textContaining('sharedinbox.de').first);
await tester.pumpAndSettle();
expect(find.textContaining('Error:'), findsOneWidget);
},
);
} }
+100 -2
View File
@@ -23,7 +23,7 @@ void main() {
expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget); expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget);
}); });
testWidgets('shows 20-minute expiry hint', (tester) async { testWidgets('shows expiry countdown hint', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/receive', initialLocation: '/accounts/receive',
@@ -32,8 +32,106 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.textContaining('20 minutes'), findsOneWidget); expect(find.textContaining('expires in'), findsOneWidget);
}); });
testWidgets(
'step 2 button shows text-input fallback on platforms without camera',
(tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/receive',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
await tester.pumpAndSettle();
// On Linux (desktop, no camera) the text fallback field must appear.
expect(find.byKey(const Key('encryptedCodeField')), findsOneWidget);
},
);
testWidgets(
'step 2 — valid encrypted QR imports account via text fallback',
(tester) async {
// Pre-generate a key pair so we can encrypt a QR code with the same
// material the screen will use for decryption.
final material = await ShareEncryptionService.generateKeyPair();
final repo = FakeShareKeyRepository(material: material);
const account = Account(
id: 'src-1',
displayName: 'Alice',
email: 'alice@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
);
final encryptedQr = await ShareEncryptionService.encryptAccounts(
recipientKeyId: material.keyId,
recipientPublicKeyBytes: material.publicKeyBytes,
accounts: [
AccountPayload(
accountJson: account.toJson(),
password: 'secret',
),
],
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/receive',
overrides: baseOverrides(shareKeyRepository: repo),
),
);
await tester.pumpAndSettle(); // key generation completes
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('encryptedCodeField')),
encryptedQr,
);
await tester.tap(find.text('Import'));
await tester.pumpAndSettle();
expect(
find.text('Imported 1 account successfully.'),
findsOneWidget,
);
},
);
testWidgets(
'step 2 — invalid encrypted QR shows error and returns to pub-key step',
(tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/receive',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('encryptedCodeField')),
'not-a-valid-qr-code',
);
await tester.tap(find.text('Import'));
await tester.pumpAndSettle();
// Screen returns to the pub-key step with an error message visible.
expect(find.byKey(const Key('pubKeyQrCode')), findsOneWidget);
expect(find.textContaining('Import failed:'), findsWidgets);
},
);
}); });
group('AccountSendScreen', () { group('AccountSendScreen', () {
+70
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/data/db/database.dart' show SyncHealthRow;
import 'helpers.dart'; import 'helpers.dart';
@@ -206,5 +207,74 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
expect(find.text('sharedinbox.de'), findsOneWidget); expect(find.text('sharedinbox.de'), findsOneWidget);
}); });
testWidgets('shows Healthy when sync health is healthy', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts',
overrides: baseOverrides(
accounts: [kTestAccount],
syncHealth: SyncHealthRow(
accountId: kTestAccount.id,
lastVerifiedAt: DateTime(2024, 6),
isHealthy: true,
),
),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Healthy'), findsOneWidget);
});
testWidgets(
'shows discrepancy details when sync health has discrepancies',
(tester) async {
const summary =
'{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}';
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts',
overrides: baseOverrides(
accounts: [kTestAccount],
syncHealth: SyncHealthRow(
accountId: kTestAccount.id,
lastVerifiedAt: DateTime(2024, 6),
isHealthy: false,
discrepancySummary: summary,
),
),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('missing locally: 3'), findsOneWidget);
expect(find.textContaining('flag mismatches: 1'), findsOneWidget);
},
);
testWidgets(
'sync health row is positioned below the account name row',
(tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts',
overrides: baseOverrides(
accounts: [kTestAccount],
syncHealth: SyncHealthRow(
accountId: kTestAccount.id,
lastVerifiedAt: DateTime(2024, 6),
isHealthy: true,
),
),
),
);
await tester.pumpAndSettle();
final namePos = tester.getTopLeft(find.text('Alice')).dy;
final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy;
expect(healthPos, greaterThan(namePos));
},
);
}); });
} }
+54
View File
@@ -0,0 +1,54 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
class _FakeAssetBundle extends CachingAssetBundle {
final Map<String, String> _assets;
_FakeAssetBundle(this._assets);
@override
Future<ByteData> load(String key) async {
if (_assets.containsKey(key)) {
final encoded = utf8.encode(_assets[key]!);
return ByteData.view(Uint8List.fromList(encoded).buffer);
}
throw FlutterError('Asset not found: "$key"');
}
}
const _fakeChangelog =
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
void main() {
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
await tester.pumpWidget(
DefaultAssetBundle(
bundle: _FakeAssetBundle({'assets/changelog.txt': _fakeChangelog}),
child: const MaterialApp(home: ChangeLogScreen()),
),
);
await tester.pumpAndSettle();
expect(find.text('ChangeLog'), findsOneWidget);
expect(find.textContaining('initial release'), findsOneWidget);
expect(find.textContaining('resolve crash'), findsOneWidget);
expect(find.textContaining('Error loading changelog'), findsNothing);
});
testWidgets('ChangeLogScreen shows error when asset is missing', (
tester,
) async {
await tester.pumpWidget(
DefaultAssetBundle(
bundle: _FakeAssetBundle({}),
child: const MaterialApp(home: ChangeLogScreen()),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Error loading changelog'), findsOneWidget);
});
}
+180
View File
@@ -116,13 +116,193 @@ void main() {
expect(clipboardText, isNotNull); expect(clipboardText, isNotNull);
expect(clipboardText, contains('App Version: 1.0.0+42')); expect(clipboardText, contains('App Version: 1.0.0+42'));
expect(clipboardText, contains('Build Mode:'));
expect(clipboardText, contains('Platform:')); expect(clipboardText, contains('Platform:'));
expect(clipboardText, contains('Dart:'));
expect(clipboardText, contains('Timestamp:'));
expect(clipboardText, contains('TestException: clipboard test')); expect(clipboardText, contains('TestException: clipboard test'));
// GIT_HASH is empty in test builds — no Git Commit line expected // GIT_HASH is empty in test builds — no Git Commit line expected
expect(clipboardText, isNot(contains('Git Commit:'))); expect(clipboardText, isNot(contains('Git Commit:')));
}, },
); );
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 version, build mode, and platform in the UI',
(tester) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
const exception = 'TestException: info row test';
final stackTrace = StackTrace.current;
await tester.pumpWidget(
MaterialApp(
home: CrashScreen(exception: exception, stackTrace: stackTrace),
),
);
await tester.pumpAndSettle();
// Info row shows app version (from mock), build mode, and platform OS.
expect(find.textContaining('1.0.0+42'), findsWidgets);
// In test builds kDebugMode is true.
expect(find.textContaining('debug'), findsOneWidget);
// Platform OS is always present (linux in CI, android/ios on device).
expect(
find.textContaining(RegExp(r'linux|android|ios|windows|macos')),
findsWidgets,
);
},
);
testWidgets(
'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( testWidgets(
'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash', 'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash',
(tester) async { (tester) async {
+82
View File
@@ -105,6 +105,88 @@ void main() {
expect(find.text('Edit account'), findsNothing); expect(find.text('Edit account'), findsNothing);
}); });
testWidgets(
'try connection button is disabled when no password stored or entered',
(
tester,
) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides(
accounts: [kTestAccount],
hasStoredPassword: false,
),
),
);
await tester.pumpAndSettle();
final button = tester.widget<OutlinedButton>(
find.byKey(const Key('editTryConnectionButton')),
);
expect(button.onPressed, isNull);
});
testWidgets(
'try connection button is enabled after typing password with no stored password',
(tester) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides(
accounts: [kTestAccount],
hasStoredPassword: false,
),
),
);
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('editPasswordField')),
'mypassword',
);
await tester.pump();
final button = tester.widget<OutlinedButton>(
find.byKey(const Key('editTryConnectionButton')),
);
expect(button.onPressed, isNotNull);
});
testWidgets('save button is disabled when no password stored or entered', (
tester,
) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides(
accounts: [kTestAccount],
hasStoredPassword: false,
),
),
);
await tester.pumpAndSettle();
final button = tester
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Save'));
expect(button.onPressed, isNull);
});
testWidgets('connection error shows error message', (tester) async { testWidgets('connection error shows error message', (tester) async {
tester.view.physicalSize = const Size(800, 1400); tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
+250
View File
@@ -179,6 +179,218 @@ void main() {
expect(find.text('report.pdf'), findsOneWidget); expect(find.text('report.pdf'), findsOneWidget);
}); });
testWidgets('Reply All button is not present in app bar', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Reply all',
),
findsNothing,
);
});
testWidgets('Reply on single-recipient email navigates directly to compose',
(tester) async {
// testEmail has from=[bob], to=[alice]. After removing alice (own),
// only bob remains → no dialog, navigate straight to compose.
final email = testEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [
..._overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
email: email,
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
],
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Reply',
),
);
await tester.pumpAndSettle();
// No dialog shown — straight navigation to compose.
expect(find.text('Reply All'), findsNothing);
});
testWidgets('Reply on multi-recipient email shows Reply All dialog',
(tester) async {
// Email with an extra Cc recipient so the dialog is triggered.
final email = Email(
id: 'acc-1:42',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 42,
subject: 'Hello world',
receivedAt: DateTime(2024, 6),
sentAt: DateTime(2024, 6),
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
to: const [EmailAddress(email: 'alice@example.com')],
cc: const [EmailAddress(name: 'Carol', email: 'carol@example.com')],
isSeen: false,
isFlagged: false,
hasAttachment: false,
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
email: email,
),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Reply',
),
);
await tester.pumpAndSettle();
// Dialog must appear with title 'Reply All'.
expect(find.text('Reply All'), findsOneWidget);
// Both non-own addresses should be listed in the dialog.
expect(find.textContaining('bob@example.com'), findsAtLeastNWidgets(1));
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
});
testWidgets('Mark as spam is in popup menu, not a standalone button',
(tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
// No standalone icon button for mark as spam.
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam',
),
findsNothing,
);
// It appears in the popup menu.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
expect(find.text('Mark as spam'), findsOneWidget);
});
testWidgets('Mark as spam shows dialog when no junk folder',
(tester) async {
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
// returns null → dialog shown.
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
// Open the popup menu first, then tap Mark as spam.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Mark as spam'));
await tester.pumpAndSettle();
expect(find.text('No spam folder found'), findsOneWidget);
});
testWidgets('Archive button is present in app bar', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Archive',
),
findsOneWidget,
);
});
testWidgets('Archive shows dialog when no archive folder', (tester) async {
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
// returns null → dialog shown.
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Archive',
),
);
await tester.pumpAndSettle();
expect(find.text('No archive folder found'), findsOneWidget);
});
testWidgets('Mark as unread is in popup menu, not a standalone button',
(tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
// No standalone icon button for mark as unread.
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as unread',
),
findsNothing,
);
// It appears in the popup menu.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
expect(find.text('Mark as unread'), findsOneWidget);
});
testWidgets('Show Raw Email dialog shows size of email', (tester) async { testWidgets('Show Raw Email dialog shows size of email', (tester) async {
// 'A' * 2048 → fmtSize(2048) == '2.0 KB' // 'A' * 2048 → fmtSize(2048) == '2.0 KB'
final rawContent = 'A' * 2048; final rawContent = 'A' * 2048;
@@ -271,6 +483,44 @@ void main() {
expect(find.text('Share'), findsOneWidget); expect(find.text('Share'), findsOneWidget);
}); });
testWidgets(
'long-press on unsubscribe chip shows URL tooltip',
(tester) async {
final email = testEmail(
listUnsubscribeHeader: '<https://example.com/unsubscribe>',
);
await tester.pumpWidget(
buildApp(
initialLocation:
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
email: email,
),
),
);
await tester.pumpAndSettle();
expect(find.text('Unsubscribe'), findsOneWidget);
expect(
find.byWidgetPredicate(
(w) =>
w is Tooltip && w.message == 'https://example.com/unsubscribe',
),
findsOneWidget,
);
await tester.longPress(find.text('Unsubscribe'));
await tester.pumpAndSettle();
expect(
find.text('https://example.com/unsubscribe'),
findsOneWidget,
);
},
);
testWidgets('Show Mail Structure opens dialog with MIME parts', ( testWidgets('Show Mail Structure opens dialog with MIME parts', (
tester, tester,
) async { ) async {
+147 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_detail_screen.dart'; import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
import 'package:sharedinbox/ui/screens/email_list_screen.dart'; import 'package:sharedinbox/ui/screens/email_list_screen.dart';
@@ -315,7 +316,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('INBOX'), findsOneWidget); expect(find.text('INBOX'), findsOneWidget);
expect(find.byType(BottomAppBar), findsNothing); expect(find.byIcon(Icons.close), findsNothing);
}); });
testWidgets('tapping clear icon in search bar clears results', ( testWidgets('tapping clear icon in search bar clears results', (
@@ -631,5 +632,150 @@ void main() {
expect(find.text('This is the preview text'), findsOneWidget); expect(find.text('This is the preview text'), findsOneWidget);
}); });
group('archive with missing folder', () {
testWidgets('shows dialog when archive folder is not found', (
tester,
) async {
final email = testEmail(subject: 'To archive');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
// No archive folder in the repo.
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
// Enter selection mode and tap archive.
await tester.longPress(find.text('To archive'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.archive));
await tester.pumpAndSettle();
expect(find.text('No archive folder found'), findsOneWidget);
expect(find.text('Choose existing folder'), findsOneWidget);
expect(find.text('Create "Archive"'), findsOneWidget);
});
testWidgets('tapping Create creates the folder and moves emails', (
tester,
) async {
final email = testEmail(subject: 'To archive');
final movedTo = <String>[];
final fakeEmailRepo = _SpyEmailRepository(
emails: [email],
onMove: (id, path) => movedTo.add(path),
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(fakeEmailRepo),
],
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('To archive'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.archive));
await tester.pumpAndSettle();
// Tap "Create Archive".
await tester.tap(find.text('Create "Archive"'));
await tester.pumpAndSettle();
expect(movedTo, contains('Archive'));
});
testWidgets(
'tapping Choose existing opens folder picker and moves emails',
(tester) async {
final email = testEmail(subject: 'To archive');
final movedTo = <String>[];
final fakeEmailRepo = _SpyEmailRepository(
emails: [email],
onMove: (id, path) => movedTo.add(path),
);
const archiveFolder = Mailbox(
id: 'acc-1:OldArchive',
accountId: 'acc-1',
path: 'OldArchive',
name: 'OldArchive',
unreadCount: 0,
totalCount: 0,
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
// Repo has a folder but it has no 'archive' role.
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([archiveFolder]),
),
emailRepositoryProvider.overrideWithValue(fakeEmailRepo),
],
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('To archive'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.archive));
await tester.pumpAndSettle();
// Tap "Choose existing folder".
await tester.tap(find.text('Choose existing folder'));
await tester.pumpAndSettle();
// Bottom sheet with folder list appears.
expect(find.text('OldArchive'), findsOneWidget);
await tester.tap(find.text('OldArchive'));
await tester.pumpAndSettle();
expect(movedTo, contains('OldArchive'));
},
);
});
}); });
} }
/// Email repository spy that records [moveEmail] calls.
class _SpyEmailRepository extends FakeEmailRepository {
_SpyEmailRepository({
super.emails,
required void Function(String emailId, String path) onMove,
}) : _onMove = onMove;
final void Function(String emailId, String path) _onMove;
@override
Future<void> moveEmail(String emailId, String destMailboxPath) async {
_onMove(emailId, destMailboxPath);
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+99 -13
View File
@@ -14,6 +14,7 @@ import 'package:sharedinbox/core/models/discovery_result.dart';
import 'package:sharedinbox/core/models/draft.dart'; import 'package:sharedinbox/core/models/draft.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart'; import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
@@ -21,10 +22,12 @@ import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart'; import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart'; import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart'; import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_service.dart'; import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
import 'package:sharedinbox/core/services/share_encryption_service.dart'; import 'package:sharedinbox/core/services/share_encryption_service.dart';
import 'package:sharedinbox/data/db/database.dart' show SyncHealthRow;
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/account_list_screen.dart'; import 'package:sharedinbox/ui/screens/account_list_screen.dart';
import 'package:sharedinbox/ui/screens/account_receive_screen.dart'; import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
@@ -38,17 +41,19 @@ import 'package:sharedinbox/ui/screens/email_list_screen.dart';
import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart'; import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart';
import 'package:sharedinbox/ui/screens/search_screen.dart'; import 'package:sharedinbox/ui/screens/search_screen.dart';
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart'; import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Fake repositories // Fake repositories
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
class FakeAccountRepository implements AccountRepository { class FakeAccountRepository implements AccountRepository {
final List<Account> _accounts;
FakeAccountRepository([List<Account>? accounts]) FakeAccountRepository([List<Account>? accounts])
: _accounts = List.of(accounts ?? []); : _accounts = List.of(accounts ?? []);
final List<Account> _accounts;
bool hasPassword = true;
@override @override
Stream<List<Account>> observeAccounts() => Stream.value(List.of(_accounts)); Stream<List<Account>> observeAccounts() => Stream.value(List.of(_accounts));
@@ -75,15 +80,22 @@ class FakeAccountRepository implements AccountRepository {
_accounts.removeWhere((a) => a.id == id); _accounts.removeWhere((a) => a.id == id);
@override @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 { class FakeShareKeyRepository implements ShareKeyRepository {
FakeShareKeyRepository({ShareKeyMaterial? material}) : _material = material;
ShareKeyMaterial? _material; ShareKeyMaterial? _material;
@override @override
Future<ShareKeyMaterial> createKeyPair() async { Future<ShareKeyMaterial> createKeyPair() async {
_material = await ShareEncryptionService.generateKeyPair(); _material ??= await ShareEncryptionService.generateKeyPair();
return _material!; return _material!;
} }
@@ -156,8 +168,28 @@ class FakeMailboxRepository implements MailboxRepository {
@override @override
Future<Mailbox?> findMailboxByRole(String accountId, String role) async => Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
_mailboxes.where((m) => m.role == role).firstOrNull; _mailboxes.where((m) => m.role == role).firstOrNull;
@override @override
Future<void> clearForResync(String accountId) async {} Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async {
final mailbox = Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
_mailboxes.add(mailbox);
return mailbox;
}
} }
class FakeEmailRepository implements EmailRepository { class FakeEmailRepository implements EmailRepository {
@@ -382,6 +414,7 @@ class _NoOpManageSieveProbeService implements ManageSieveProbeService {
Widget buildApp({ Widget buildApp({
required String initialLocation, required String initialLocation,
required List<Override> overrides, required List<Override> overrides,
UserPreferencesRepository? userPreferences,
}) { }) {
final testRouter = GoRouter( final testRouter = GoRouter(
initialLocation: initialLocation, initialLocation: initialLocation,
@@ -402,6 +435,10 @@ Widget buildApp({
path: 'send', path: 'send',
builder: (ctx, state) => const AccountSendScreen(), builder: (ctx, state) => const AccountSendScreen(),
), ),
GoRoute(
path: 'preferences',
builder: (ctx, state) => const UserPreferencesScreen(),
),
GoRoute( GoRoute(
path: ':accountId/edit', path: ':accountId/edit',
builder: (ctx, state) => EditAccountScreen( builder: (ctx, state) => EditAccountScreen(
@@ -477,16 +514,18 @@ Widget buildApp({
return ProviderScope( return ProviderScope(
// Defaults come first so tests can override them via [overrides]. // Defaults come first so tests can override them via [overrides].
// //
// syncHealthProvider and syncLogRepositoryProvider are backed by Drift // syncLogRepositoryProvider is backed by a Drift StreamQuery. When the
// StreamQueries. When a StreamProvider that wraps a Drift query is disposed, // provider is disposed, Drift schedules a Timer.run() for cache
// Drift schedules a Timer.run() for cache debouncing. Flutter's test // debouncing. Flutter's test framework then fails the test with "A Timer
// framework then fails the test with "A Timer is still pending". Replacing // is still pending". Replacing it with a synchronous stream avoids this.
// these with simple synchronous streams avoids the pending-timer assertion. // syncHealthProvider has the same issue and is overridden in baseOverrides.
overrides: [ overrides: [
syncHealthProvider.overrideWith((ref, _) => Stream.value(null)),
syncLogRepositoryProvider.overrideWithValue( syncLogRepositoryProvider.overrideWithValue(
const NoOpSyncLogRepository(), const NoOpSyncLogRepository(),
), ),
userPreferencesRepositoryProvider.overrideWithValue(
userPreferences ?? FakeUserPreferencesRepository(),
),
...overrides, ...overrides,
manageSieveProbeServiceProvider.overrideWith( manageSieveProbeServiceProvider.overrideWith(
(ref) => _NoOpManageSieveProbeService(), (ref) => _NoOpManageSieveProbeService(),
@@ -511,10 +550,14 @@ List<Override> baseOverrides({
List<Mailbox>? mailboxes, List<Mailbox>? mailboxes,
DiscoveryResult? discovery, DiscoveryResult? discovery,
Exception? connectionError, Exception? connectionError,
ShareKeyRepository? shareKeyRepository,
bool hasStoredPassword = true,
SyncHealthRow? syncHealth,
}) => }) =>
[ [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository(accounts)), FakeAccountRepository(accounts)..hasPassword = hasStoredPassword,
),
mailboxRepositoryProvider mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository(mailboxes)), .overrideWithValue(FakeMailboxRepository(mailboxes)),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
@@ -525,7 +568,12 @@ List<Override> baseOverrides({
connectionTestServiceProvider.overrideWithValue( connectionTestServiceProvider.overrideWithValue(
FakeConnectionTestService(error: connectionError), FakeConnectionTestService(error: connectionError),
), ),
shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()), shareKeyRepositoryProvider.overrideWithValue(
shareKeyRepository ?? FakeShareKeyRepository(),
),
// syncHealthProvider is backed by a Drift StreamQuery; override with a
// plain stream to avoid "A Timer is still pending" in tests.
syncHealthProvider.overrideWith((ref, _) => Stream.value(syncHealth)),
]; ];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -555,6 +603,7 @@ Email testEmail({
bool isSeen = false, bool isSeen = false,
bool isFlagged = false, bool isFlagged = false,
bool hasAttachment = false, bool hasAttachment = false,
String? listUnsubscribeHeader,
}) => }) =>
Email( Email(
id: id, id: id,
@@ -570,8 +619,45 @@ Email testEmail({
isSeen: isSeen, isSeen: isSeen,
isFlagged: isFlagged, isFlagged: isFlagged,
hasAttachment: hasAttachment, hasAttachment: hasAttachment,
listUnsubscribeHeader: listUnsubscribeHeader,
); );
class FakeUserPreferencesRepository implements UserPreferencesRepository {
FakeUserPreferencesRepository({
this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage,
});
MenuPosition menuPosition;
MenuPosition mailViewButtonPosition;
AfterMailViewAction afterMailViewAction;
@override
Stream<UserPreferences> observePreferences() => Stream.value(
UserPreferences(
menuPosition: menuPosition,
mailViewButtonPosition: mailViewButtonPosition,
afterMailViewAction: afterMailViewAction,
),
);
@override
Future<void> updateMenuPosition(MenuPosition position) async {
menuPosition = position;
}
@override
Future<void> updateMailViewButtonPosition(MenuPosition position) async {
mailViewButtonPosition = position;
}
@override
Future<void> updateAfterMailViewAction(AfterMailViewAction action) async {
afterMailViewAction = action;
}
}
class FakeSearchHistoryRepository implements SearchHistoryRepository { class FakeSearchHistoryRepository implements SearchHistoryRepository {
final List<String> _history = []; final List<String> _history = [];
@@ -41,6 +41,20 @@ void main() {
expect(html, contains('https: http: data: blob:')); expect(html, contains('https: http: data: blob:'));
_expectLightMode(html); _expectLightMode(html);
}); });
test('prevents horizontal overflow so wide HTML emails are not cut off',
() {
final html =
buildEmailHtml('<table width="600"><tr><td>x</td></tr></table>');
// Body clips overflow so fixed-width email tables don't escape the viewport.
expect(html, contains('overflow-x: hidden'));
// Tables are forced to full viewport width so fixed pixel widths don't overflow.
expect(html, contains('table { width: 100%'));
// All elements are capped at viewport width via max-width.
expect(html, contains('max-width: 100%'));
// Pre-formatted text wraps instead of stretching the page.
expect(html, contains('white-space: pre-wrap'));
});
}); });
// On Linux (the test host) the widget falls back to plain text extracted via // On Linux (the test host) the widget falls back to plain text extracted via
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'helpers.dart'; import 'helpers.dart';
@@ -142,6 +143,60 @@ void main() {
expect(find.byIcon(Icons.expand_more), findsOneWidget); expect(find.byIcon(Icons.expand_more), findsOneWidget);
}); });
testWidgets('shows bottom app bar with back button by default', (
tester,
) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
expect(find.byType(BottomAppBar), findsOneWidget);
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
});
testWidgets('hides bottom app bar when button position is top', (
tester,
) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
userPreferences: FakeUserPreferencesRepository(
mailViewButtonPosition: MenuPosition.top,
),
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
expect(find.byType(BottomAppBar), findsNothing);
});
testWidgets('flagged email shows star icon', (tester) async { testWidgets('flagged email shows star icon', (tester) async {
final email = _threadEmail(isFlagged: true); final email = _threadEmail(isFlagged: true);
await tester.pumpWidget( await tester.pumpWidget(
@@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
import 'helpers.dart';
void main() {
group('UserPreferencesScreen', () {
testWidgets('shows both menu position options', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
expect(find.text('Menu bar position'), findsOneWidget);
expect(find.text('Bottom (default)'), findsNWidgets(2));
expect(find.text('Top'), findsNWidgets(2));
});
testWidgets('shows single mail view button position section', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
expect(
find.text('Single mail view button position'),
findsOneWidget,
);
});
testWidgets('menu position bottom option is selected by default', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<MenuPosition>);
final menuGroup =
tester.widget<RadioGroup<MenuPosition>>(radioGroups.first);
expect(menuGroup.groupValue, MenuPosition.bottom);
});
testWidgets('mail view button position bottom is selected by default', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<MenuPosition>);
final mailViewGroup =
tester.widget<RadioGroup<MenuPosition>>(radioGroups.last);
expect(mailViewGroup.groupValue, MenuPosition.bottom);
});
testWidgets('tapping Top in menu position section updates the repo', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Top').first);
await tester.pumpAndSettle();
final repo = ProviderScope.containerOf(
tester.element(find.byType(UserPreferencesScreen)),
).read(userPreferencesRepositoryProvider)
as FakeUserPreferencesRepository;
expect(repo.menuPosition, MenuPosition.top);
});
testWidgets(
'tapping Top in mail view button position section updates the repo', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Top').last);
await tester.pumpAndSettle();
final repo = ProviderScope.containerOf(
tester.element(find.byType(UserPreferencesScreen)),
).read(userPreferencesRepositoryProvider)
as FakeUserPreferencesRepository;
expect(repo.mailViewButtonPosition, MenuPosition.top);
});
testWidgets('shows after mail action section', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
// Scroll down to reveal the new section below the fold.
await tester.drag(find.byType(ListView), const Offset(0, -500));
await tester.pumpAndSettle();
expect(find.text('After mail action'), findsOneWidget);
expect(find.text('Next message (default)'), findsOneWidget);
expect(find.text('Return to mailbox'), findsOneWidget);
});
testWidgets('after mail action next message is selected by default', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -500));
await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<AfterMailViewAction>);
final group =
tester.widget<RadioGroup<AfterMailViewAction>>(radioGroups.first);
expect(group.groupValue, AfterMailViewAction.nextMessage);
});
testWidgets('tapping Return to mailbox updates the repo', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -500));
await tester.pumpAndSettle();
await tester.tap(find.text('Return to mailbox'));
await tester.pumpAndSettle();
final repo = ProviderScope.containerOf(
tester.element(find.byType(UserPreferencesScreen)),
).read(userPreferencesRepositoryProvider)
as FakeUserPreferencesRepository;
expect(repo.afterMailViewAction, AfterMailViewAction.showMailbox);
});
});
}
+2 -1
View File
@@ -25,7 +25,8 @@ The app processes the following data **exclusively on your device**:
device's secure storage and never transmitted to us. device's secure storage and never transmitted to us.
- **Email messages and attachments** — fetched directly from your email provider's IMAP server and - **Email messages and attachments** — fetched directly from your email provider's IMAP server and
displayed in the app. We never receive, store, or process your emails. displayed in the app. We never receive, store, or process your emails.
- **App settings and configuration** — stored locally on your device. - **App settings and configuration** — stored locally on your device. The app will never upload
this data to sharedinbox.de or any third-party service.
### Network connections ### Network connections