Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 27492127d2 fix: migrate to Riverpod 3.x and update dependencies (#175)
- Bump flutter_riverpod to ^3.0.0 (resolves to 3.3.1)
- Migrate UndoService from StateNotifier/StateNotifierProvider to
  Notifier/NotifierProvider; ref is now built-in, build() returns
  initial state
- Migrate EmailDetailNotifier from AutoDisposeFamilyAsyncNotifier to
  AsyncNotifier with constructor-injected emailId; family arg passed
  via EmailDetailNotifier(this._emailId)
- Replace deprecated AsyncValue.valueOrNull with .value throughout
  email_list_screen and email_detail_screen
- Import Override from flutter_riverpod/misc.dart in all files that use
  it as a type (no longer re-exported from flutter_riverpod.dart)
- Explicitly type syncLogRepositoryProvider as Provider<SyncLogRepository>
  so the abstract interface is used at the provider boundary
- Fix widget-test pending-timer failures caused by Drift StreamQuery
  cache debouncing: add syncHealthProvider and syncLogRepositoryProvider
  no-op overrides in buildApp() so no real Drift streams are opened
  during test teardown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 19:43:21 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 9f657cd624 fix(ci): replace continue-on-error with conditional step execution (#172)
Remove all continue-on-error usages from CI workflows:
- deploy.yml: replace continue-on-error on SSH deploy steps with
  if: secrets.SSH_PRIVATE_KEY != '' so steps are skipped (not failed)
  when the secret is absent
- windows-nightly.yml: remove continue-on-error from job and steps
  (job is already disabled via if: false)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 19:03:04 +02:00
Bot of Thomas Güttler 6adba9b001 perf: parallelize APK deploy and reduce fetch-depth in deploy.yml (#171) (#188) 2026-05-23 18:55:08 +02:00
Bot of Thomas Güttler 11d9805fca test: cover _resolveDatabasePath retry logic (#167) (#187) 2026-05-23 18:35:15 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 3019fdf145 refactor(deploy_cron): trigger Forgejo Actions workflow via fgj instead of deploying locally
Replace local `task publish-website` invocation with `fgj actions workflow run website.yml`
so the deploy runs in CI rather than on the local machine. Remove failure-tracking state
files and issue-creation logic — Forgejo Actions handles its own reporting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:42:20 +02:00
Bot of Thomas Güttler 14342f6472 fix: use exact grep patterns for build_runner and flutter pub get (#136) (#159) 2026-05-23 17:25:08 +02:00
Bot of Thomas Güttler b86c1a5c69 fix: verify Hugo binary SHA-256 checksum after download (#162) (#182) 2026-05-23 17:10:11 +02:00
17 changed files with 203 additions and 176 deletions
+39 -22
View File
@@ -14,7 +14,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 50
fetch-depth: 1
- name: Check runner tools
run: |
@@ -49,7 +49,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 50
fetch-depth: 1
- name: Check runner tools
run: |
@@ -73,12 +73,36 @@ jobs:
DAGGER_NO_NAG: "1"
run: task publish-android
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
deploy-apk:
name: Build & Deploy APK to Server
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Build & Deploy APK to server
# continue-on-error: step requires SSH_PRIVATE_KEY secret; if unset the task
# precondition fails, but we don't want that to fail the whole job — the Play
# Store publish above already succeeded. The overall job stays green even
# though this step shows as failed/orange in the UI.
continue-on-error: true
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
@@ -100,7 +124,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 50
fetch-depth: 1
- name: Check runner tools
run: |
@@ -117,12 +141,7 @@ jobs:
run: scripts/setup_dagger_remote.sh
- name: Build & Deploy Linux to server
# continue-on-error: step requires SSH_PRIVATE_KEY secret; if unset the task
# precondition fails, but the build step that precedes this (done via Dagger)
# already succeeded. Deployment is best-effort; a missing secret should not
# turn the job red. The step will show as failed/orange in the UI even though
# the overall job is green — this is intentional.
continue-on-error: true
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
@@ -137,16 +156,16 @@ jobs:
publish-website:
name: Publish Website Build History
runs-on: ubuntu-latest
needs: [build-linux, deploy-playstore]
needs: [build-linux, deploy-playstore, deploy-apk]
if: |
always() &&
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success')
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 50
fetch-depth: 1
- name: Check runner tools
run: |
@@ -163,9 +182,7 @@ jobs:
run: scripts/setup_dagger_remote.sh
- name: Generate build history and deploy website
# continue-on-error: website publish is best-effort; a missing SSH_PRIVATE_KEY
# should not block the overall workflow status.
continue-on-error: true
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
@@ -180,7 +197,7 @@ jobs:
label-deploy-health:
name: Update Deploy Health Label
runs-on: ubuntu-latest
needs: [test-android-firebase, deploy-playstore, build-linux]
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux]
if: always() && vars.DEPLOY_HEALTH_ISSUE != ''
timeout-minutes: 5
@@ -190,7 +207,7 @@ jobs:
FORGEJO_TOKEN: ${{ github.token }}
FORGEJO_URL: ${{ github.server_url }}
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
ALL_SUCCEEDED: ${{ needs.test-android-firebase.result == 'success' && needs.deploy-playstore.result == 'success' && needs.build-linux.result == 'success' }}
ALL_SUCCEEDED: ${{ needs.test-android-firebase.result == 'success' && needs.deploy-playstore.result == 'success' && needs.deploy-apk.result == 'success' && needs.build-linux.result == 'success' }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error
-3
View File
@@ -11,7 +11,6 @@ jobs:
name: Build & Deploy Windows (Nightly)
runs-on: windows-runner
if: false
continue-on-error: true
steps:
- uses: actions/checkout@v4
@@ -32,7 +31,6 @@ jobs:
- name: Set up SSH key
if: env.SKIP_BUILD != 'true'
continue-on-error: true
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
@@ -42,7 +40,6 @@ jobs:
- name: Deploy Windows to server
if: env.SKIP_BUILD != 'true'
continue-on-error: true
env:
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
+3 -3
View File
@@ -221,7 +221,7 @@ func (m *Ci) pubGetLayer() *dagger.Container {
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter pub get >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -vE '^[+~><] ' "$tmp" || true`}).
`grep -vE '^(\+|Downloading packages)' "$tmp" || true`}).
WithExec([]string{"python3", "-c",
"import json, os\n" +
"f='.dart_tool/package_config.json'; d=json.load(open(f)); [d.pop(k,None) for k in ('generated','generatorVersion')]; json.dump(d,open(f,'w'))\n" +
@@ -245,7 +245,7 @@ func (m *Ci) codegenBase() *dagger.Container {
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -vE '^\[' "$tmp" || true`})
`grep -vE '^\[.*s\] \|' "$tmp" || true`})
}
// setup overlays platform-specific source files onto the shared codegen base.
@@ -411,7 +411,7 @@ func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -vE '^\[' "$tmp" || true`}).
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
Stdout(ctx)
}
+9 -105
View File
@@ -1,24 +1,17 @@
#!/usr/bin/env python3
"""
Cron deploy script for sharedinbox website.
Runs every 5 minutes; skips if origin/main has not changed since last successful deploy.
Gives up and creates a Codeberg issue after 5 consecutive failures on the same commit.
Runs every 5 minutes; skips if origin/main has not changed since last trigger.
Triggers the 'Deploy Website' Forgejo Actions workflow via fgj on each new commit.
Forgejo Actions handles failure reporting.
"""
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
REPO_DIR = Path(__file__).parent.resolve()
SHA_FILE = REPO_DIR / '.last_deployed_sha'
FAILED_SHA_FILE = REPO_DIR / '.last_failed_sha'
FAIL_COUNT_FILE = REPO_DIR / '.fail_count'
ERROR_FILE = REPO_DIR / '.last_deploy_error'
ISSUE_SHA_FILE = REPO_DIR / '.last_issue_sha'
MAX_FAILURES = 5
SHA_FILE = REPO_DIR / '.last_deployed_sha'
REPO = 'guettli/sharedinbox'
CODEBERG = 'https://codeberg.org'
def git(*args):
@@ -32,70 +25,6 @@ def read(path: Path) -> str:
return path.read_text().strip() if path.exists() else ''
def read_int(path: Path) -> int:
try:
return int(read(path))
except ValueError:
return 0
def issue_exists_for(sha: str) -> bool:
"""Check Codeberg for an open issue referencing this commit SHA."""
result = subprocess.run(
['tea', 'issue', 'list', '--repo', REPO, '--state', 'open',
'--limit', '50', '--output', 'simple'],
capture_output=True, text=True,
)
return sha[:8] in result.stdout
def create_issue(failed_sha: str, fail_count: int) -> None:
error_output = read(ERROR_FILE)
tail = '\n'.join(error_output.splitlines()[-40:]) if error_output else '(no output captured)'
commit_url = f'{CODEBERG}/{REPO}/commit/{failed_sha}'
script_url = f'{CODEBERG}/{REPO}/src/branch/main/deploy_cron.py'
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
title = f'Deploy failed {fail_count}x on {failed_sha[:8]} — needs fix'
body = f"""\
## Deploy failure — action needed
The automated deploy cron failed **{fail_count} times** on commit \
[{failed_sha[:8]}]({commit_url}) and has stopped retrying.
| | |
|---|---|
| **Detected** | {timestamp} |
| **Failing commit** | [{failed_sha}]({commit_url}) |
| **Failures** | {fail_count} / {MAX_FAILURES} |
| **Deploy script** | [deploy_cron.py]({script_url}) |
| **Log file** | `~/si-deploy-cron/deploy.log` |
### Last deploy output
```
{tail}
```
### Next steps
Push a fix to `main` — the cron (every 5 min) will retry automatically on the next commit.
"""
result = subprocess.run(
['tea', 'issue', 'create',
'--repo', REPO,
'--title', title,
'--description', body,
'--labels', 'State/Ready,Prio/High'],
capture_output=True, text=True,
)
if result.returncode != 0:
print(f'Failed to create issue: {result.stderr}', file=sys.stderr)
else:
print(f'Issue created: {result.stdout.strip()}')
def main():
try:
git('fetch', 'origin', 'main')
@@ -103,48 +32,23 @@ def main():
print(f'git fetch failed (transient?): {exc} — skipping this run.', file=sys.stderr)
return
remote_sha = git('rev-parse', 'origin/main')
last_sha = read(SHA_FILE)
last_failed = read(FAILED_SHA_FILE)
fail_count = read_int(FAIL_COUNT_FILE) if remote_sha == last_failed else 0
last_issue = read(ISSUE_SHA_FILE)
last_sha = read(SHA_FILE)
if remote_sha == last_sha:
print(f'No changes since {remote_sha[:8]}, skipping.')
return
if fail_count >= MAX_FAILURES:
if remote_sha != last_issue and not issue_exists_for(remote_sha):
print(f'{remote_sha[:8]} failed {fail_count}x — creating issue.')
create_issue(remote_sha, fail_count)
ISSUE_SHA_FILE.write_text(remote_sha + '\n')
else:
print(f'{remote_sha[:8]} failed {fail_count}x, issue already exists, skipping.')
return
attempt = fail_count + 1
print(f'Deploying {remote_sha[:8]} (attempt {attempt}/{MAX_FAILURES}, was {last_sha[:8] or "none"})...')
git('pull', '--ff-only', 'origin', 'main')
print(f'New commit {remote_sha[:8]} (was {last_sha[:8] or "none"}) — triggering workflow...')
result = subprocess.run(
['task', 'publish-website'],
cwd=REPO_DIR,
['fgj', 'actions', 'workflow', 'run', 'website.yml', '-R', REPO],
capture_output=True, text=True,
)
combined = result.stdout + result.stderr
print(combined, end='')
if result.returncode != 0:
print(f'Deploy failed (exit {result.returncode}), attempt {attempt}/{MAX_FAILURES}', file=sys.stderr)
FAILED_SHA_FILE.write_text(remote_sha + '\n')
FAIL_COUNT_FILE.write_text(str(attempt) + '\n')
ERROR_FILE.write_text(combined)
print(f'fgj workflow run failed: {result.stderr}', file=sys.stderr)
sys.exit(1)
SHA_FILE.write_text(remote_sha + '\n')
for f in (FAILED_SHA_FILE, FAIL_COUNT_FILE, ERROR_FILE, ISSUE_SHA_FILE):
f.unlink(missing_ok=True)
print('Deploy complete.')
print('Workflow triggered.')
if __name__ == '__main__':
+16 -15
View File
@@ -4,38 +4,39 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
class UndoService extends StateNotifier<List<UndoAction>> {
UndoService(this._ref) : super([]);
final Ref _ref;
class UndoService extends Notifier<List<UndoAction>> {
static const int _maxHistory = 10;
// Resolves once init() has loaded persisted history. Default to an already-
// resolved future so operations are safe even if init() is never called.
Future<void> _ready = Future.value();
// Resolves once build() has loaded persisted history.
late Future<void> _ready;
Future<void> init() async {
_ready = _ref.read(undoRepositoryProvider).getHistory().then((history) {
if (mounted) state = history;
@override
List<UndoAction> build() {
_ready = ref.read(undoRepositoryProvider).getHistory().then((history) {
if (ref.mounted) state = history;
});
await _ready;
return [];
}
/// Waits for the persisted history to finish loading. Called by tests to
/// ensure the provider is ready before asserting state.
Future<void> init() => _ready;
Future<void> pushAction(UndoAction action) async {
await _ready;
final newList = [...state, action];
if (newList.length > _maxHistory) {
final removed = newList.removeAt(0);
await _ref.read(undoRepositoryProvider).deleteAction(removed.id);
await ref.read(undoRepositoryProvider).deleteAction(removed.id);
}
state = newList;
await _ref.read(undoRepositoryProvider).saveAction(action);
await ref.read(undoRepositoryProvider).saveAction(action);
}
Future<void> clear() async {
await _ready;
state = [];
unawaited(_ref.read(undoRepositoryProvider).clearHistory());
unawaited(ref.read(undoRepositoryProvider).clearHistory());
}
Future<void> undo({String? actionId}) async {
@@ -57,7 +58,7 @@ class UndoService extends StateNotifier<List<UndoAction>> {
// happened and retry if the undo failed (e.g. after an IMAP sync reverted
// the local change). The inverse action added below allows undoing the undo.
final repo = _ref.read(emailRepositoryProvider);
final repo = ref.read(emailRepositoryProvider);
for (final id in action.emailIds) {
// 1. Try to cancel the original change (if not started yet).
+5
View File
@@ -616,6 +616,11 @@ Future<String> _resolveDatabasePath() async {
);
}
// These two functions are only called from unit tests (database_path_test.dart).
// They expose internals that cannot be reached via the public API.
Future<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
void resetDatabasePathForTesting() => _dbPath = null;
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final file = File(await _resolveDatabasePath());
+11 -12
View File
@@ -11,6 +11,7 @@ import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/repositories/undo_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart';
@@ -101,7 +102,7 @@ final searchHistoryRepositoryProvider =
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
});
final syncLogRepositoryProvider = Provider((ref) {
final syncLogRepositoryProvider = Provider<SyncLogRepository>((ref) {
return SyncLogRepositoryImpl(ref.watch(dbProvider));
});
@@ -181,11 +182,7 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
});
final undoServiceProvider =
StateNotifierProvider<UndoService, List<UndoAction>>((ref) {
final service = UndoService(ref);
unawaited(service.init());
return service;
});
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
/// Loads email header + body and marks the email as seen.
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
@@ -194,16 +191,18 @@ final emailDetailProvider = AsyncNotifierProvider.autoDispose
EmailDetailNotifier.new,
);
class EmailDetailNotifier
extends AutoDisposeFamilyAsyncNotifier<(Email?, EmailBody), String> {
class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
EmailDetailNotifier(this._emailId);
final String _emailId;
@override
Future<(Email?, EmailBody)> build(String emailId) async {
Future<(Email?, EmailBody)> build() async {
final repo = ref.read(emailRepositoryProvider);
final results = await Future.wait([
repo.getEmail(emailId),
repo.getEmailBody(emailId),
repo.getEmail(_emailId),
repo.getEmailBody(_emailId),
]);
unawaited(repo.setFlag(emailId, seen: true));
unawaited(repo.setFlag(_emailId, seen: true));
return (results[0] as Email?, results[1] as EmailBody);
}
}
+1
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:sharedinbox/core/services/notification_service.dart';
import 'package:sharedinbox/core/sync/background_sync.dart';
+3 -3
View File
@@ -43,15 +43,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
ref.listen<AsyncValue<(Email?, EmailBody)>>(
emailDetailProvider(widget.emailId),
(_, next) {
final email = next.valueOrNull?.$1;
final email = next.value?.$1;
if (email != null && mounted) {
setState(() => _isFlagged = email.isFlagged);
}
},
);
final header = detail.valueOrNull?.$1;
final body = detail.valueOrNull?.$2;
final header = detail.value?.$1;
final body = detail.value?.$2;
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
+3 -3
View File
@@ -261,9 +261,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget _buildSyncButton(EmailRepository emailRepo) {
final isSyncing =
ref.watch(isSyncingProvider(widget.accountId)).valueOrNull ?? false;
ref.watch(isSyncingProvider(widget.accountId)).value ?? false;
final hasError =
ref.watch(syncLastErrorProvider(widget.accountId)).valueOrNull != null;
ref.watch(syncLastErrorProvider(widget.accountId)).value != null;
return IconButton(
tooltip: isSyncing
? 'Syncing…'
@@ -350,7 +350,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget _buildSyncErrorBanner() {
final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId));
final error = errorAsync.valueOrNull;
final error = errorAsync.value;
if (error == null || error == _dismissedError) {
return const SizedBox.shrink();
}
+4 -4
View File
@@ -415,10 +415,10 @@ packages:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
version: "3.3.1"
flutter_secure_storage:
dependency: "direct main"
description:
@@ -891,10 +891,10 @@ packages:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
version: "3.2.1"
share_plus:
dependency: "direct main"
description:
+1 -1
View File
@@ -24,7 +24,7 @@ dependencies:
path: ^1.9.1
# State management
flutter_riverpod: ^2.6.1
flutter_riverpod: ^3.0.0
# Navigation
go_router: ^17.2.3
+92
View File
@@ -1,3 +1,6 @@
import 'dart:async';
import 'package:fake_async/fake_async.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
@@ -19,6 +22,30 @@ class _UnavailablePathProvider extends Fake
}
}
// Fake PathProviderPlatform that fails the first [failCount] calls, then
// returns a fixed path. Used to exercise the retry loop in
// _resolveDatabasePath() without waiting for real timers.
class _SucceedAfterNPathProvider extends Fake
with MockPlatformInterfaceMixin
implements PathProviderPlatform {
_SucceedAfterNPathProvider({required this.failCount});
final int failCount;
int _callCount = 0;
@override
Future<String?> getApplicationSupportPath() async {
_callCount++;
if (_callCount <= failCount) {
throw PlatformException(
code: 'channel-error',
message: 'Simulated: path_provider channel not ready',
);
}
return '/tmp/test_app_support';
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -38,4 +65,69 @@ void main() {
await expectLater(initDatabasePath(), completes);
},
);
// Tests for _resolveDatabasePath() — the lazy retry path called on first DB
// access when initDatabasePath() already failed. fake_async lets us advance
// the back-off timers without waiting real-world milliseconds.
test(
'_resolveDatabasePath retries and eventually succeeds after transient failures',
() {
resetDatabasePathForTesting();
final prev = PathProviderPlatform.instance;
// Fail 3 times, succeed on the 4th call. The delays in
// _resolveDatabasePath are [200, 500, 1000, 2000, 4000] ms, so three
// failures cost 200+500+1000 = 1700 ms before the fourth attempt.
PathProviderPlatform.instance = _SucceedAfterNPathProvider(failCount: 3);
addTearDown(() {
PathProviderPlatform.instance = prev;
resetDatabasePathForTesting();
});
fakeAsync((fake) {
String? result;
unawaited(resolveDatabasePathForTesting().then((r) => result = r));
// Advance fake time through the three back-off delays.
fake.elapse(const Duration(milliseconds: 200 + 500 + 1000 + 1));
expect(result, isNotNull);
expect(result, endsWith('sharedinbox.db'));
});
},
);
test(
'_resolveDatabasePath throws PlatformException after exhausting all retries',
() {
resetDatabasePathForTesting();
final prev = PathProviderPlatform.instance;
PathProviderPlatform.instance = _UnavailablePathProvider();
addTearDown(() {
PathProviderPlatform.instance = prev;
resetDatabasePathForTesting();
});
fakeAsync((fake) {
Object? caughtError;
unawaited(
resolveDatabasePathForTesting().catchError((Object e) {
caughtError = e;
return ''; // ignored; satisfies the Future<String> return type
}),
);
// Advance past all five back-off delays: 200+500+1000+2000+4000 ms.
fake.elapse(
const Duration(milliseconds: 200 + 500 + 1000 + 2000 + 4000 + 1),
);
expect(caughtError, isA<PlatformException>());
expect(
(caughtError! as PlatformException).message,
contains('cannot open database'),
);
});
},
);
}
+1
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
+1 -1
View File
@@ -3,7 +3,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart';
+13 -3
View File
@@ -6,6 +6,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/account.dart';
@@ -19,6 +20,7 @@ import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
@@ -473,10 +475,18 @@ Widget buildApp({
);
return ProviderScope(
// Always neutralise the ManageSieve probe so widget tests never open a
// real socket. Tests that need to assert on probe behaviour should supply
// their own override before this default in [overrides].
// Defaults come first so tests can override them via [overrides].
//
// syncHealthProvider and syncLogRepositoryProvider are backed by Drift
// StreamQueries. When a StreamProvider that wraps a Drift query is disposed,
// Drift schedules a Timer.run() for cache debouncing. Flutter's test
// framework then fails the test with "A Timer is still pending". Replacing
// these with simple synchronous streams avoids the pending-timer assertion.
overrides: [
syncHealthProvider.overrideWith((ref, _) => Stream.value(null)),
syncLogRepositoryProvider.overrideWithValue(
const NoOpSyncLogRepository(),
),
...overrides,
manageSieveProbeServiceProvider.overrideWith(
(ref) => _NoOpManageSieveProbeService(),