Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 a1b9e0a8b0 feat: show app version as link on crash screen and in MD report (#236)
When a git hash is available, the crash screen now displays the app
version number as a tappable link (pointing to the Codeberg commit
page) above the existing git-hash link, and the clipboard markdown
report formats the App Version line as a markdown link in the same way
the About screen already does.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 08:12:35 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 3a08daa402 style: format edit_account_screen_test.dart
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 07:58:44 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2336afa0d7 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 07:47:51 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 c343ed6bd7 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-24 18:27:03 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 1d5eb187bf fix: fall back to text input when mobile_scanner plugin is unavailable (#202)
On some Android builds the mobile_scanner native plugin is not registered,
causing a MissingPluginException when the send/receive screens try to open
the QR scanner.  Add a pre-flight _initScanner() method that starts and
immediately stops a temporary MobileScannerController in a try/catch; any
exception (including MissingPluginException) sets _scannerFailed=true and
the UI falls back to the existing copy-paste text-input flow instead of
leaving the user stuck with a blank camera view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:47:15 +02:00
28 changed files with 121 additions and 1302 deletions
+4 -4
View File
@@ -38,7 +38,7 @@ jobs:
echo "Changed files:"
echo "$CHANGED"
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/)'
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
echo "$CHANGED" | grep -qE "$android_re" \
@@ -97,7 +97,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 100
fetch-depth: 1
- name: Check runner tools
run: |
@@ -136,7 +136,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 100
fetch-depth: 1
- name: Check runner tools
run: |
@@ -178,7 +178,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 100
fetch-depth: 1
- name: Check runner tools
run: |
-33
View File
@@ -1,33 +0,0 @@
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
+1 -2
View File
@@ -28,8 +28,7 @@ android/.gradle/
android/local.properties
android/app/google-services.json
android/key.properties
# android/app/src/main/java/io/flutter/plugins/ intentionally tracked so that
# GeneratedPluginRegistrant.java (catch Throwable) is committed and used by CI.
android/app/src/main/java/io/flutter/plugins/
.android/
Android/
.gradle/
+6 -20
View File
@@ -10,21 +10,9 @@ CLI tool `fgj` is available to query issues/PRs/actions.
We use issues, follow this label state machine:
- **State/ToPlan** — Issue needs a plan written by an agent before implementation
- **State/Planned** — Plan has been posted as a comment; awaiting human review
- **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)
```
- **State/Ready** — Issue is available to pick up
- **State/InProgress** — Set this when you start working on an issue
- **State/Question** — Set this when you hit a blocker or need clarification
List open issues ready to pick up:
@@ -34,11 +22,9 @@ fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/
Rules:
- Never start implementation on an issue without `State/Ready`
- Planning agents only post a plan comment — they do NOT write code or open PRs
- 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.
- Never start work on an issue without `State/Ready`
- When working via the agent loop: `State/Ready``State/InProgress` is set automatically
by `agent_loop.py` before the agent starts — do **not** set it yourself.
- When working manually: switch to `State/InProgress` as your **first action**:
```bash
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
+2 -11
View File
@@ -224,7 +224,7 @@ tasks:
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
cmds:
- mkdir -p build/app/outputs/bundle/release
- 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
- dagger call --progress=plain -q -m ci --source=. build-android-release -o build/app/outputs/bundle/release/app-release.aab
upload-android-bundle:
desc: Upload AAB from build/ to Play Store via Dagger
@@ -238,7 +238,6 @@ tasks:
publish-android:
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
deps: [generate-changelog]
preconditions:
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
msg: "PLAY_STORE_CONFIG_JSON is not set"
@@ -247,7 +246,7 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. 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"
- 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
deploy-apk:
desc: Build and deploy Android APK via Dagger
@@ -336,14 +335,6 @@ tasks:
- |
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:
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]
+1
View File
@@ -4,6 +4,7 @@ gradle-wrapper.jar
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
@@ -1,84 +0,0 @@
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);
}
}
}
+15 -72
View File
@@ -195,8 +195,7 @@ func (m *Ci) toolchain() *dagger.Container {
WithUser("ci").
WithExec([]string{"/bin/sh", "-c",
`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; }`}).
WithExec([]string{"flutter", "precache", "--linux", "--no-android", "--no-ios"})
`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; }`})
}
// Base is the Flutter toolchain container with mutable cache mounts attached.
@@ -286,21 +285,6 @@ 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.
func (m *Ci) linuxSrc() *dagger.Directory {
return m.Source.Filter(dagger.DirectoryFilterOpts{
@@ -599,17 +583,9 @@ func (m *Ci) BuildLinux() *dagger.Directory {
}
// BuildLinuxRelease builds the Linux release bundle.
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)
}
func (m *Ci) BuildLinuxRelease() *dagger.Directory {
return m.setup(m.linuxSrc()).
WithExec(args).
WithExec([]string{"flutter", "build", "linux", "--release"}).
Directory("build/linux/x64/release/bundle")
}
@@ -622,7 +598,7 @@ func (m *Ci) DeployLinux(
sshHost string,
commitHash string,
) (string, error) {
bundle := m.BuildLinuxRelease(commitHash)
bundle := m.BuildLinuxRelease()
datePath := time.Now().Format("2006/01/02")
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
@@ -638,27 +614,16 @@ func (m *Ci) DeployLinux(
// setupKeystore decodes the base64 keystore into the android build container.
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
return m.androidBase().
return m.setup(m.androidSrc()).
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
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.
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)
}
func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string) *dagger.File {
return m.setupKeystore(keystoreBase64, keystorePassword).
WithExec(args).
WithExec([]string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}).
File("build/app/outputs/flutter-apk/app-release.apk")
}
@@ -674,7 +639,7 @@ func (m *Ci) DeployApk(
keystorePassword *dagger.Secret,
buildNumber string,
) (string, error) {
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber, commitHash)
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber)
datePath := time.Now().Format("2006/01/02")
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
@@ -690,7 +655,8 @@ func (m *Ci) DeployApk(
// 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.
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
built := m.firebaseBase().
built := m.setup(m.firebaseSrc()).
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"}).
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
WithWorkdir("/src/android").
// --no-daemon avoids connecting to a stale daemon whose registry file was
@@ -749,17 +715,9 @@ func (m *Ci) TestAndroidFirebase(
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
func (m *Ci) BuildAndroidRelease(
// 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", "appbundle", "--release", "--no-pub", "--build-number", "1"}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
return m.androidBase().
WithExec(args).
func (m *Ci) BuildAndroidRelease() *dagger.File {
return m.setup(m.androidSrc()).
WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}).
File("build/app/outputs/bundle/release/app-release.aab")
}
@@ -831,29 +789,14 @@ func (m *Ci) PublishAndroid(
playStoreConfig *dagger.Secret,
keystoreBase64 *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) {
versionCode := int(time.Now().Unix())
aab := m.BuildAndroidRelease(commitHash)
aab := m.BuildAndroidRelease()
stamped := m.StampAndroidVersionCode(aab, versionCode)
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
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.
// Paste the output into any Mermaid renderer (codeberg, github, mermaid.live)
// or save it as a .md file to get a rendered diagram.
@@ -867,7 +810,7 @@ func (m *Ci) Graph() string {
` + "```" + `mermaid
flowchart TD
subgraph dagger ["Dagger · Check pipeline"]
toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"]
toolchain["toolchain\nflutter:3.41.6 + NDK + apt"]
pubGet["pubGetLayer\nflutter pub get"]
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
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
export PATH="$HOME/.nix-profile/bin:$PATH"
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger" "*fgj-*/bin/fgj"; do
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger"; do
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
done
+4 -25
View File
@@ -95,30 +95,6 @@ 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(
BuildContext context,
int imapCount,
@@ -191,7 +167,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
onTapLink: (text, href, title) {
if (href != null) {
unawaited(
_launchUrl(context, Uri.parse(href)),
launchUrl(
Uri.parse(href),
mode: LaunchMode.externalApplication,
),
);
}
},
+15 -48
View File
@@ -32,7 +32,6 @@ enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
_Step _step = _Step.generatingKey;
ShareKeyMaterial? _keyMaterial;
DateTime? _keyExpiresAt;
String? _pubKeyQr;
String? _errorMessage;
bool _scannerActive = false;
@@ -65,7 +64,6 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
);
setState(() {
_keyMaterial = material;
_keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
_pubKeyQr = qr;
_step = _Step.showingPubKey;
});
@@ -87,24 +85,22 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
}
}
// 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.
// Pre-flight: start + stop the scanner to verify the plugin is available.
// Falls back to text entry on any exception (including MissingPluginException).
Future<void> _initScanner() async {
MobileScannerController? ctrl;
bool available = false;
try {
await const MethodChannel(
'dev.steenbakker.mobile_scanner/scanner/method',
).invokeMethod<int>('state');
ctrl = MobileScannerController();
await ctrl.start();
await ctrl.stop();
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;
// Plugin not available on this device; text fallback will be shown.
} finally {
try {
await ctrl?.dispose();
} catch (_) {}
}
if (!mounted) return;
if (available) {
@@ -278,7 +274,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
},
),
const SizedBox(height: 8),
_ExpiryHint(expiresAt: _keyExpiresAt!),
const _ExpiryHint(),
const SizedBox(height: 32),
if (_errorMessage != null) ...[
Text(
@@ -408,37 +404,8 @@ bool _cameraScanSupported() =>
Platform.isMacOS ||
Platform.isWindows;
class _ExpiryHint extends StatefulWidget {
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')}';
}
class _ExpiryHint extends StatelessWidget {
const _ExpiryHint();
@override
Widget build(BuildContext context) {
@@ -448,7 +415,7 @@ class _ExpiryHintState extends State<_ExpiryHint> {
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
'This key expires in ${_formatRemaining()}',
'This key expires in 20 minutes',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
+11 -13
View File
@@ -57,24 +57,22 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
}
}
// 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.
// Pre-flight: start + stop the scanner to verify the plugin is available.
// Falls back to text entry on any exception (including MissingPluginException).
Future<void> _initScanner() async {
MobileScannerController? ctrl;
bool available = false;
try {
await const MethodChannel(
'dev.steenbakker.mobile_scanner/scanner/method',
).invokeMethod<int>('state');
ctrl = MobileScannerController();
await ctrl.start();
await ctrl.stop();
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;
// Plugin not available on this device; text fallback will be shown.
} finally {
try {
await ctrl?.dispose();
} catch (_) {}
}
if (!mounted) return;
if (available) {
+2 -2
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -12,8 +13,7 @@ class ChangeLogScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(title: const Text('ChangeLog')),
body: FutureBuilder<String>(
future:
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
future: rootBundle.loadString('assets/changelog.txt'),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
+5 -33
View File
@@ -1,6 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
@@ -18,23 +17,12 @@ class CrashScreen extends StatelessWidget {
final StackTrace? stackTrace;
final String gitHash;
String get _buildMode {
if (kDebugMode) return 'debug';
if (kProfileMode) return 'profile';
return 'release';
}
Future<String> _fetchVersion() async {
Future<String> _buildReport() async {
String version = 'unknown';
try {
final info = await PackageInfo.fromPlatform();
return '${info.version}+${info.buildNumber}';
} catch (_) {
return 'unknown';
}
}
Future<String> _buildReport() async {
final version = await _fetchVersion();
version = '${info.version}+${info.buildNumber}';
} catch (_) {}
final platform =
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
final versionDisplay = gitHash.isNotEmpty
@@ -43,13 +31,9 @@ class CrashScreen extends StatelessWidget {
final gitLine = gitHash.isNotEmpty
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
: '';
final timestamp = DateTime.now().toUtc().toIso8601String();
return 'App Version: $versionDisplay\n'
'Build Mode: $_buildMode\n'
'$gitLine'
'Platform: $platform\n'
'Dart: ${Platform.version}\n'
'Timestamp: $timestamp\n\n'
'Platform: $platform\n\n'
'Error:\n```\n$exception\n```\n\n'
'Stack Trace:\n```\n$stackTrace\n```';
}
@@ -75,18 +59,6 @@ class CrashScreen extends StatelessWidget {
style: Theme.of(ctx).textTheme.titleMedium,
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) ...[
const SizedBox(height: 8),
FutureBuilder<PackageInfo>(
+2 -11
View File
@@ -51,7 +51,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
_smtpHostCtrl.addListener(_rebuild);
_sieveHostCtrl.addListener(_rebuild);
_imapHostCtrl.addListener(_rebuild);
_passwordCtrl.addListener(_rebuild);
unawaited(_load());
}
@@ -91,7 +90,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
_smtpHostCtrl.removeListener(_rebuild);
_sieveHostCtrl.removeListener(_rebuild);
_imapHostCtrl.removeListener(_rebuild);
_passwordCtrl.removeListener(_rebuild);
for (final c in [
_displayNameCtrl,
_usernameCtrl,
@@ -355,17 +353,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
testing: _tryTesting,
okMessage: _tryOk,
errorMessage: _tryErr,
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
? _tryConnection
: null,
onPressed: _tryConnection,
),
const SizedBox(height: 8),
FilledButton(
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
? _save
: null,
child: const Text('Save'),
),
FilledButton(onPressed: _save, child: const Text('Save')),
],
),
),
+14 -197
View File
@@ -70,9 +70,16 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
onPressed: header == null
? null
: () {
unawaited(
_replyWithRecipientDialog(context, header, body),
);
unawaited(_reply(context, header, body, replyAll: false));
},
),
IconButton(
icon: const Icon(Icons.reply_all),
tooltip: 'Reply all',
onPressed: header == null
? null
: () {
unawaited(_reply(context, header, body, replyAll: true));
},
),
IconButton(
@@ -114,15 +121,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
tooltip: 'Snooze',
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(
icon: const Icon(Icons.delete),
tooltip: 'Delete',
@@ -305,78 +303,17 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
return '\n\n— On $date, $from wrote:\n$quoted';
}
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(
Future<void> _reply(
BuildContext context,
Email header,
EmailBody? body, {
required String to,
required String cc,
required bool replyAll,
}) async {
final to = header.from.isNotEmpty ? header.from.first.email : '';
final subject = (header.subject?.startsWith('Re:') ?? false)
? header.subject!
: 'Re: ${header.subject ?? ''}';
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
final quoted = await _quotedBody(header, body);
if (!context.mounted) return;
unawaited(
@@ -393,38 +330,6 @@ 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(
BuildContext context,
Email header,
@@ -765,94 +670,6 @@ 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 {
const _MimeRow(this.depth, this.label);
final int depth;
+3 -3
View File
@@ -1117,13 +1117,13 @@ packages:
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: "direct overridden"
dependency: transitive
description:
name: url_launcher_android
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev"
source: hosted
version: "6.3.24"
version: "6.3.30"
url_launcher_ios:
dependency: transitive
description:
-4
View File
@@ -89,7 +89,3 @@ dependency_overrides:
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
# stable Pigeon and is known to work reliably.
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
@@ -1,10 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"labels": ["dependencies"],
"github-actions": {
"fileMatch": ["^\\.forgejo/workflows/[^/]+\\.ya?ml$"]
}
}
+16 -158
View File
@@ -8,25 +8,21 @@ Flow
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
b. Age ≤ 1 h → print status, exit 0 (let it keep working)
2. No agent running → extract pending_issue from state (if any), then check CI
a. pending_issue type=="plan" → post resume comment, set State/Planned, exit 0
b. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
c. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
d. Main CI running → save pending-ci state, exit 0
e. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
f. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
section 2b always returns first)
g. Main CI ok (or no run yet) → find oldest ToPlan issue, start plan agent,
a. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
b. Catch-up: orphaned issue-N-fix PRs with passing CI merge them
c. Main CI running → save pending-ci state, exit 0
d. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
e. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
section 2a always returns first)
f. Main CI ok (or no run yet) → find oldest Ready issue, start issue agent,
save state, exit 0
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
g. No Ready issues print "nothing to do", exit 0
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
Plan agents must NOT write any code or create PRs; they only post a plan comment.
State file: ~/.sharedinbox-agent-state.json
{ "pid": 12345, "issue": 91,
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue|plan|ci-fix|pending-ci" }
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue" }
Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
To resume the Claude conversation, look up the session UUID first:
@@ -42,7 +38,6 @@ import re
import shlex
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
@@ -70,8 +65,6 @@ LABEL_READY = "State/Ready"
LABEL_IN_PROGRESS = "State/InProgress"
LABEL_QUESTION = "State/Question"
LABEL_PRIO_HIGH = "Prio/High"
LABEL_TO_PLAN = "State/ToPlan"
LABEL_PLANNED = "State/Planned"
# Only pick up issues filed by these accounts.
ALLOWED_ISSUE_AUTHORS = {"guettli", "guettlibot", "guettlibot2"}
@@ -154,39 +147,17 @@ def _ready_issues() -> list[dict]:
return ready
def _to_plan_issues() -> list[dict]:
"""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
def _latest_main_ci_run() -> dict | None:
"""Return the latest ci.yml run on the main branch.
"""Return the latest CI run on the main branch (excludes PR and schedule runs).
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".
Using the global latest run (limit=1) is wrong: a passing or failing run
on a PR branch could mask the true state of main. We filter to push
events on the 'main' prettyref so section-3 logic only reacts to main.
"""
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
runs = (data or {}).get("workflow_runs", [])
for run in runs:
if (run.get("event") == "push"
and run.get("prettyref") == "main"
and run.get("workflow_id") == "ci.yml"):
if run.get("event") == "push" and run.get("prettyref") == "main":
return run
return None
@@ -266,54 +237,11 @@ def _latest_ci_run_for_pr(pr_number: int) -> dict | 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:
"""Squash-merge a PR via fgj."""
_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 ────────────────────────────────────────────────────────────────
@@ -614,29 +542,13 @@ def _run_loop() -> int:
# Agent not running (or no state) — extract any pending issue, then clean up.
pending_issue: int | None = None
pending_type: str | None = None
ci_run_id_at_start: int | None = None
if state:
pending_issue = state.get("issue")
pending_type = state.get("type")
ci_run_id_at_start = state.get("ci_run_id_at_start")
_clear_state()
# ── 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 ───────────────────────────────
# ── 2. Check for a PR opened by the agent ────────────────────────────────
if pending_issue:
branch = f"issue-{pending_issue}-fix"
pr = _find_pr_for_branch(branch)
@@ -712,13 +624,6 @@ def _run_loop() -> int:
)
return 0
if _find_pr_for_branch(branch):
merge_result = _handle_pr_still_open_after_merge(pr_number, branch, pending_issue)
if merge_result == "rebase-spawned":
return 0
if merge_result == "merged":
_close_issue(pending_issue)
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
return 0
print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.")
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
@@ -775,9 +680,6 @@ def _run_loop() -> int:
continue
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.")
try:
_merge_pr(pr_number)
@@ -787,16 +689,6 @@ def _run_loop() -> int:
# Verify the merge actually happened; fgj can exit 0 without merging
# (e.g. branch-protection rules not satisfied).
if _find_pr_for_branch(branch):
merge_result = _handle_pr_still_open_after_merge(pr_number, branch, issue_num)
if merge_result == "rebase-spawned":
return 0
if merge_result == "merged":
if issue_num:
_close_issue(issue_num)
print(f"Catch-up: merged PR #{pr_number} and closed issue #{issue_num} after retry.")
else:
print(f"Catch-up: merged PR #{pr_number} after retry.")
return 0
print(
f"Catch-up: PR #{pr_number} is still open after merge attempt "
"— skipping to avoid infinite retry."
@@ -884,44 +776,10 @@ def _run_loop() -> int:
print(f"CI passed{ci_run_part} — closed {_issue_url(pending_issue)}.")
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.
issues = _ready_issues()
if not issues:
print("No issues with State/ToPlan or State/Ready. Nothing to do.")
print("No issues with State/Ready. Nothing to do.")
return 0
issue = issues[0]
+11 -147
View File
@@ -506,40 +506,28 @@ class TestOutputFormat(unittest.TestCase):
class TestLatestMainCiRun(unittest.TestCase):
"""_latest_main_ci_run() must return only ci.yml push-to-main runs."""
"""_latest_main_ci_run() must return only push-to-main runs, ignoring schedule/deploy workflows."""
def _ci_run(self, run_id, status="success"):
return {"event": "push", "prettyref": "main", "workflow_id": "ci.yml",
"status": status, "id": run_id}
def _deploy_run(self, run_id, status="success"):
return {"event": "push", "prettyref": "main", "workflow_id": "deploy.yml",
"status": status, "id": run_id}
def test_skips_deploy_run_returns_ci_run(self):
# Forgejo reports deploy.yml schedule runs as event=push/prettyref=main;
# must be excluded by workflow_id filter.
runs = [self._deploy_run(1), self._ci_run(2)]
def test_skips_schedule_runs_returns_push_to_main(self):
runs = [
{"event": "schedule", "prettyref": "main", "status": "success", "id": 1},
{"event": "push", "prettyref": "main", "status": "success", "id": 2},
]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNotNone(result)
self.assertEqual(result["id"], 2)
def test_returns_none_when_only_deploy_runs_exist(self):
runs = [self._deploy_run(1)]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNone(result)
def test_returns_none_when_only_schedule_runs_exist(self):
runs = [{"event": "schedule", "prettyref": "main", "workflow_id": "deploy.yml",
"status": "success", "id": 1}]
runs = [
{"event": "schedule", "prettyref": "main", "status": "success", "id": 1},
]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNone(result)
def test_returns_ci_push_to_main_run(self):
runs = [self._ci_run(42, status="running")]
def test_returns_push_to_main_run(self):
runs = [{"event": "push", "prettyref": "main", "status": "running", "id": 42}]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNotNone(result)
@@ -745,130 +733,6 @@ class TestRunLoopResumeCommand(unittest.TestCase):
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()."""
-36
View File
@@ -27,22 +27,6 @@ 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 []}) {
return ProviderScope(
overrides: [
@@ -196,24 +180,4 @@ void main() {
);
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);
},
);
}
+2 -100
View File
@@ -23,7 +23,7 @@ void main() {
expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget);
});
testWidgets('shows expiry countdown hint', (tester) async {
testWidgets('shows 20-minute expiry hint', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/receive',
@@ -32,106 +32,8 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.textContaining('expires in'), findsOneWidget);
expect(find.textContaining('20 minutes'), 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', () {
-54
View File
@@ -1,54 +0,0 @@
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);
});
}
-32
View File
@@ -116,10 +116,7 @@ void main() {
expect(clipboardText, isNotNull);
expect(clipboardText, contains('App Version: 1.0.0+42'));
expect(clipboardText, contains('Build Mode:'));
expect(clipboardText, contains('Platform:'));
expect(clipboardText, contains('Dart:'));
expect(clipboardText, contains('Timestamp:'));
expect(clipboardText, contains('TestException: clipboard test'));
// GIT_HASH is empty in test builds — no Git Commit line expected
expect(clipboardText, isNot(contains('Git Commit:')));
@@ -171,35 +168,6 @@ 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 {
+4 -59
View File
@@ -106,8 +106,7 @@ void main() {
});
testWidgets(
'try connection button is disabled when no password stored or entered',
(
'try connection shows password required when no password stored', (
tester,
) async {
tester.view.physicalSize = const Size(800, 1400);
@@ -126,65 +125,11 @@ void main() {
);
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.tap(find.byKey(const Key('editTryConnectionButton')));
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);
// App must not crash; password field shows a validation error.
expect(find.text('Required'), findsOneWidget);
});
testWidgets('connection error shows error message', (tester) async {
-136
View File
@@ -179,142 +179,6 @@ void main() {
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 {
// 'A' * 2048 → fmtSize(2048) == '2.0 KB'
final rawContent = 'A' * 2048;
+2 -7
View File
@@ -85,13 +85,11 @@ class FakeAccountRepository implements AccountRepository {
}
class FakeShareKeyRepository implements ShareKeyRepository {
FakeShareKeyRepository({ShareKeyMaterial? material}) : _material = material;
ShareKeyMaterial? _material;
@override
Future<ShareKeyMaterial> createKeyPair() async {
_material ??= await ShareEncryptionService.generateKeyPair();
_material = await ShareEncryptionService.generateKeyPair();
return _material!;
}
@@ -519,7 +517,6 @@ List<Override> baseOverrides({
List<Mailbox>? mailboxes,
DiscoveryResult? discovery,
Exception? connectionError,
ShareKeyRepository? shareKeyRepository,
bool hasStoredPassword = true,
}) =>
[
@@ -536,9 +533,7 @@ List<Override> baseOverrides({
connectionTestServiceProvider.overrideWithValue(
FakeConnectionTestService(error: connectionError),
),
shareKeyRepositoryProvider.overrideWithValue(
shareKeyRepository ?? FakeShareKeyRepository(),
),
shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()),
];
// ---------------------------------------------------------------------------