Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
774829ece5 | ||
|
|
33949b92c0 | ||
|
|
a1b9e0a8b0 | ||
|
|
3a08daa402 | ||
|
|
2336afa0d7 | ||
|
|
c343ed6bd7 | ||
|
|
1d5eb187bf |
@@ -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/|scripts/deploy_playstore\.py)'
|
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/)'
|
||||||
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: 100
|
fetch-depth: 1
|
||||||
|
|
||||||
- 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: 100
|
fetch-depth: 1
|
||||||
|
|
||||||
- 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: 100
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+1
-2
@@ -28,8 +28,7 @@ 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/ intentionally tracked so that
|
android/app/src/main/java/io/flutter/plugins/
|
||||||
# GeneratedPluginRegistrant.java (catch Throwable) is committed and used by CI.
|
|
||||||
.android/
|
.android/
|
||||||
Android/
|
Android/
|
||||||
.gradle/
|
.gradle/
|
||||||
|
|||||||
@@ -10,21 +10,9 @@ 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/ToPlan** — Issue needs a plan written by an agent before implementation
|
- **State/Ready** — Issue is available to pick up
|
||||||
- **State/Planned** — Plan has been posted as a comment; awaiting human review
|
- **State/InProgress** — Set this when you start working on an issue
|
||||||
- **State/Ready** — Issue is approved and ready for implementation
|
- **State/Question** — Set this when you hit a blocker or need clarification
|
||||||
- **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:
|
||||||
|
|
||||||
@@ -34,11 +22,9 @@ fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/
|
|||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- Never start implementation on an issue without `State/Ready`
|
- Never start work on an issue without `State/Ready`
|
||||||
- Planning agents only post a plan comment — they do NOT write code or open PRs
|
- When working via the agent loop: `State/Ready` → `State/InProgress` is set automatically
|
||||||
- After `State/Planned`, a human must review the plan and manually add `State/Ready`
|
by `agent_loop.py` before the agent starts — do **not** set it yourself.
|
||||||
- When working via the agent loop: label transitions are set automatically
|
|
||||||
by `agent_loop.py` — do **not** set them yourself.
|
|
||||||
- When working manually: switch to `State/InProgress` as your **first action**:
|
- When working manually: switch to `State/InProgress` as your **first action**:
|
||||||
```bash
|
```bash
|
||||||
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
|
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
|
||||||
|
|||||||
+2
-3
@@ -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
|
||||||
- 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:
|
upload-android-bundle:
|
||||||
desc: Upload AAB from build/ to Play Store via Dagger
|
desc: Upload AAB from build/ to Play Store via Dagger
|
||||||
@@ -238,7 +238,6 @@ 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"
|
||||||
@@ -247,7 +246,7 @@ tasks:
|
|||||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
|
- 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:
|
deploy-apk:
|
||||||
desc: Build and deploy Android APK via Dagger
|
desc: Build and deploy Android APK via Dagger
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ gradle-wrapper.jar
|
|||||||
/gradlew
|
/gradlew
|
||||||
/gradlew.bat
|
/gradlew.bat
|
||||||
/local.properties
|
/local.properties
|
||||||
|
GeneratedPluginRegistrant.java
|
||||||
.cxx/
|
.cxx/
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
# Remember to never publicly share your keystore.
|
||||||
|
|||||||
@@ -1,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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+11
-42
@@ -195,8 +195,7 @@ 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.
|
||||||
@@ -584,17 +583,9 @@ func (m *Ci) BuildLinux() *dagger.Directory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BuildLinuxRelease builds the Linux release bundle.
|
// BuildLinuxRelease builds the Linux release bundle.
|
||||||
func (m *Ci) BuildLinuxRelease(
|
func (m *Ci) BuildLinuxRelease() *dagger.Directory {
|
||||||
// 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(args).
|
WithExec([]string{"flutter", "build", "linux", "--release"}).
|
||||||
Directory("build/linux/x64/release/bundle")
|
Directory("build/linux/x64/release/bundle")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,7 +598,7 @@ func (m *Ci) DeployLinux(
|
|||||||
sshHost string,
|
sshHost string,
|
||||||
commitHash string,
|
commitHash string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
bundle := m.BuildLinuxRelease(commitHash)
|
bundle := m.BuildLinuxRelease()
|
||||||
|
|
||||||
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)
|
||||||
@@ -630,20 +621,9 @@ func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagg
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BuildAndroidApk builds a release APK signed with the upload key.
|
// BuildAndroidApk builds a release APK signed with the upload key.
|
||||||
func (m *Ci) BuildAndroidApk(
|
func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string) *dagger.File {
|
||||||
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(args).
|
WithExec([]string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}).
|
||||||
File("build/app/outputs/flutter-apk/app-release.apk")
|
File("build/app/outputs/flutter-apk/app-release.apk")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,7 +639,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, commitHash)
|
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber)
|
||||||
|
|
||||||
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)
|
||||||
@@ -735,17 +715,9 @@ 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(
|
func (m *Ci) BuildAndroidRelease() *dagger.File {
|
||||||
// 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.setup(m.androidSrc()).
|
return m.setup(m.androidSrc()).
|
||||||
WithExec(args).
|
WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}).
|
||||||
File("build/app/outputs/bundle/release/app-release.aab")
|
File("build/app/outputs/bundle/release/app-release.aab")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -817,12 +789,9 @@ 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(commitHash)
|
aab := m.BuildAndroidRelease()
|
||||||
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)
|
||||||
@@ -841,7 +810,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 + precache"]
|
toolchain["toolchain\nflutter:3.41.6 + NDK + apt"]
|
||||||
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"])
|
||||||
|
|||||||
@@ -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" "*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)
|
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
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class SyncLogEntry {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.result,
|
required this.result,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
|
this.stackTrace,
|
||||||
|
this.isPermanent = false,
|
||||||
required this.protocol,
|
required this.protocol,
|
||||||
required this.emailsFetched,
|
required this.emailsFetched,
|
||||||
required this.emailsSkipped,
|
required this.emailsSkipped,
|
||||||
@@ -34,6 +36,8 @@ class SyncLogEntry {
|
|||||||
final int id;
|
final int id;
|
||||||
final String result; // 'ok' or 'error'
|
final String result; // 'ok' or 'error'
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
final String? stackTrace;
|
||||||
|
final bool isPermanent;
|
||||||
final String protocol; // 'imap' or 'jmap'
|
final String protocol; // 'imap' or 'jmap'
|
||||||
final int emailsFetched;
|
final int emailsFetched;
|
||||||
final int emailsSkipped;
|
final int emailsSkipped;
|
||||||
@@ -54,6 +58,8 @@ abstract class SyncLogRepository {
|
|||||||
required String accountId,
|
required String accountId,
|
||||||
required bool success,
|
required bool success,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
String? stackTrace,
|
||||||
|
bool isPermanent = false,
|
||||||
required String protocol,
|
required String protocol,
|
||||||
required int emailsFetched,
|
required int emailsFetched,
|
||||||
required int emailsSkipped,
|
required int emailsSkipped,
|
||||||
@@ -81,6 +87,8 @@ class NoOpSyncLogRepository implements SyncLogRepository {
|
|||||||
required String accountId,
|
required String accountId,
|
||||||
required bool success,
|
required bool success,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
String? stackTrace,
|
||||||
|
bool isPermanent = false,
|
||||||
required String protocol,
|
required String protocol,
|
||||||
required int emailsFetched,
|
required int emailsFetched,
|
||||||
required int emailsSkipped,
|
required int emailsSkipped,
|
||||||
|
|||||||
@@ -260,6 +260,8 @@ class _AccountSync implements _SyncLoop {
|
|||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
success: false,
|
success: false,
|
||||||
errorMessage: e.toString(),
|
errorMessage: e.toString(),
|
||||||
|
stackTrace: st.toString(),
|
||||||
|
isPermanent: isPermanent,
|
||||||
protocol: 'imap',
|
protocol: 'imap',
|
||||||
emailsFetched: 0,
|
emailsFetched: 0,
|
||||||
emailsSkipped: 0,
|
emailsSkipped: 0,
|
||||||
@@ -513,6 +515,8 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
success: false,
|
success: false,
|
||||||
errorMessage: e.toString(),
|
errorMessage: e.toString(),
|
||||||
|
stackTrace: st.toString(),
|
||||||
|
isPermanent: isPermanent,
|
||||||
protocol: 'jmap',
|
protocol: 'jmap',
|
||||||
emailsFetched: 0,
|
emailsFetched: 0,
|
||||||
emailsSkipped: 0,
|
emailsSkipped: 0,
|
||||||
|
|||||||
@@ -192,6 +192,9 @@ class SyncLogs extends Table {
|
|||||||
DateTimeColumn get finishedAt => dateTime()();
|
DateTimeColumn get finishedAt => dateTime()();
|
||||||
// Added in schema v13: raw protocol log when account.verbose == true.
|
// Added in schema v13: raw protocol log when account.verbose == true.
|
||||||
TextColumn get protocolLog => text().nullable()();
|
TextColumn get protocolLog => text().nullable()();
|
||||||
|
// Added in schema v33: stack trace and permanent flag for error entries.
|
||||||
|
TextColumn get errorStackTrace => text().nullable()();
|
||||||
|
BoolColumn get isPermanent => boolean().withDefault(const Constant(false))();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-mailbox breakdown for a single sync cycle.
|
/// Per-mailbox breakdown for a single sync cycle.
|
||||||
@@ -329,7 +332,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 32;
|
int get schemaVersion => 33;
|
||||||
|
|
||||||
Future<void> _createEmailFts() async {
|
Future<void> _createEmailFts() async {
|
||||||
await customStatement('''
|
await customStatement('''
|
||||||
@@ -570,6 +573,10 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
if (from < 32) {
|
if (from < 32) {
|
||||||
await m.createTable(localSieveApplied);
|
await m.createTable(localSieveApplied);
|
||||||
}
|
}
|
||||||
|
if (from >= 7 && from < 33) {
|
||||||
|
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
|
||||||
|
await m.addColumn(syncLogs, syncLogs.isPermanent);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
|||||||
required String accountId,
|
required String accountId,
|
||||||
required bool success,
|
required bool success,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
String? stackTrace,
|
||||||
|
bool isPermanent = false,
|
||||||
required String protocol,
|
required String protocol,
|
||||||
required int emailsFetched,
|
required int emailsFetched,
|
||||||
required int emailsSkipped,
|
required int emailsSkipped,
|
||||||
@@ -30,6 +32,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
|||||||
accountId: accountId,
|
accountId: accountId,
|
||||||
result: success ? 'ok' : 'error',
|
result: success ? 'ok' : 'error',
|
||||||
errorMessage: Value(errorMessage),
|
errorMessage: Value(errorMessage),
|
||||||
|
errorStackTrace: Value(stackTrace),
|
||||||
|
isPermanent: Value(isPermanent),
|
||||||
protocol: Value(protocol),
|
protocol: Value(protocol),
|
||||||
itemsSynced: Value(emailsFetched),
|
itemsSynced: Value(emailsFetched),
|
||||||
emailsSkipped: Value(emailsSkipped),
|
emailsSkipped: Value(emailsSkipped),
|
||||||
@@ -75,6 +79,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
result: r.result,
|
result: r.result,
|
||||||
errorMessage: r.errorMessage,
|
errorMessage: r.errorMessage,
|
||||||
|
stackTrace: r.errorStackTrace,
|
||||||
|
isPermanent: r.isPermanent,
|
||||||
protocol: r.protocol,
|
protocol: r.protocol,
|
||||||
emailsFetched: r.itemsSynced,
|
emailsFetched: r.itemsSynced,
|
||||||
emailsSkipped: r.emailsSkipped,
|
emailsSkipped: r.emailsSkipped,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
@@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/utils/about_markdown.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class AboutScreen extends ConsumerStatefulWidget {
|
class AboutScreen extends ConsumerStatefulWidget {
|
||||||
@@ -19,57 +20,15 @@ class AboutScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||||
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
||||||
|
final Future<AndroidDeviceInfo?> _androidInfoFuture = getAndroidDeviceInfo();
|
||||||
late final Stream<List<Account>> _accountsStream;
|
late final Stream<List<Account>> _accountsStream;
|
||||||
|
|
||||||
static const _gitHash = String.fromEnvironment('GIT_HASH');
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _buildMarkdown(
|
|
||||||
BuildContext context,
|
|
||||||
PackageInfo? pkg,
|
|
||||||
int imapCount,
|
|
||||||
int jmapCount,
|
|
||||||
) {
|
|
||||||
final size = MediaQuery.of(context).size;
|
|
||||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
|
||||||
final physW = (size.width * pixelRatio).toInt();
|
|
||||||
final physH = (size.height * pixelRatio).toInt();
|
|
||||||
final version =
|
|
||||||
pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
|
|
||||||
final versionDisplay = _gitHash.isNotEmpty
|
|
||||||
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
|
|
||||||
: version;
|
|
||||||
final osName = _capitalize(Platform.operatingSystem);
|
|
||||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
|
||||||
|
|
||||||
final gitCommitLine = _gitHash.isNotEmpty
|
|
||||||
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
|
||||||
: '';
|
|
||||||
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
|
||||||
'| Property | Value |\n'
|
|
||||||
'|----------|-------|\n'
|
|
||||||
'| App Version | $versionDisplay |\n'
|
|
||||||
'$gitCommitLine'
|
|
||||||
'| Platform | ${Platform.operatingSystem} |\n'
|
|
||||||
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
|
||||||
'| Resolution | ${physW}x$physH px'
|
|
||||||
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
|
||||||
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
|
||||||
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
|
||||||
'| Processors | ${Platform.numberOfProcessors} |\n'
|
|
||||||
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
|
||||||
'| IMAP Accounts | $imapCount |\n'
|
|
||||||
'| JMAP Accounts | $jmapCount |\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _capitalize(String s) =>
|
|
||||||
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
|
|
||||||
|
|
||||||
Future<void> _copyToClipboard(
|
Future<void> _copyToClipboard(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
int imapCount,
|
int imapCount,
|
||||||
@@ -79,10 +38,17 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
try {
|
try {
|
||||||
pkg = await _packageInfoFuture;
|
pkg = await _packageInfoFuture;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
final androidInfo = await _androidInfoFuture;
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
await Clipboard.setData(
|
await Clipboard.setData(
|
||||||
ClipboardData(
|
ClipboardData(
|
||||||
text: _buildMarkdown(context, pkg, imapCount, jmapCount),
|
text: buildAboutMarkdown(
|
||||||
|
context: context,
|
||||||
|
pkg: pkg,
|
||||||
|
imapCount: imapCount,
|
||||||
|
jmapCount: jmapCount,
|
||||||
|
androidInfo: androidInfo,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -95,30 +61,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(
|
Future<void> _createIssue(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
int imapCount,
|
int imapCount,
|
||||||
@@ -128,9 +70,16 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
try {
|
try {
|
||||||
pkg = await _packageInfoFuture;
|
pkg = await _packageInfoFuture;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
final androidInfo = await _androidInfoFuture;
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
final body = Uri.encodeComponent(
|
final body = Uri.encodeComponent(
|
||||||
_buildMarkdown(context, pkg, imapCount, jmapCount),
|
buildAboutMarkdown(
|
||||||
|
context: context,
|
||||||
|
pkg: pkg,
|
||||||
|
imapCount: imapCount,
|
||||||
|
jmapCount: jmapCount,
|
||||||
|
androidInfo: androidInfo,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
final url = Uri.parse(
|
final url = Uri.parse(
|
||||||
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
||||||
@@ -180,20 +129,29 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
return Markdown(
|
return FutureBuilder<AndroidDeviceInfo?>(
|
||||||
data: _buildMarkdown(
|
future: _androidInfoFuture,
|
||||||
context,
|
builder: (context, androidSnapshot) {
|
||||||
snapshot.data,
|
return Markdown(
|
||||||
imapCount,
|
data: buildAboutMarkdown(
|
||||||
jmapCount,
|
context: context,
|
||||||
),
|
pkg: snapshot.data,
|
||||||
selectable: true,
|
imapCount: imapCount,
|
||||||
onTapLink: (text, href, title) {
|
jmapCount: jmapCount,
|
||||||
if (href != null) {
|
androidInfo: androidSnapshot.data,
|
||||||
unawaited(
|
),
|
||||||
_launchUrl(context, Uri.parse(href)),
|
selectable: true,
|
||||||
);
|
onTapLink: (text, href, title) {
|
||||||
}
|
if (href != null) {
|
||||||
|
unawaited(
|
||||||
|
launchUrl(
|
||||||
|
Uri.parse(href),
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ 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;
|
||||||
@@ -65,7 +64,6 @@ 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;
|
||||||
});
|
});
|
||||||
@@ -87,24 +85,22 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-flight: probe the scanner's permission-state method to verify the
|
// Pre-flight: start + stop the scanner to verify the plugin is available.
|
||||||
// plugin is registered. MissingPluginException is thrown on Android builds
|
// Falls back to text entry on any exception (including MissingPluginException).
|
||||||
// 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 {
|
Future<void> _initScanner() async {
|
||||||
|
MobileScannerController? ctrl;
|
||||||
bool available = false;
|
bool available = false;
|
||||||
try {
|
try {
|
||||||
await const MethodChannel(
|
ctrl = MobileScannerController();
|
||||||
'dev.steenbakker.mobile_scanner/scanner/method',
|
await ctrl.start();
|
||||||
).invokeMethod<int>('state');
|
await ctrl.stop();
|
||||||
available = true;
|
available = true;
|
||||||
} on MissingPluginException {
|
|
||||||
// Plugin not registered on this device; text fallback will be shown.
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Plugin registered but state check failed; let the scanner widget
|
// Plugin not available on this device; text fallback will be shown.
|
||||||
// handle it via its errorBuilder.
|
} finally {
|
||||||
available = true;
|
try {
|
||||||
|
await ctrl?.dispose();
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (available) {
|
if (available) {
|
||||||
@@ -278,7 +274,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_ExpiryHint(expiresAt: _keyExpiresAt!),
|
const _ExpiryHint(),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
if (_errorMessage != null) ...[
|
if (_errorMessage != null) ...[
|
||||||
Text(
|
Text(
|
||||||
@@ -408,37 +404,8 @@ bool _cameraScanSupported() =>
|
|||||||
Platform.isMacOS ||
|
Platform.isMacOS ||
|
||||||
Platform.isWindows;
|
Platform.isWindows;
|
||||||
|
|
||||||
class _ExpiryHint extends StatefulWidget {
|
class _ExpiryHint extends StatelessWidget {
|
||||||
const _ExpiryHint({required this.expiresAt});
|
const _ExpiryHint();
|
||||||
|
|
||||||
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) {
|
||||||
@@ -448,7 +415,7 @@ class _ExpiryHintState extends State<_ExpiryHint> {
|
|||||||
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 ${_formatRemaining()}',
|
'This key expires in 20 minutes',
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -57,24 +57,22 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-flight: probe the scanner's permission-state method to verify the
|
// Pre-flight: start + stop the scanner to verify the plugin is available.
|
||||||
// plugin is registered. MissingPluginException is thrown on Android builds
|
// Falls back to text entry on any exception (including MissingPluginException).
|
||||||
// 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 {
|
Future<void> _initScanner() async {
|
||||||
|
MobileScannerController? ctrl;
|
||||||
bool available = false;
|
bool available = false;
|
||||||
try {
|
try {
|
||||||
await const MethodChannel(
|
ctrl = MobileScannerController();
|
||||||
'dev.steenbakker.mobile_scanner/scanner/method',
|
await ctrl.start();
|
||||||
).invokeMethod<int>('state');
|
await ctrl.stop();
|
||||||
available = true;
|
available = true;
|
||||||
} on MissingPluginException {
|
|
||||||
// Plugin not registered on this device; text fallback will be shown.
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Plugin registered but state check failed; let the scanner widget
|
// Plugin not available on this device; text fallback will be shown.
|
||||||
// handle it via its errorBuilder.
|
} finally {
|
||||||
available = true;
|
try {
|
||||||
|
await ctrl?.dispose();
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (available) {
|
if (available) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -12,8 +13,7 @@ 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:
|
future: rootBundle.loadString('assets/changelog.txt'),
|
||||||
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());
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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';
|
||||||
@@ -18,23 +17,12 @@ class CrashScreen extends StatelessWidget {
|
|||||||
final StackTrace? stackTrace;
|
final StackTrace? stackTrace;
|
||||||
final String gitHash;
|
final String gitHash;
|
||||||
|
|
||||||
String get _buildMode {
|
Future<String> _buildReport() async {
|
||||||
if (kDebugMode) return 'debug';
|
String version = 'unknown';
|
||||||
if (kProfileMode) return 'profile';
|
|
||||||
return 'release';
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> _fetchVersion() async {
|
|
||||||
try {
|
try {
|
||||||
final info = await PackageInfo.fromPlatform();
|
final info = await PackageInfo.fromPlatform();
|
||||||
return '${info.version}+${info.buildNumber}';
|
version = '${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
|
final versionDisplay = gitHash.isNotEmpty
|
||||||
@@ -43,13 +31,9 @@ class CrashScreen extends StatelessWidget {
|
|||||||
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'
|
||||||
: '';
|
: '';
|
||||||
final timestamp = DateTime.now().toUtc().toIso8601String();
|
|
||||||
return 'App Version: $versionDisplay\n'
|
return 'App Version: $versionDisplay\n'
|
||||||
'Build Mode: $_buildMode\n'
|
|
||||||
'$gitLine'
|
'$gitLine'
|
||||||
'Platform: $platform\n'
|
'Platform: $platform\n\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```';
|
||||||
}
|
}
|
||||||
@@ -75,18 +59,6 @@ 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>(
|
FutureBuilder<PackageInfo>(
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ 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());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +90,6 @@ 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,
|
||||||
@@ -355,17 +353,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
testing: _tryTesting,
|
testing: _tryTesting,
|
||||||
okMessage: _tryOk,
|
okMessage: _tryOk,
|
||||||
errorMessage: _tryErr,
|
errorMessage: _tryErr,
|
||||||
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
|
onPressed: _tryConnection,
|
||||||
? _tryConnection
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
FilledButton(
|
FilledButton(onPressed: _save, child: const Text('Save')),
|
||||||
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
|
|
||||||
? _save
|
|
||||||
: null,
|
|
||||||
child: const Text('Save'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/utils/about_markdown.dart';
|
||||||
|
|
||||||
final _timeFmt = DateFormat('MMM d, HH:mm:ss');
|
final _timeFmt = DateFormat('MMM d, HH:mm:ss');
|
||||||
|
|
||||||
@@ -21,6 +25,57 @@ String _fmtBytes(int bytes) {
|
|||||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _buildSyncEntryMarkdown(SyncLogEntry entry) {
|
||||||
|
final buf = StringBuffer();
|
||||||
|
buf.writeln('## Sync Entry');
|
||||||
|
buf.writeln();
|
||||||
|
buf.writeln('| Property | Value |');
|
||||||
|
buf.writeln('|----------|-------|');
|
||||||
|
buf.writeln('| Started | ${_timeFmt.format(entry.startedAt)} |');
|
||||||
|
buf.writeln('| Finished | ${_timeFmt.format(entry.finishedAt)} |');
|
||||||
|
buf.writeln('| Duration | ${_fmtDuration(entry.duration)} |');
|
||||||
|
if (entry.protocol.isNotEmpty) {
|
||||||
|
buf.writeln('| Protocol | ${entry.protocol.toUpperCase()} |');
|
||||||
|
}
|
||||||
|
final statusLabel = entry.isOk
|
||||||
|
? 'OK'
|
||||||
|
: entry.isPermanent
|
||||||
|
? 'Error (permanent)'
|
||||||
|
: 'Error';
|
||||||
|
buf.writeln('| Status | $statusLabel |');
|
||||||
|
buf.writeln('| Emails fetched | ${entry.emailsFetched} |');
|
||||||
|
buf.writeln('| Emails up-to-date | ${entry.emailsSkipped} |');
|
||||||
|
buf.writeln('| Mailboxes synced | ${entry.mailboxesSynced} |');
|
||||||
|
buf.writeln('| Pending changes flushed | ${entry.pendingFlushed} |');
|
||||||
|
buf.writeln('| Data transferred | ${_fmtBytes(entry.bytesTransferred)} |');
|
||||||
|
if (entry.mailboxStats.isNotEmpty) {
|
||||||
|
buf.writeln();
|
||||||
|
buf.writeln('### Per mailbox');
|
||||||
|
buf.writeln();
|
||||||
|
buf.writeln('| Mailbox | Fetched | Up-to-date | Duration |');
|
||||||
|
buf.writeln('|---------|---------|------------|----------|');
|
||||||
|
for (final m in entry.mailboxStats) {
|
||||||
|
final dur = m.duration != null ? _fmtDuration(m.duration!) : '-';
|
||||||
|
buf.writeln('| ${m.mailboxPath} | ${m.fetched} | ${m.skipped} | $dur |');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.errorMessage != null) {
|
||||||
|
buf.writeln();
|
||||||
|
buf.writeln('**Error:**');
|
||||||
|
buf.writeln();
|
||||||
|
buf.writeln(entry.errorMessage);
|
||||||
|
}
|
||||||
|
if (entry.stackTrace != null) {
|
||||||
|
buf.writeln();
|
||||||
|
buf.writeln('**Stack trace:**');
|
||||||
|
buf.writeln();
|
||||||
|
buf.writeln('```');
|
||||||
|
buf.write(entry.stackTrace);
|
||||||
|
buf.writeln('```');
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
class SyncLogScreen extends ConsumerStatefulWidget {
|
class SyncLogScreen extends ConsumerStatefulWidget {
|
||||||
const SyncLogScreen({super.key, required this.accountId});
|
const SyncLogScreen({super.key, required this.accountId});
|
||||||
|
|
||||||
@@ -69,6 +124,41 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
|
|||||||
ref.read(syncManagerProvider).syncNow(widget.accountId);
|
ref.read(syncManagerProvider).syncNow(widget.accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _copyEntry(SyncLogEntry entry, BuildContext context) async {
|
||||||
|
final accounts =
|
||||||
|
await ref.read(accountRepositoryProvider).observeAccounts().first;
|
||||||
|
final imapCount = accounts.where((a) => a.type == AccountType.imap).length;
|
||||||
|
final jmapCount = accounts.where((a) => a.type == AccountType.jmap).length;
|
||||||
|
|
||||||
|
PackageInfo? pkg;
|
||||||
|
try {
|
||||||
|
pkg = await PackageInfo.fromPlatform();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final androidInfo = await getAndroidDeviceInfo();
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
final syncMd = _buildSyncEntryMarkdown(entry);
|
||||||
|
final aboutMd = buildAboutMarkdown(
|
||||||
|
context: context,
|
||||||
|
pkg: pkg,
|
||||||
|
imapCount: imapCount,
|
||||||
|
jmapCount: jmapCount,
|
||||||
|
androidInfo: androidInfo,
|
||||||
|
);
|
||||||
|
await Clipboard.setData(ClipboardData(text: '$syncMd\n$aboutMd'));
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 3),
|
||||||
|
content: Text('Copied to clipboard'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -96,16 +186,20 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
|
|||||||
? const Center(child: Text('No sync entries yet'))
|
? const Center(child: Text('No sync entries yet'))
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
itemCount: _entries.length,
|
itemCount: _entries.length,
|
||||||
itemBuilder: (ctx, i) => _SyncLogTile(entry: _entries[i]),
|
itemBuilder: (ctx, i) => _SyncLogTile(
|
||||||
|
entry: _entries[i],
|
||||||
|
onCopy: () => _copyEntry(_entries[i], ctx),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SyncLogTile extends StatelessWidget {
|
class _SyncLogTile extends StatelessWidget {
|
||||||
const _SyncLogTile({required this.entry});
|
const _SyncLogTile({required this.entry, required this.onCopy});
|
||||||
|
|
||||||
final SyncLogEntry entry;
|
final SyncLogEntry entry;
|
||||||
|
final VoidCallback onCopy;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -115,6 +209,12 @@ class _SyncLogTile extends StatelessWidget {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final errorColor = theme.colorScheme.error;
|
final errorColor = theme.colorScheme.error;
|
||||||
|
|
||||||
|
final subtitleText = entry.isOk
|
||||||
|
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
|
||||||
|
: entry.isPermanent
|
||||||
|
? 'Error (permanent) · took $durationLabel'
|
||||||
|
: 'Error · took $durationLabel';
|
||||||
|
|
||||||
return ExpansionTile(
|
return ExpansionTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
entry.isOk ? Icons.check_circle : Icons.error_outline,
|
entry.isOk ? Icons.check_circle : Icons.error_outline,
|
||||||
@@ -125,11 +225,20 @@ class _SyncLogTile extends StatelessWidget {
|
|||||||
style: entry.isOk ? null : TextStyle(color: errorColor),
|
style: entry.isOk ? null : TextStyle(color: errorColor),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
entry.isOk
|
subtitleText,
|
||||||
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
|
|
||||||
: 'Error · took $durationLabel',
|
|
||||||
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor),
|
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor),
|
||||||
),
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.copy, size: 18),
|
||||||
|
tooltip: 'Copy as markdown',
|
||||||
|
onPressed: onCopy,
|
||||||
|
),
|
||||||
|
const Icon(Icons.expand_more),
|
||||||
|
],
|
||||||
|
),
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(72, 0, 16, 12),
|
padding: const EdgeInsets.fromLTRB(72, 0, 16, 12),
|
||||||
@@ -171,6 +280,31 @@ class _SyncLogTile extends StatelessWidget {
|
|||||||
style: TextStyle(color: errorColor, fontSize: 12),
|
style: TextStyle(color: errorColor, fontSize: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (entry.stackTrace != null) ...[
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 6, bottom: 2),
|
||||||
|
child: Text(
|
||||||
|
'Stack trace',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black87,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
entry.stackTrace!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: Colors.red[300],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
if (entry.protocolLog != null) ...[
|
if (entry.protocolLog != null) ...[
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.only(top: 6, bottom: 2),
|
padding: EdgeInsets.only(top: 6, bottom: 2),
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||||
|
|
||||||
|
/// Builds the About markdown table used in [AboutScreen] and sync log copies.
|
||||||
|
///
|
||||||
|
/// Pass [androidInfo] when running on Android; omit on other platforms.
|
||||||
|
String buildAboutMarkdown({
|
||||||
|
required BuildContext context,
|
||||||
|
PackageInfo? pkg,
|
||||||
|
required int imapCount,
|
||||||
|
required int jmapCount,
|
||||||
|
AndroidDeviceInfo? androidInfo,
|
||||||
|
}) {
|
||||||
|
final size = MediaQuery.of(context).size;
|
||||||
|
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||||
|
final physW = (size.width * pixelRatio).toInt();
|
||||||
|
final physH = (size.height * pixelRatio).toInt();
|
||||||
|
final version = pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
|
||||||
|
final versionDisplay = _gitHash.isNotEmpty
|
||||||
|
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
|
||||||
|
: version;
|
||||||
|
final osName = _capitalize(Platform.operatingSystem);
|
||||||
|
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||||
|
|
||||||
|
final gitCommitLine = _gitHash.isNotEmpty
|
||||||
|
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
||||||
|
: '';
|
||||||
|
final androidLines = androidInfo != null
|
||||||
|
? '| Android Manufacturer | ${androidInfo.manufacturer} |\n'
|
||||||
|
'| Android Model | ${androidInfo.model} |\n'
|
||||||
|
'| Android Version | ${androidInfo.version.release} |\n'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
||||||
|
'| Property | Value |\n'
|
||||||
|
'|----------|-------|\n'
|
||||||
|
'| App Version | $versionDisplay |\n'
|
||||||
|
'$gitCommitLine'
|
||||||
|
'| Platform | ${Platform.operatingSystem} |\n'
|
||||||
|
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
||||||
|
'$androidLines'
|
||||||
|
'| Resolution | ${physW}x$physH px'
|
||||||
|
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
||||||
|
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
||||||
|
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
||||||
|
'| Processors | ${Platform.numberOfProcessors} |\n'
|
||||||
|
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
||||||
|
'| IMAP Accounts | $imapCount |\n'
|
||||||
|
'| JMAP Accounts | $jmapCount |\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches Android device info, or null on non-Android platforms.
|
||||||
|
Future<AndroidDeviceInfo?> getAndroidDeviceInfo() async {
|
||||||
|
if (!Platform.isAndroid) return null;
|
||||||
|
try {
|
||||||
|
return await DeviceInfoPlugin().androidInfo;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _capitalize(String s) =>
|
||||||
|
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
|
||||||
+27
-3
@@ -249,6 +249,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.12"
|
version: "0.7.12"
|
||||||
|
device_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: device_info_plus
|
||||||
|
sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "13.1.0"
|
||||||
|
device_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_platform_interface
|
||||||
|
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.1.0"
|
||||||
drift:
|
drift:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1117,13 +1133,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.2"
|
version: "6.3.2"
|
||||||
url_launcher_android:
|
url_launcher_android:
|
||||||
dependency: "direct overridden"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
|
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.24"
|
version: "6.3.30"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1284,6 +1300,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.0"
|
version: "6.3.0"
|
||||||
|
win32_registry:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32_registry
|
||||||
|
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.3"
|
||||||
workmanager:
|
workmanager:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
+3
-4
@@ -62,6 +62,9 @@ dependencies:
|
|||||||
package_info_plus: ^10.1.0
|
package_info_plus: ^10.1.0
|
||||||
share_plus: ^13.1.0
|
share_plus: ^13.1.0
|
||||||
|
|
||||||
|
# Device hardware info for bug reports
|
||||||
|
device_info_plus: ^13.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
@@ -89,7 +92,3 @@ dependency_overrides:
|
|||||||
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
|
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
|
||||||
# stable Pigeon and is known to work reliably.
|
# stable Pigeon and is known to work reliably.
|
||||||
path_provider_android: ">=2.2.0 <2.2.21"
|
path_provider_android: ">=2.2.0 <2.2.21"
|
||||||
# url_launcher_android 6.3.25 updated to Pigeon 26, which causes a
|
|
||||||
# channel-error on launchUrl on some Android devices (same root cause as
|
|
||||||
# path_provider_android). Pin to <6.3.25 which uses stable Pigeon.
|
|
||||||
url_launcher_android: ">=6.3.0 <6.3.25"
|
|
||||||
|
|||||||
+16
-105
@@ -8,25 +8,21 @@ 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 type=="plan" → post resume comment, set State/Planned, exit 0
|
a. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
|
||||||
b. 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. 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 running → save pending-ci state, exit 0
|
d. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
|
||||||
e. 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 —
|
||||||
f. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
|
section 2a always returns first)
|
||||||
section 2b always returns first)
|
f. Main CI ok (or no run yet) → find oldest Ready issue, start issue agent,
|
||||||
g. Main CI ok (or no run yet) → find oldest ToPlan issue, start plan agent,
|
|
||||||
save state, exit 0
|
save state, exit 0
|
||||||
h. No ToPlan issues → find oldest Ready issue, start issue agent,
|
g. No Ready issues → print "nothing to do", exit 0
|
||||||
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|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.
|
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:
|
||||||
@@ -69,8 +65,6 @@ 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"}
|
||||||
@@ -153,39 +147,17 @@ def _ready_issues() -> list[dict]:
|
|||||||
return ready
|
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:
|
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
|
Using the global latest run (limit=1) is wrong: a passing or failing run
|
||||||
event=push and prettyref=main, so filtering by event alone is not enough.
|
on a PR branch could mask the true state of main. We filter to push
|
||||||
We also require workflow_id == "ci.yml".
|
events on the 'main' prettyref so section-3 logic only reacts to main.
|
||||||
"""
|
"""
|
||||||
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"
|
if run.get("event") == "push" and run.get("prettyref") == "main":
|
||||||
and run.get("prettyref") == "main"
|
|
||||||
and run.get("workflow_id") == "ci.yml"):
|
|
||||||
return run
|
return run
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -265,14 +237,6 @@ 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")
|
||||||
@@ -578,29 +542,13 @@ 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()
|
||||||
|
|
||||||
# ── 2a. Finished planning agent ───────────────────────────────────────────
|
# ── 2. Check for a PR opened by the 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)
|
||||||
@@ -732,9 +680,6 @@ 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)
|
||||||
@@ -831,44 +776,10 @@ 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/ToPlan or State/Ready. Nothing to do.")
|
print("No issues with State/Ready. Nothing to do.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
issue = issues[0]
|
issue = issues[0]
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const _excluded = {
|
|||||||
'lib/ui/widgets/try_connection_button.dart',
|
'lib/ui/widgets/try_connection_button.dart',
|
||||||
'lib/ui/widgets/undo_shell.dart',
|
'lib/ui/widgets/undo_shell.dart',
|
||||||
'lib/ui/screens/about_screen.dart',
|
'lib/ui/screens/about_screen.dart',
|
||||||
|
'lib/ui/utils/about_markdown.dart',
|
||||||
'lib/ui/widgets/email_tile.dart',
|
'lib/ui/widgets/email_tile.dart',
|
||||||
'lib/core/sync/account_sync_manager.dart',
|
'lib/core/sync/account_sync_manager.dart',
|
||||||
'lib/core/sync/background_sync.dart',
|
'lib/core/sync/background_sync.dart',
|
||||||
|
|||||||
+11
-63
@@ -506,40 +506,28 @@ class TestOutputFormat(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestLatestMainCiRun(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"):
|
def test_skips_schedule_runs_returns_push_to_main(self):
|
||||||
return {"event": "push", "prettyref": "main", "workflow_id": "ci.yml",
|
runs = [
|
||||||
"status": status, "id": run_id}
|
{"event": "schedule", "prettyref": "main", "status": "success", "id": 1},
|
||||||
|
{"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_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):
|
def test_returns_none_when_only_schedule_runs_exist(self):
|
||||||
runs = [{"event": "schedule", "prettyref": "main", "workflow_id": "deploy.yml",
|
runs = [
|
||||||
"status": "success", "id": 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_ci_push_to_main_run(self):
|
def test_returns_push_to_main_run(self):
|
||||||
runs = [self._ci_run(42, status="running")]
|
runs = [{"event": "push", "prettyref": "main", "status": "running", "id": 42}]
|
||||||
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)
|
||||||
@@ -745,46 +733,6 @@ 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 TestHeartbeat(unittest.TestCase):
|
class TestHeartbeat(unittest.TestCase):
|
||||||
"""Tests for _update_heartbeat() and cmd_monitor()."""
|
"""Tests for _update_heartbeat() and cmd_monitor()."""
|
||||||
|
|
||||||
|
|||||||
@@ -288,6 +288,8 @@ class _FakeLogs implements SyncLogRepository {
|
|||||||
required String accountId,
|
required String accountId,
|
||||||
required bool success,
|
required bool success,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
String? stackTrace,
|
||||||
|
bool isPermanent = false,
|
||||||
required String protocol,
|
required String protocol,
|
||||||
required int emailsFetched,
|
required int emailsFetched,
|
||||||
required int emailsSkipped,
|
required int emailsSkipped,
|
||||||
|
|||||||
@@ -181,6 +181,8 @@ class FakeSyncLogRepository implements SyncLogRepository {
|
|||||||
required String accountId,
|
required String accountId,
|
||||||
required bool success,
|
required bool success,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
String? stackTrace,
|
||||||
|
bool isPermanent = false,
|
||||||
required String protocol,
|
required String protocol,
|
||||||
required int emailsFetched,
|
required int emailsFetched,
|
||||||
required int emailsSkipped,
|
required int emailsSkipped,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ void main() {
|
|||||||
group('Migration', () {
|
group('Migration', () {
|
||||||
test('schemaVersion matches expected value', () async {
|
test('schemaVersion matches expected value', () async {
|
||||||
final db = AppDatabase(NativeDatabase.memory());
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
expect(db.schemaVersion, 32);
|
expect(db.schemaVersion, 33);
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,6 +194,11 @@ void main() {
|
|||||||
// v32: local_sieve_applied table.
|
// v32: local_sieve_applied table.
|
||||||
await db.customSelect('SELECT count(*) FROM local_sieve_applied').get();
|
await db.customSelect('SELECT count(*) FROM local_sieve_applied').get();
|
||||||
|
|
||||||
|
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
||||||
|
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
||||||
|
expect(syncLogColumns, contains('error_stack_trace'));
|
||||||
|
expect(syncLogColumns, contains('is_permanent'));
|
||||||
|
|
||||||
await db.close();
|
await db.close();
|
||||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
});
|
});
|
||||||
@@ -381,11 +386,16 @@ void main() {
|
|||||||
await _tableColumns(db, 'sync_log_mailboxes');
|
await _tableColumns(db, 'sync_log_mailboxes');
|
||||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||||
|
|
||||||
|
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
||||||
|
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
||||||
|
expect(syncLogColumns, contains('error_stack_trace'));
|
||||||
|
expect(syncLogColumns, contains('is_permanent'));
|
||||||
|
|
||||||
await db.close();
|
await db.close();
|
||||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fresh install creates all tables at schemaVersion 32', () async {
|
test('fresh install creates all tables at schemaVersion 33', () async {
|
||||||
final db = AppDatabase(NativeDatabase.memory());
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
await db.select(db.accounts).get();
|
await db.select(db.accounts).get();
|
||||||
|
|
||||||
@@ -426,6 +436,11 @@ void main() {
|
|||||||
await _tableColumns(db, 'sync_log_mailboxes');
|
await _tableColumns(db, 'sync_log_mailboxes');
|
||||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||||
|
|
||||||
|
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
||||||
|
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
||||||
|
expect(syncLogColumns, contains('error_stack_trace'));
|
||||||
|
expect(syncLogColumns, contains('is_permanent'));
|
||||||
|
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ class _FakeSyncLog implements SyncLogRepository {
|
|||||||
required String accountId,
|
required String accountId,
|
||||||
required bool success,
|
required bool success,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
String? stackTrace,
|
||||||
|
bool isPermanent = false,
|
||||||
required String protocol,
|
required String protocol,
|
||||||
required int emailsFetched,
|
required int emailsFetched,
|
||||||
required int emailsSkipped,
|
required int emailsSkipped,
|
||||||
|
|||||||
@@ -126,4 +126,34 @@ void main() {
|
|||||||
expect(rows.first.result, 'error');
|
expect(rows.first.result, 'error');
|
||||||
expect(rows.first.errorMessage, 'Connection refused');
|
expect(rows.first.errorMessage, 'Connection refused');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stores and retrieves stackTrace and isPermanent on error entries',
|
||||||
|
() async {
|
||||||
|
final repo = SyncLogRepositoryImpl(db);
|
||||||
|
final start = DateTime(2024, 3, 1, 9);
|
||||||
|
final end = DateTime(2024, 3, 1, 9, 0, 1);
|
||||||
|
const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)';
|
||||||
|
|
||||||
|
await repo.log(
|
||||||
|
accountId: 'acc1',
|
||||||
|
success: false,
|
||||||
|
errorMessage: 'MissingPluginException',
|
||||||
|
stackTrace: fakeTrace,
|
||||||
|
isPermanent: true,
|
||||||
|
protocol: 'imap',
|
||||||
|
emailsFetched: 0,
|
||||||
|
emailsSkipped: 0,
|
||||||
|
mailboxesSynced: 0,
|
||||||
|
pendingFlushed: 0,
|
||||||
|
bytesTransferred: 0,
|
||||||
|
startedAt: start,
|
||||||
|
finishedAt: end,
|
||||||
|
);
|
||||||
|
|
||||||
|
final entries = await repo.observeSyncLogs('acc1').first;
|
||||||
|
final entry = entries.firstWhere((e) => e.startedAt == start);
|
||||||
|
expect(entry.stackTrace, fakeTrace);
|
||||||
|
expect(entry.isPermanent, true);
|
||||||
|
expect(entry.errorMessage, 'MissingPluginException');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 []}) {
|
Widget _buildScreen({List<Account> accounts = const []}) {
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
@@ -196,24 +180,4 @@ 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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ void main() {
|
|||||||
expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget);
|
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(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/receive',
|
initialLocation: '/accounts/receive',
|
||||||
@@ -32,106 +32,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
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', () {
|
group('AccountSendScreen', () {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -116,10 +116,7 @@ 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:')));
|
||||||
@@ -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(
|
testWidgets(
|
||||||
'CrashScreen shows app version as clickable link when git hash is set',
|
'CrashScreen shows app version as clickable link when git hash is set',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
|||||||
@@ -106,8 +106,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'try connection button is disabled when no password stored or entered',
|
'try connection shows password required when no password stored', (
|
||||||
(
|
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
tester.view.physicalSize = const Size(800, 1400);
|
tester.view.physicalSize = const Size(800, 1400);
|
||||||
@@ -126,65 +125,11 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final button = tester.widget<OutlinedButton>(
|
await tester.tap(find.byKey(const Key('editTryConnectionButton')));
|
||||||
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.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(
|
// App must not crash; password field shows a validation error.
|
||||||
find.byKey(const Key('editPasswordField')),
|
expect(find.text('Required'), findsOneWidget);
|
||||||
'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 {
|
||||||
|
|||||||
@@ -85,13 +85,11 @@ class FakeAccountRepository implements AccountRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,7 +517,6 @@ List<Override> baseOverrides({
|
|||||||
List<Mailbox>? mailboxes,
|
List<Mailbox>? mailboxes,
|
||||||
DiscoveryResult? discovery,
|
DiscoveryResult? discovery,
|
||||||
Exception? connectionError,
|
Exception? connectionError,
|
||||||
ShareKeyRepository? shareKeyRepository,
|
|
||||||
bool hasStoredPassword = true,
|
bool hasStoredPassword = true,
|
||||||
}) =>
|
}) =>
|
||||||
[
|
[
|
||||||
@@ -536,9 +533,7 @@ List<Override> baseOverrides({
|
|||||||
connectionTestServiceProvider.overrideWithValue(
|
connectionTestServiceProvider.overrideWithValue(
|
||||||
FakeConnectionTestService(error: connectionError),
|
FakeConnectionTestService(error: connectionError),
|
||||||
),
|
),
|
||||||
shareKeyRepositoryProvider.overrideWithValue(
|
shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()),
|
||||||
shareKeyRepository ?? FakeShareKeyRepository(),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user