Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ece6f09e5 | ||
|
|
3f946dfca0 | ||
|
|
c517f604e0 | ||
|
|
7d393ec818 | ||
|
|
5c38357033 | ||
|
|
7715190cbf | ||
|
|
80cde04d87 | ||
|
|
83060bc1bf | ||
|
|
fb6f2cca68 | ||
|
|
71ccf24d0c | ||
|
|
4f6f1d9437 | ||
|
|
833e8d49b0 |
@@ -30,11 +30,48 @@ jobs:
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Locate Docker daemon for local Dagger engine
|
||||
run: |
|
||||
# Skip if remote Dagger engine is already configured (preferred path)
|
||||
if [ -n "${_DAGGER_RUNNER_HOST:-}" ]; then
|
||||
echo "Remote Dagger engine configured, no local Docker needed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Try host Docker socket (DooD) if runner mounts it
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
if DOCKER_HOST=unix:///var/run/docker.sock docker info >/dev/null 2>&1; then
|
||||
echo "Docker available via host socket."
|
||||
echo "DOCKER_HOST=unix:///var/run/docker.sock" >> "$GITHUB_ENV"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "WARNING: No remote Dagger engine and no local Docker found." >&2
|
||||
echo " - Remote engine: check DAGGER_STUNNEL_URL secret and that the host proxy is running." >&2
|
||||
echo " - Local Docker: runner does not expose /var/run/docker.sock." >&2
|
||||
echo "CI will likely fail at the Dagger step." >&2
|
||||
|
||||
- name: Prune Dagger cache before check
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
# prune(maxUsedSpace) also reclaims named cache volumes (gradle-cache, go-build-cache, etc.)
|
||||
# when total cache exceeds the limit; without args only unreferenced entries are removed.
|
||||
run: |
|
||||
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
|
||||
|
||||
- name: Run Full Check Suite
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task check-dagger
|
||||
|
||||
- name: Prune Dagger cache after check
|
||||
if: always()
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: |
|
||||
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
@@ -31,6 +31,7 @@ jobs:
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Run Android Tests on Firebase Test Lab
|
||||
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
|
||||
env:
|
||||
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
|
||||
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
||||
@@ -66,6 +67,7 @@ jobs:
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Publish Android to Play Store
|
||||
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
@@ -102,11 +104,7 @@ jobs:
|
||||
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 }}
|
||||
@@ -145,12 +143,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 }}
|
||||
@@ -191,9 +184,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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
+12
-1
@@ -284,8 +284,13 @@ tasks:
|
||||
for attempt in 1 2 3; do
|
||||
run_dagger "$@" && return 0
|
||||
RC=$?
|
||||
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused" "$DAGGER_OUT"; then
|
||||
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|invalid return status code" "$DAGGER_OUT"; then
|
||||
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
||||
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
|
||||
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
|
||||
dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
|
||||
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
|
||||
sleep 90
|
||||
else
|
||||
return "$RC"
|
||||
fi
|
||||
@@ -315,6 +320,12 @@ tasks:
|
||||
wait "$RECV_PID" 2>/dev/null || true
|
||||
exit $RC
|
||||
|
||||
dagger-prune:
|
||||
desc: Prune the Dagger engine cache (keeps named volumes unless total exceeds 75 GB, then targets 50 GB)
|
||||
cmds:
|
||||
- |
|
||||
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
|
||||
|
||||
integration-android:
|
||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
||||
deps: [_preflight, _android-sdk-check, _android-avd-setup]
|
||||
|
||||
+1
-1
@@ -739,7 +739,7 @@ func (m *Ci) UploadToPlayStore(
|
||||
From("python:3.12-alpine").
|
||||
WithExec([]string{"apk", "add", "--no-cache", "curl"}).
|
||||
WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")).
|
||||
WithExec([]string{"pip", "install", "requests", "google-auth"}).
|
||||
WithExec([]string{"pip", "install", "google-auth", "requests"}).
|
||||
WithFile("/src/build/app/outputs/bundle/release/app-release.aab", aab).
|
||||
WithFile("/src/scripts/deploy_playstore.py", scriptSource.File("scripts/deploy_playstore.py")).
|
||||
WithSecretVariable("PLAY_STORE_CONFIG_JSON", playStoreConfig).
|
||||
|
||||
@@ -94,8 +94,9 @@
|
||||
sqlite
|
||||
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
|
||||
(python3.withPackages (ps: with ps; [
|
||||
google-auth
|
||||
requests
|
||||
google-api-python-client
|
||||
google-auth-httplib2
|
||||
httplib2
|
||||
])) # used by stalwart-dev/start and deploy_playstore.py
|
||||
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
||||
]);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
@@ -24,6 +25,9 @@ const _kResourceType = 'background_check';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void callbackDispatcher() {
|
||||
// Required so that path_provider and other plugins are available in this
|
||||
// background isolate (issue #192).
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
Workmanager().executeTask((_, __) async {
|
||||
try {
|
||||
await _doBackgroundSync();
|
||||
|
||||
@@ -609,6 +609,17 @@ Future<String> _resolveDatabasePath() async {
|
||||
await Future<void>.delayed(Duration(milliseconds: ms));
|
||||
}
|
||||
}
|
||||
// On Android, path_provider can be permanently broken on some devices
|
||||
// regardless of how long we wait (issue #192). Derive the path from
|
||||
// /proc/self/cmdline (the Android process name == package name) without
|
||||
// a platform channel as a last resort so the app can still open its DB.
|
||||
if (Platform.isAndroid) {
|
||||
final fallback = await _androidFallbackPath();
|
||||
if (fallback != null) {
|
||||
_dbPath = fallback;
|
||||
return _dbPath!;
|
||||
}
|
||||
}
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'path_provider unavailable after ${delays.length + 1} attempts — '
|
||||
@@ -616,10 +627,44 @@ Future<String> _resolveDatabasePath() async {
|
||||
);
|
||||
}
|
||||
|
||||
// These two functions are only called from unit tests (database_path_test.dart).
|
||||
// Reads /proc/self/cmdline to extract the Android package name, then
|
||||
// constructs the standard app files-dir path without a platform channel.
|
||||
// Returns null when the path cannot be determined or created.
|
||||
Future<String?> _androidFallbackPath() async {
|
||||
try {
|
||||
final bytes = await File('/proc/self/cmdline').readAsBytes();
|
||||
final end = bytes.indexOf(0);
|
||||
final packageName = String.fromCharCodes(
|
||||
end >= 0 ? bytes.sublist(0, end) : bytes,
|
||||
).trim();
|
||||
// A valid Android package name contains dots but not slashes.
|
||||
if (packageName.isEmpty ||
|
||||
!packageName.contains('.') ||
|
||||
packageName.contains('/')) {
|
||||
return null;
|
||||
}
|
||||
for (final base in [
|
||||
'/data/user/0/$packageName/files',
|
||||
'/data/data/$packageName/files',
|
||||
]) {
|
||||
try {
|
||||
await Directory(base).create(recursive: true);
|
||||
return p.join(base, 'sharedinbox.db');
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// These 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;
|
||||
Future<String?> androidFallbackPathForTesting() => _androidFallbackPath();
|
||||
|
||||
LazyDatabase _openConnection() {
|
||||
return LazyDatabase(() async {
|
||||
|
||||
+11
-12
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -47,10 +47,14 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
final osName = _capitalize(Platform.operatingSystem);
|
||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||
|
||||
return '## sharedinbox.de\n\n'
|
||||
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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+60
-67
@@ -6,76 +6,49 @@ import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import requests
|
||||
from google.auth.transport.requests import AuthorizedSession
|
||||
from google.oauth2 import service_account
|
||||
|
||||
PACKAGE_NAME = "de.sharedinbox.mua"
|
||||
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
||||
TRACK = "internal"
|
||||
_TIMEOUT = 300 # seconds — AAB uploads can be large
|
||||
_MAX_UPLOAD_ATTEMPTS = 3
|
||||
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
|
||||
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
|
||||
_MAX_UPLOAD_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _make_session(config_json: str) -> AuthorizedSession:
|
||||
creds = service_account.Credentials.from_service_account_info(
|
||||
json.loads(config_json),
|
||||
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
||||
def _upload_aab_resumable(session, package, edit_id, aab_path):
|
||||
"""Upload AAB using the Google resumable upload protocol."""
|
||||
file_size = os.path.getsize(aab_path)
|
||||
init_url = f"{_UPLOAD_BASE}/{package}/edits/{edit_id}/bundles"
|
||||
|
||||
# Step 1: initiate the resumable upload session
|
||||
init_resp = session.post(
|
||||
init_url,
|
||||
params={"uploadType": "resumable"},
|
||||
headers={
|
||||
"X-Upload-Content-Type": "application/octet-stream",
|
||||
"X-Upload-Content-Length": str(file_size),
|
||||
"Content-Length": "0",
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
return AuthorizedSession(creds)
|
||||
init_resp.raise_for_status()
|
||||
upload_url = init_resp.headers["Location"]
|
||||
|
||||
|
||||
def _upload_aab(session: AuthorizedSession, edit_id: str) -> int:
|
||||
"""Resumable upload of the AAB. Returns the version code."""
|
||||
file_size = os.path.getsize(AAB_PATH)
|
||||
|
||||
with open(AAB_PATH, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
last_exc = None
|
||||
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
|
||||
try:
|
||||
# Each attempt needs a fresh resumable upload URL — the previous URL expires on failure.
|
||||
init_resp = session.post(
|
||||
f"{_UPLOAD_BASE}/{PACKAGE_NAME}/edits/{edit_id}/bundles",
|
||||
params={"uploadType": "resumable"},
|
||||
headers={
|
||||
"X-Upload-Content-Type": "application/octet-stream",
|
||||
"X-Upload-Content-Length": str(file_size),
|
||||
},
|
||||
json={},
|
||||
timeout=30,
|
||||
)
|
||||
if not init_resp.ok:
|
||||
print(f"Init attempt {attempt + 1} failed: HTTP {init_resp.status_code}: {init_resp.text[:500]}")
|
||||
init_resp.raise_for_status()
|
||||
upload_url = init_resp.headers["Location"]
|
||||
|
||||
upload_resp = session.put(
|
||||
upload_url,
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": str(file_size),
|
||||
},
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
if not upload_resp.ok:
|
||||
print(f"Upload attempt {attempt + 1} failed: HTTP {upload_resp.status_code}: {upload_resp.text[:500]}")
|
||||
upload_resp.raise_for_status()
|
||||
return upload_resp.json()["versionCode"]
|
||||
except requests.RequestException as exc:
|
||||
last_exc = exc
|
||||
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
|
||||
delay = 10 * (2 ** attempt)
|
||||
print(f"Attempt {attempt + 1} failed ({exc}), retrying in {delay}s…")
|
||||
time.sleep(delay)
|
||||
|
||||
raise RuntimeError(
|
||||
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
|
||||
) from last_exc
|
||||
# Step 2: upload the file in a single PUT to the session URI
|
||||
with open(aab_path, "rb") as f:
|
||||
upload_resp = session.put(
|
||||
upload_url,
|
||||
data=f,
|
||||
headers={
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": str(file_size),
|
||||
},
|
||||
timeout=600,
|
||||
)
|
||||
upload_resp.raise_for_status()
|
||||
return upload_resp.json()
|
||||
|
||||
|
||||
def main():
|
||||
@@ -88,25 +61,45 @@ def main():
|
||||
print(f"Error: AAB not found at {AAB_PATH}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
session = _make_session(config_json)
|
||||
|
||||
edit_resp = session.post(
|
||||
f"{_BASE}/{PACKAGE_NAME}/edits",
|
||||
json={},
|
||||
timeout=30,
|
||||
creds = service_account.Credentials.from_service_account_info(
|
||||
json.loads(config_json),
|
||||
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
||||
)
|
||||
session = AuthorizedSession(creds)
|
||||
|
||||
edit_resp = session.post(f"{_BASE}/{PACKAGE_NAME}/edits", json={}, timeout=30)
|
||||
edit_resp.raise_for_status()
|
||||
edit_id = edit_resp.json()["id"]
|
||||
|
||||
version_code = _upload_aab(session, edit_id)
|
||||
last_exc = None
|
||||
bundle = None
|
||||
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
|
||||
try:
|
||||
bundle = _upload_aab_resumable(session, PACKAGE_NAME, edit_id, AAB_PATH)
|
||||
break
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
|
||||
delay = 10 * (2 ** attempt)
|
||||
print(
|
||||
f"Upload attempt {attempt + 1} failed ({type(exc).__name__}: {exc}), "
|
||||
f"retrying in {delay}s…"
|
||||
)
|
||||
time.sleep(delay)
|
||||
if bundle is None:
|
||||
raise RuntimeError(
|
||||
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
|
||||
) from last_exc
|
||||
|
||||
version_code = bundle["versionCode"]
|
||||
print(f"Uploaded AAB, version code: {version_code}")
|
||||
|
||||
tracks_resp = session.put(
|
||||
track_resp = session.put(
|
||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
|
||||
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
||||
timeout=30,
|
||||
)
|
||||
tracks_resp.raise_for_status()
|
||||
track_resp.raise_for_status()
|
||||
|
||||
commit_resp = session.post(
|
||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
|
||||
|
||||
@@ -14,14 +14,42 @@ if [ "$host" == "$port" ]; then
|
||||
port="8774"
|
||||
fi
|
||||
|
||||
echo "Probing $host:$port..."
|
||||
if ! nc -zw 3 "$host" "$port" 2>/dev/null; then
|
||||
echo "Error: No Dagger server responded on $host:$port"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found active Dagger server on $host:$port"
|
||||
MAX_PROBE_ATTEMPTS=5
|
||||
PROBE_DELAY=30
|
||||
for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
|
||||
echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..."
|
||||
if nc -zw 5 "$host" "$port" 2>/dev/null; then
|
||||
echo "Found active server on $host:$port"
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then
|
||||
echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts"
|
||||
echo "Remote engine unavailable — CI will use the local Dagger engine."
|
||||
exit 0
|
||||
fi
|
||||
echo "Dagger server not responding, waiting ${PROBE_DELAY}s before retry..."
|
||||
sleep $PROBE_DELAY
|
||||
done
|
||||
|
||||
# 2. Setup TLS credentials (passed as env vars from secrets)
|
||||
# 2a. Try plain TCP connection first (works when server is a plain TCP proxy, no TLS)
|
||||
echo "Trying plain TCP Dagger connection at tcp://$host:$port..."
|
||||
if _DAGGER_RUNNER_HOST="tcp://$host:$port" \
|
||||
_EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" \
|
||||
timeout 8 dagger version >/dev/null 2>&1; then
|
||||
echo "Plain TCP Dagger connection succeeded — no TLS stunnel needed."
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
|
||||
echo "_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
|
||||
else
|
||||
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port"
|
||||
export _DAGGER_RUNNER_HOST="tcp://$host:$port"
|
||||
echo "Dagger configured at tcp://$host:$port (plain TCP)"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
echo "Plain TCP connection not available; trying TLS stunnel..."
|
||||
|
||||
# 2b. Setup TLS credentials (passed as env vars from secrets)
|
||||
mkdir -p /tmp/dagger-tls
|
||||
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
|
||||
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for deploy_playstore.py."""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
import deploy_playstore
|
||||
|
||||
|
||||
def _make_session(
|
||||
edit_id="edit-42",
|
||||
version_code=7,
|
||||
upload_side_effects=None,
|
||||
):
|
||||
"""Return a mock AuthorizedSession with sensible defaults."""
|
||||
session = MagicMock()
|
||||
|
||||
# POST /edits → create edit
|
||||
edit_resp = MagicMock()
|
||||
edit_resp.json.return_value = {"id": edit_id}
|
||||
session.post.return_value = edit_resp
|
||||
|
||||
# POST resumable-init → Location header
|
||||
init_resp = MagicMock()
|
||||
init_resp.headers = {"Location": "https://upload.example.com/session"}
|
||||
|
||||
# PUT upload → bundle JSON
|
||||
upload_resp = MagicMock()
|
||||
upload_resp.json.return_value = {"versionCode": version_code}
|
||||
|
||||
if upload_side_effects is not None:
|
||||
# Use side_effect list: first call is edit create, rest are upload inits
|
||||
# We override the PUT side effects via _upload_aab_resumable mock instead
|
||||
pass
|
||||
|
||||
return session, init_resp, upload_resp
|
||||
|
||||
|
||||
class TestMainEnvChecks(unittest.TestCase):
|
||||
def test_missing_env_exits(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with self.assertRaises(SystemExit) as ctx:
|
||||
deploy_playstore.main()
|
||||
self.assertEqual(ctx.exception.code, 1)
|
||||
|
||||
def test_missing_aab_exits(self):
|
||||
fake_config = '{"type": "service_account"}'
|
||||
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}):
|
||||
with patch("deploy_playstore.os.path.exists", return_value=False):
|
||||
with self.assertRaises(SystemExit) as ctx:
|
||||
deploy_playstore.main()
|
||||
self.assertEqual(ctx.exception.code, 1)
|
||||
|
||||
|
||||
class TestMainHappyPath(unittest.TestCase):
|
||||
def _run_main(self, fake_config='{"type":"service_account"}'):
|
||||
mock_session = MagicMock()
|
||||
# POST for edit create and commit
|
||||
post_responses = [
|
||||
MagicMock(**{"json.return_value": {"id": "edit-42"}}), # create edit
|
||||
MagicMock(), # commit
|
||||
]
|
||||
mock_session.post.side_effect = post_responses
|
||||
# PUT for track update
|
||||
mock_session.put.return_value = MagicMock()
|
||||
|
||||
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}):
|
||||
with patch("deploy_playstore.os.path.exists", return_value=True):
|
||||
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
|
||||
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
|
||||
with patch(
|
||||
"deploy_playstore._upload_aab_resumable",
|
||||
return_value={"versionCode": 7},
|
||||
):
|
||||
deploy_playstore.main()
|
||||
|
||||
return mock_session
|
||||
|
||||
def test_creates_edit(self):
|
||||
session = self._run_main()
|
||||
create_call = session.post.call_args_list[0]
|
||||
self.assertIn("/edits", create_call[0][0])
|
||||
|
||||
def test_commits_edit(self):
|
||||
session = self._run_main()
|
||||
commit_call = session.post.call_args_list[1]
|
||||
self.assertIn(":commit", commit_call[0][0])
|
||||
|
||||
def test_updates_track(self):
|
||||
session = self._run_main()
|
||||
track_call = session.put.call_args_list[0]
|
||||
self.assertIn("/tracks/", track_call[0][0])
|
||||
|
||||
|
||||
class TestUploadRetry(unittest.TestCase):
|
||||
def _run_main(self, upload_side_effects, sleep_mock=None):
|
||||
mock_session = MagicMock()
|
||||
post_responses = [
|
||||
MagicMock(**{"json.return_value": {"id": "edit-1"}}),
|
||||
MagicMock(),
|
||||
]
|
||||
mock_session.post.side_effect = post_responses
|
||||
mock_session.put.return_value = MagicMock()
|
||||
|
||||
patches = [
|
||||
patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}),
|
||||
patch("deploy_playstore.os.path.exists", return_value=True),
|
||||
patch("deploy_playstore.service_account.Credentials.from_service_account_info"),
|
||||
patch("deploy_playstore.AuthorizedSession", return_value=mock_session),
|
||||
patch("deploy_playstore._upload_aab_resumable", side_effect=upload_side_effects),
|
||||
patch("deploy_playstore.time.sleep"),
|
||||
]
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
deploy_playstore.main()
|
||||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
|
||||
def test_succeeds_on_first_attempt(self):
|
||||
with patch("deploy_playstore._upload_aab_resumable", return_value={"versionCode": 5}) as mock_upload:
|
||||
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
|
||||
with patch("deploy_playstore.os.path.exists", return_value=True):
|
||||
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
|
||||
mock_session = MagicMock()
|
||||
mock_session.post.side_effect = [
|
||||
MagicMock(**{"json.return_value": {"id": "e1"}}),
|
||||
MagicMock(),
|
||||
]
|
||||
mock_session.put.return_value = MagicMock()
|
||||
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
|
||||
deploy_playstore.main()
|
||||
mock_upload.assert_called_once()
|
||||
|
||||
def test_retries_once_on_error_then_succeeds(self):
|
||||
self._run_main([ValueError("transient"), {"versionCode": 9}])
|
||||
|
||||
def test_raises_after_all_attempts_exhausted(self):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
self._run_main([ValueError("err"), ValueError("err"), ValueError("err")])
|
||||
self.assertIn(str(deploy_playstore._MAX_UPLOAD_ATTEMPTS), str(ctx.exception))
|
||||
|
||||
def test_backoff_delays_are_10s_then_20s(self):
|
||||
mock_session = MagicMock()
|
||||
mock_session.post.side_effect = [
|
||||
MagicMock(**{"json.return_value": {"id": "e1"}}),
|
||||
MagicMock(),
|
||||
]
|
||||
mock_session.put.return_value = MagicMock()
|
||||
|
||||
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
|
||||
with patch("deploy_playstore.os.path.exists", return_value=True):
|
||||
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
|
||||
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
|
||||
with patch(
|
||||
"deploy_playstore._upload_aab_resumable",
|
||||
side_effect=[ValueError("e"), ValueError("e"), {"versionCode": 3}],
|
||||
):
|
||||
with patch("deploy_playstore.time.sleep") as mock_sleep:
|
||||
deploy_playstore.main()
|
||||
|
||||
mock_sleep.assert_has_calls([call(10), call(20)])
|
||||
|
||||
|
||||
class TestUploadAabResumable(unittest.TestCase):
|
||||
def test_initiates_and_uploads(self):
|
||||
mock_session = MagicMock()
|
||||
init_resp = MagicMock()
|
||||
init_resp.headers = {"Location": "https://upload.example.com/sess"}
|
||||
upload_resp = MagicMock()
|
||||
upload_resp.json.return_value = {"versionCode": 42}
|
||||
mock_session.post.return_value = init_resp
|
||||
mock_session.put.return_value = upload_resp
|
||||
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||
f.write(b"fake-aab-content")
|
||||
aab_path = f.name
|
||||
|
||||
try:
|
||||
result = deploy_playstore._upload_aab_resumable(
|
||||
mock_session, "com.example.app", "edit-1", aab_path
|
||||
)
|
||||
finally:
|
||||
os.unlink(aab_path)
|
||||
|
||||
self.assertEqual(result["versionCode"], 42)
|
||||
mock_session.post.assert_called_once()
|
||||
mock_session.put.assert_called_once()
|
||||
put_call = mock_session.put.call_args
|
||||
self.assertEqual(put_call[0][0], "https://upload.example.com/sess")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -129,5 +130,27 @@ void main() {
|
||||
);
|
||||
});
|
||||
},
|
||||
// The Android fallback runs only on Android, so on the host machine the
|
||||
// exception is still thrown after all retries. Skip on Android to avoid
|
||||
// depending on /data/user/0/... being absent in the test environment.
|
||||
skip: Platform.isAndroid,
|
||||
);
|
||||
|
||||
// Regression test for issue #192: _androidFallbackPath must return null when
|
||||
// the process cmdline does not look like an Android package name (e.g. on
|
||||
// the host test machine where the process is the Dart executable).
|
||||
test(
|
||||
'_androidFallbackPath returns null when process name is not a package name',
|
||||
() async {
|
||||
// On non-Android platforms the host process cmdline is a file-system path
|
||||
// (starts with '/'), which the fallback correctly rejects. On Android
|
||||
// the process IS named after the package — the fallback is free to
|
||||
// succeed or return null depending on the device state; we do not assert
|
||||
// here so as not to constrain Android behaviour.
|
||||
if (!Platform.isAndroid) {
|
||||
final result = await androidFallbackPathForTesting();
|
||||
expect(result, isNull);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,6 +151,10 @@ void main() {
|
||||
expect(clipboardText, contains('Dark Mode'));
|
||||
expect(clipboardText, contains('IMAP Accounts'));
|
||||
expect(clipboardText, contains('JMAP Accounts'));
|
||||
expect(
|
||||
clipboardText,
|
||||
contains('[sharedinbox.de](https://sharedinbox.de)'),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('AboutScreen create-issue button opens Codeberg URL', (
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user