Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 b1ce093c33 feat: move Reply All into dialog, add Mark as Spam (#260)
- Remove standalone Reply All button from the app bar.
- Add Mark as Spam button before Delete; moves email to the junk
  mailbox (role "junk") and shows a snackbar if no junk folder exists.
- Reply button now collects all addresses from From/To/Cc, filters out
  the account's own address, deduplicates, and:
    • If ≤1 candidate: navigates directly to compose (no dialog).
    • If ≥2 candidates: shows a "Reply All" dialog where each address
      can be placed in To, Cc, or skipped. Addresses from incoming Cc
      default to Cc; From/To addresses default to To.
- Update widget tests: verify Reply All button is absent, dialog
  appears for multi-recipient emails, Mark as Spam button present,
  and snackbar shown when no junk folder is configured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:46:43 +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
29 changed files with 1662 additions and 111 deletions
+4 -4
View File
@@ -38,7 +38,7 @@ jobs:
echo "Changed files:" echo "Changed files:"
echo "$CHANGED" echo "$CHANGED"
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/)' 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" \
@@ -97,7 +97,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 +136,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: |
@@ -178,7 +178,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: |
+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
+33
View File
@@ -0,0 +1,33 @@
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: 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
+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/
+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"
+11 -2
View File
@@ -224,7 +224,7 @@ tasks:
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
cmds: cmds:
- mkdir -p build/app/outputs/bundle/release - mkdir -p build/app/outputs/bundle/release
- 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
@@ -238,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"
@@ -246,7 +247,7 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD" - sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set" msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds: cmds:
- 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
@@ -335,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]
-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.
@@ -0,0 +1,84 @@
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 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);
}
}
}
+72 -15
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{
@@ -583,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")
} }
@@ -598,7 +622,7 @@ func (m *Ci) DeployLinux(
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)
@@ -614,16 +638,27 @@ func (m *Ci) DeployLinux(
// setupKeystore decodes the base64 keystore into the android build container. // setupKeystore decodes the base64 keystore into the android build container.
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container { func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
return m.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")
} }
@@ -639,7 +674,7 @@ func (m *Ci) DeployApk(
keystorePassword *dagger.Secret, keystorePassword *dagger.Secret,
buildNumber string, buildNumber string,
) (string, error) { ) (string, error) {
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber) 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)
@@ -655,8 +690,7 @@ func (m *Ci) DeployApk(
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab. // BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk. // Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory { func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
built := m.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
@@ -715,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")
} }
@@ -789,14 +831,29 @@ 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) {
return dag.Container().
From("renovate/renovate:39").
WithSecretVariable("RENOVATE_TOKEN", renovateToken).
WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
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.
@@ -810,7 +867,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
+25 -4
View File
@@ -95,6 +95,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,
@@ -167,10 +191,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
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,
),
); );
} }
}, },
+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());
+66 -6
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';
@@ -17,20 +18,38 @@ class CrashScreen extends StatelessWidget {
final StackTrace? stackTrace; final StackTrace? stackTrace;
final String gitHash; final String gitHash;
Future<String> _buildReport() async { String get _buildMode {
String version = 'unknown'; if (kDebugMode) return 'debug';
if (kProfileMode) return 'profile';
return 'release';
}
Future<String> _fetchVersion() async {
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 versionDisplay = gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)'
: version;
final gitLine = gitHash.isNotEmpty final gitLine = gitHash.isNotEmpty
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n' ? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
: ''; : '';
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,8 +75,49 @@ class CrashScreen extends StatelessWidget {
style: Theme.of(ctx).textTheme.titleMedium, style: Theme.of(ctx).textTheme.titleMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 4),
FutureBuilder<String>(
future: _fetchVersion(),
builder: (context, snapshot) => Text(
'v${snapshot.data ?? ''}$_buildMode'
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
),
if (gitHash.isNotEmpty) ...[ if (gitHash.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
FutureBuilder<PackageInfo>(
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( GestureDetector(
onTap: () async { onTap: () async {
final url = Uri.parse( final url = Uri.parse(
+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'),
),
], ],
), ),
), ),
+197 -14
View File
@@ -70,16 +70,9 @@ 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(
icon: const Icon(Icons.reply_all),
tooltip: 'Reply all',
onPressed: header == null
? null
: () {
unawaited(_reply(context, header, body, replyAll: true));
}, },
), ),
IconButton( IconButton(
@@ -121,6 +114,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
tooltip: 'Snooze', tooltip: 'Snooze',
onPressed: header == null ? null : () => _snooze(context, header), onPressed: header == null ? null : () => _snooze(context, header),
), ),
IconButton(
icon: const Icon(Icons.report_outlined),
tooltip: 'Mark as spam',
onPressed: header == null
? null
: () {
unawaited(_markAsSpam(context, header));
},
),
IconButton( IconButton(
icon: const Icon(Icons.delete), icon: const Icon(Icons.delete),
tooltip: 'Delete', tooltip: 'Delete',
@@ -303,17 +305,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 +393,38 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
); );
} }
Future<void> _markAsSpam(BuildContext context, Email header) async {
final mailboxRepo = ref.read(mailboxRepositoryProvider);
final junk = await mailboxRepo.findMailboxByRole(header.accountId, 'junk');
if (junk == null) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No Junk folder found')),
);
return;
}
await ref
.read(emailRepositoryProvider)
.moveEmail(widget.emailId, junk.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: junk.path,
),
),
);
if (context.mounted) context.pop();
}
Future<void> _forward( Future<void> _forward(
BuildContext context, BuildContext context,
Email header, Email header,
@@ -670,6 +765,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;
+3 -3
View File
@@ -1117,13 +1117,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:
+4
View File
@@ -89,3 +89,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"
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"labels": ["dependencies"],
"github-actions": {
"fileMatch": ["^\\.forgejo/workflows/[^/]+\\.ya?ml$"]
}
}
+202 -17
View File
@@ -8,21 +8,25 @@ Flow
a. Age > 1 h → kill it, set its issue to State/Question, exit 1 a. Age > 1 h → kill it, set its issue to State/Question, exit 1
b. Age ≤ 1 h → print status, exit 0 (let it keep working) b. Age ≤ 1 h → print status, exit 0 (let it keep working)
2. No agent running → extract pending_issue from state (if any), then check CI 2. No agent running → extract pending_issue from state (if any), then check CI
a. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed a. pending_issue type=="plan" → post resume comment, set State/Planned, exit 0
b. Catch-up: orphaned issue-N-fix PRs with passing CI merge them b. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
c. Main CI running → save pending-ci state, exit 0 c. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
d. Main CI failed → start fix-CI agent (pushes fix to main), exit 0 d. Main CI running → save pending-ci state, exit 0
e. Main CI ok + pending_issue → close the issue, exit 0 (dead code path — e. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
section 2a always returns first) f. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
f. Main CI ok (or no run yet) → find oldest Ready issue, start issue agent, section 2b always returns first)
g. Main CI ok (or no run yet) → find oldest ToPlan issue, start plan agent,
save state, exit 0 save state, exit 0
g. No Ready issues print "nothing to do", exit 0 h. No ToPlan issues → find oldest Ready issue, start issue agent,
save state, exit 0
i. No Ready issues → print "nothing to do", exit 0
Issue agents must NOT close the issue themselves; the loop closes it after CI passes. Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
Plan agents must NOT write any code or create PRs; they only post a plan comment.
State file: ~/.sharedinbox-agent-state.json State file: ~/.sharedinbox-agent-state.json
{ "pid": 12345, "issue": 91, { "pid": 12345, "issue": 91,
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue" } "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:
@@ -38,6 +42,7 @@ import re
import shlex import shlex
import subprocess import subprocess
import sys import sys
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -53,7 +58,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("/", "-")
) )
@@ -63,6 +70,8 @@ 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"}
@@ -145,17 +154,39 @@ def _ready_issues() -> list[dict]:
return ready return ready
def _latest_main_ci_run() -> dict | None: def _to_plan_issues() -> list[dict]:
"""Return the latest CI run on the main branch (excludes PR and schedule runs). """Return open issues with State/ToPlan, Prio/High first, then oldest."""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "issue", "list",
"--repo", REPO, "--state", "open", "--json"],
capture_output=True, text=True, check=True,
)
data = json.loads(result.stdout) if result.stdout.strip() else []
to_plan = [
i for i in data
if any(lbl["name"] == LABEL_TO_PLAN for lbl in i.get("labels", []))
and i.get("user", {}).get("login", "") in ALLOWED_ISSUE_AUTHORS
]
to_plan.sort(key=lambda i: (
0 if any(lbl["name"] == LABEL_PRIO_HIGH for lbl in i.get("labels", [])) else 1,
i["number"],
))
return to_plan
Using the global latest run (limit=1) is wrong: a passing or failing run
on a PR branch could mask the true state of main. We filter to push def _latest_main_ci_run() -> dict | None:
events on the 'main' prettyref so section-3 logic only reacts to main. """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") data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
runs = (data or {}).get("workflow_runs", []) runs = (data or {}).get("workflow_runs", [])
for run in runs: for run in runs:
if run.get("event") == "push" and run.get("prettyref") == "main": if (run.get("event") == "push"
and run.get("prettyref") == "main"
and run.get("workflow_id") == "ci.yml"):
return run return run
return None return None
@@ -235,11 +266,54 @@ def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
return None return None
def _get_issue_labels(issue: int) -> list[str]:
"""Return label names for an issue."""
data = _tea_get(f"repos/{REPO}/issues/{issue}")
if not data:
return []
return [lbl["name"] for lbl in data.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
"""
pr_data = _tea_get(f"repos/{REPO}/pulls/{pr_number}")
mergeable = (pr_data or {}).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 ────────────────────────────────────────────────────────────────
@@ -273,6 +347,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.
@@ -442,12 +522,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()
@@ -502,13 +614,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)}"
_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)
@@ -584,6 +712,13 @@ def _run_loop() -> int:
) )
return 0 return 0
if _find_pr_for_branch(branch): if _find_pr_for_branch(branch):
merge_result = _handle_pr_still_open_after_merge(pr_number, branch, pending_issue)
if merge_result == "rebase-spawned":
return 0
if merge_result == "merged":
_close_issue(pending_issue)
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
return 0
print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.") print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.")
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS]) _set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue( _comment_issue(
@@ -640,6 +775,9 @@ def _run_loop() -> int:
continue continue
if pr_run and pr_run.get("status") == "success": if pr_run and pr_run.get("status") == "success":
if issue_num and LABEL_QUESTION in _get_issue_labels(issue_num):
print(f"Catch-up: PR #{pr_number} — issue #{issue_num} is State/Question, skipping.")
continue
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.") print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
try: try:
_merge_pr(pr_number) _merge_pr(pr_number)
@@ -649,6 +787,16 @@ def _run_loop() -> int:
# Verify the merge actually happened; fgj can exit 0 without merging # Verify the merge actually happened; fgj can exit 0 without merging
# (e.g. branch-protection rules not satisfied). # (e.g. branch-protection rules not satisfied).
if _find_pr_for_branch(branch): if _find_pr_for_branch(branch):
merge_result = _handle_pr_still_open_after_merge(pr_number, branch, issue_num)
if merge_result == "rebase-spawned":
return 0
if merge_result == "merged":
if issue_num:
_close_issue(issue_num)
print(f"Catch-up: merged PR #{pr_number} and closed issue #{issue_num} after retry.")
else:
print(f"Catch-up: merged PR #{pr_number} after retry.")
return 0
print( print(
f"Catch-up: PR #{pr_number} is still open after merge attempt " f"Catch-up: PR #{pr_number} is still open after merge attempt "
"— skipping to avoid infinite retry." "— skipping to avoid infinite retry."
@@ -736,10 +884,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]
@@ -795,10 +977,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()
+214 -12
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
@@ -505,28 +506,40 @@ class TestOutputFormat(unittest.TestCase):
class TestLatestMainCiRun(unittest.TestCase): class TestLatestMainCiRun(unittest.TestCase):
"""_latest_main_ci_run() must return only push-to-main runs, ignoring schedule/deploy workflows.""" """_latest_main_ci_run() must return only ci.yml push-to-main runs."""
def test_skips_schedule_runs_returns_push_to_main(self): def _ci_run(self, run_id, status="success"):
runs = [ return {"event": "push", "prettyref": "main", "workflow_id": "ci.yml",
{"event": "schedule", "prettyref": "main", "status": "success", "id": 1}, "status": status, "id": run_id}
{"event": "push", "prettyref": "main", "status": "success", "id": 2},
] def _deploy_run(self, run_id, status="success"):
return {"event": "push", "prettyref": "main", "workflow_id": "deploy.yml",
"status": status, "id": run_id}
def test_skips_deploy_run_returns_ci_run(self):
# Forgejo reports deploy.yml schedule runs as event=push/prettyref=main;
# must be excluded by workflow_id filter.
runs = [self._deploy_run(1), self._ci_run(2)]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}): with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run() result = agent_loop._latest_main_ci_run()
self.assertIsNotNone(result) self.assertIsNotNone(result)
self.assertEqual(result["id"], 2) self.assertEqual(result["id"], 2)
def test_returns_none_when_only_schedule_runs_exist(self): def test_returns_none_when_only_deploy_runs_exist(self):
runs = [ runs = [self._deploy_run(1)]
{"event": "schedule", "prettyref": "main", "status": "success", "id": 1},
]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}): with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run() result = agent_loop._latest_main_ci_run()
self.assertIsNone(result) self.assertIsNone(result)
def test_returns_push_to_main_run(self): def test_returns_none_when_only_schedule_runs_exist(self):
runs = [{"event": "push", "prettyref": "main", "status": "running", "id": 42}] 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}): with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run() result = agent_loop._latest_main_ci_run()
self.assertIsNotNone(result) self.assertIsNotNone(result)
@@ -732,5 +745,194 @@ class TestRunLoopResumeCommand(unittest.TestCase):
self.assertNotIn("Resume:", output) self.assertNotIn("Resume:", output)
class TestCatchupSkipsQuestionIssues(unittest.TestCase):
"""Catch-up must not retry merging a PR whose issue is already State/Question."""
def _make_pr(self, pr_number=50, branch="issue-10-fix"):
return {"number": pr_number, "head": {"ref": branch}}
def test_skips_merge_when_issue_has_question_label(self):
pr = self._make_pr()
ci_run = {"id": 999, "status": "success"}
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[pr]), \
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._comment_issue") as mock_comment, \
patch("agent_loop._set_labels") as mock_labels, \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_merge.assert_not_called()
mock_comment.assert_not_called()
mock_labels.assert_not_called()
def test_proceeds_with_merge_when_issue_lacks_question_label(self):
pr = self._make_pr()
ci_run = {"id": 999, "status": "success"}
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[pr]), \
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_IN_PROGRESS]), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._find_pr_for_branch", return_value=None), \
patch("agent_loop._close_issue"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_merge.assert_called_once_with(50)
class TestMergeFailsOpen(unittest.TestCase):
"""Tests for auto-resolution when a PR is still open after the merge command."""
def _dead_state(self, issue: int, kind: str = "issue") -> dict:
return {
"pid": 999999999,
"issue": issue,
"started_at": "2026-01-01T00:00:00+00:00",
"type": kind,
}
def _open_pr(self, branch: str = "issue-10-fix") -> dict:
return {"number": 5, "head": {"ref": branch}, "created_at": "2026-01-01T00:00:00+00:00"}
def test_merge_fails_open_with_conflicts_spawns_rebase_agent(self):
"""mergeable=false → rebase agent spawned, state written as pending-ci."""
written_state = {}
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
written_state["pid"] = pid
written_state["issue"] = issue
written_state["kind"] = kind
written_state["session_name"] = session_name
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), self._open_pr()]), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr"), \
patch("agent_loop._tea_get", return_value={"mergeable": False}), \
patch("agent_loop._start_agent", return_value=77) as mock_start, \
patch("agent_loop._write_state", side_effect=fake_write_state), \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_start.assert_called_once()
prompt = mock_start.call_args[0][0]
self.assertIn("Rebase branch", prompt)
self.assertIn("issue-10-fix", prompt)
self.assertEqual(written_state.get("kind"), "pending-ci")
self.assertEqual(written_state.get("issue"), 10)
def test_merge_fails_open_no_conflicts_retries_and_succeeds(self):
"""mergeable=true, second attempt succeeds → issue closed."""
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch",
side_effect=[self._open_pr(), self._open_pr(), None]), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr"), \
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
patch("agent_loop.time.sleep"), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_called_once_with(10)
def test_merge_fails_open_no_conflicts_all_retries_exhausted(self):
"""All retries exhausted with PR still open → falls through to State/Question."""
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch",
side_effect=[self._open_pr(), self._open_pr(),
self._open_pr(), self._open_pr()]), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr"), \
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
patch("agent_loop.time.sleep"), \
patch("agent_loop._set_labels") as mock_labels, \
patch("agent_loop._comment_issue") as mock_comment, \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_not_called()
mock_labels.assert_called_once_with(
10,
add=[agent_loop.LABEL_QUESTION],
remove=[agent_loop.LABEL_IN_PROGRESS],
)
mock_comment.assert_called_once()
class TestHeartbeat(unittest.TestCase):
"""Tests for _update_heartbeat() and cmd_monitor()."""
def setUp(self):
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".heartbeat")
self._tmp.close()
self._orig = agent_loop.HEARTBEAT_FILE
agent_loop.HEARTBEAT_FILE = Path(self._tmp.name)
Path(self._tmp.name).unlink() # Start with no heartbeat file.
def tearDown(self):
agent_loop.HEARTBEAT_FILE = self._orig
Path(self._tmp.name).unlink(missing_ok=True)
def test_update_heartbeat_writes_timestamp(self):
agent_loop._update_heartbeat()
content = Path(self._tmp.name).read_text().strip()
dt = datetime.fromisoformat(content)
age = (datetime.now(timezone.utc) - dt).total_seconds()
self.assertLess(age, 5)
def test_update_heartbeat_creates_file(self):
self.assertFalse(Path(self._tmp.name).exists())
agent_loop._update_heartbeat()
self.assertTrue(Path(self._tmp.name).exists())
def test_monitor_healthy_when_recent(self):
agent_loop._update_heartbeat()
result = agent_loop.cmd_monitor()
self.assertEqual(result, 0)
def test_monitor_warns_when_heartbeat_missing(self):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
result = agent_loop.cmd_monitor()
self.assertEqual(result, 1)
self.assertIn("WARNING", buf.getvalue())
def test_monitor_warns_when_stale(self):
stale = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat()
Path(self._tmp.name).write_text(stale)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
result = agent_loop.cmd_monitor()
self.assertEqual(result, 1)
self.assertIn("WARNING", buf.getvalue())
def test_monitor_warns_when_corrupted(self):
Path(self._tmp.name).write_text("not-a-timestamp")
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
result = agent_loop.cmd_monitor()
self.assertEqual(result, 1)
self.assertIn("WARNING", buf.getvalue())
def test_run_loop_updates_heartbeat(self):
self.assertFalse(Path(self._tmp.name).exists())
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
agent_loop._run_loop()
self.assertTrue(Path(self._tmp.name).exists())
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+36
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: [
@@ -180,4 +196,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', () {
+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);
});
}
+136
View File
@@ -116,7 +116,10 @@ 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:')));
@@ -144,6 +147,7 @@ void main() {
gitHash: testHash, gitHash: testHash,
), ),
); );
await tester.pumpAndSettle();
// Git hash link should be present // Git hash link should be present
final gitLinkFinder = find.textContaining('Git Commit: abc1234'); final gitLinkFinder = find.textContaining('Git Commit: abc1234');
@@ -167,6 +171,138 @@ void main() {
}, },
); );
testWidgets(
'CrashScreen shows version, build mode, and platform in the UI',
(tester) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
const exception = 'TestException: info row test';
final stackTrace = StackTrace.current;
await tester.pumpWidget(
MaterialApp(
home: CrashScreen(exception: exception, stackTrace: stackTrace),
),
);
await tester.pumpAndSettle();
// Info row shows app version (from mock), build mode, and platform OS.
expect(find.textContaining('1.0.0+42'), findsWidgets);
// In test builds kDebugMode is true.
expect(find.textContaining('debug'), findsOneWidget);
// Platform OS is always present (linux in CI, android/ios on device).
expect(
find.textContaining(RegExp(r'linux|android|ios|windows|macos')),
findsWidgets,
);
},
);
testWidgets(
'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;
+136
View File
@@ -179,6 +179,142 @@ void main() {
expect(find.text('report.pdf'), findsOneWidget); expect(find.text('report.pdf'), findsOneWidget);
}); });
testWidgets('Reply All button is not present in app bar', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Reply all',
),
findsNothing,
);
});
testWidgets('Reply on single-recipient email navigates directly to compose',
(tester) async {
// testEmail has from=[bob], to=[alice]. After removing alice (own),
// only bob remains → no dialog, navigate straight to compose.
final email = testEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [
..._overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
email: email,
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
],
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Reply',
),
);
await tester.pumpAndSettle();
// No dialog shown — straight navigation to compose.
expect(find.text('Reply All'), findsNothing);
});
testWidgets('Reply on multi-recipient email shows Reply All dialog',
(tester) async {
// Email with an extra Cc recipient so the dialog is triggered.
final email = Email(
id: 'acc-1:42',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 42,
subject: 'Hello world',
receivedAt: DateTime(2024, 6),
sentAt: DateTime(2024, 6),
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
to: const [EmailAddress(email: 'alice@example.com')],
cc: const [EmailAddress(name: 'Carol', email: 'carol@example.com')],
isSeen: false,
isFlagged: false,
hasAttachment: false,
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
email: email,
),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Reply',
),
);
await tester.pumpAndSettle();
// Dialog must appear with title 'Reply All'.
expect(find.text('Reply All'), findsOneWidget);
// Both non-own addresses should be listed in the dialog.
expect(find.textContaining('bob@example.com'), findsAtLeastNWidgets(1));
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
});
testWidgets('Mark as spam button is present in app bar', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam',
),
findsOneWidget,
);
});
testWidgets(
'Mark as spam moves email to junk and shows snackbar when no junk folder',
(tester) async {
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
// returns null → snackbar shown.
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam',
),
);
await tester.pumpAndSettle();
expect(find.text('No Junk folder found'), 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;
+20 -7
View File
@@ -44,11 +44,12 @@ import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
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 +76,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!;
} }
@@ -511,10 +519,13 @@ List<Override> baseOverrides({
List<Mailbox>? mailboxes, List<Mailbox>? mailboxes,
DiscoveryResult? discovery, DiscoveryResult? discovery,
Exception? connectionError, Exception? connectionError,
ShareKeyRepository? shareKeyRepository,
bool hasStoredPassword = true,
}) => }) =>
[ [
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 +536,9 @@ List<Override> baseOverrides({
connectionTestServiceProvider.overrideWithValue( connectionTestServiceProvider.overrideWithValue(
FakeConnectionTestService(error: connectionError), FakeConnectionTestService(error: connectionError),
), ),
shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()), shareKeyRepositoryProvider.overrideWithValue(
shareKeyRepository ?? FakeShareKeyRepository(),
),
]; ];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------