feat: pre-fetch email bodies for offline access (#400)
Closes #373 ## Summary - **Schema v38**: two new columns on `user_preferences` — `prefetch_mode` (default `wifiOnly`) and `body_cache_limit_mb` (default 100 MB). - **`BodyCacheService`**: queries for emails that have no cached body, fetches them newest-first in batches of 20, and evicts the oldest cached bodies when the configured size limit is exceeded. - **Separate WorkManager task** (`si_bg_prefetch`): runs hourly with `NetworkType.unmetered` (Wi-Fi) or `NetworkType.connected` (any) depending on the user's choice. The task is cancelled when prefetch is disabled. - **App startup**: reads the stored preference from the DB and re-registers the WorkManager task with the correct constraint. - **Preferences screen**: radio group for prefetch mode (Wi-Fi only / Any network / Disabled) and a dropdown for cache size limit (50 / 100 / 200 / 500 MB). ## What is NOT downloaded Binary attachments are never fetched — `getEmailBody()` stores only `textBody` and `htmlBody`. The cache size limit + per-run batch cap (20 emails) keep storage bounded even on large mailboxes. ## Test plan - [x] `task analyze` — no issues - [x] `task test` — all 492 tests pass (incl. updated migration_test.dart for v38) Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de> Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/400
This commit was merged in pull request #400.
This commit is contained in:
committed by
guettli
co-authored by
guettli
Thomas SharedInbox
parent
09e20dd85f
commit
674d402ff9
@@ -1 +1 @@
|
||||
const int dbSchemaVersion = 37;
|
||||
const int dbSchemaVersion = 38;
|
||||
|
||||
@@ -2,13 +2,30 @@ enum MenuPosition { bottom, top }
|
||||
|
||||
enum AfterMailViewAction { nextMessage, showMailbox }
|
||||
|
||||
enum PrefetchMode {
|
||||
disabled,
|
||||
wifiOnly,
|
||||
always;
|
||||
|
||||
static PrefetchMode fromString(String? value) {
|
||||
return PrefetchMode.values.firstWhere(
|
||||
(e) => e.name == value,
|
||||
orElse: () => PrefetchMode.wifiOnly,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserPreferences {
|
||||
const UserPreferences({
|
||||
this.menuPosition = MenuPosition.bottom,
|
||||
this.mailViewButtonPosition = MenuPosition.bottom,
|
||||
this.afterMailViewAction = AfterMailViewAction.nextMessage,
|
||||
this.prefetchMode = PrefetchMode.wifiOnly,
|
||||
this.bodyCacheLimitMb = 100,
|
||||
});
|
||||
final MenuPosition menuPosition;
|
||||
final MenuPosition mailViewButtonPosition;
|
||||
final AfterMailViewAction afterMailViewAction;
|
||||
final PrefetchMode prefetchMode;
|
||||
final int bodyCacheLimitMb;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ abstract class UserPreferencesRepository {
|
||||
Future<void> updateMenuPosition(MenuPosition position);
|
||||
Future<void> updateMailViewButtonPosition(MenuPosition position);
|
||||
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
|
||||
Future<void> updatePrefetchMode(PrefetchMode mode);
|
||||
Future<void> updateBodyCacheLimitMb(int mb);
|
||||
|
||||
Stream<List<String>> observeTrustedImageSenders();
|
||||
Future<void> addTrustedImageSender(String senderEmail);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||
|
||||
/// Prefetches email bodies in the background and enforces a local cache size
|
||||
/// limit by evicting the oldest cached bodies when the limit is exceeded.
|
||||
class BodyCacheService {
|
||||
BodyCacheService(this._db, this._accountRepo);
|
||||
|
||||
final AppDatabase _db;
|
||||
final AccountRepository _accountRepo;
|
||||
|
||||
static const _batchSize = 20;
|
||||
|
||||
Future<void> run() async {
|
||||
final prefs = await (_db.select(
|
||||
_db.userPreferences,
|
||||
)).getSingleOrNull();
|
||||
final limitMb = prefs?.bodyCacheLimitMb ?? 100;
|
||||
final limitBytes = limitMb * 1024 * 1024;
|
||||
|
||||
await _evictIfNeeded(limitBytes);
|
||||
|
||||
final candidates = await _fetchCandidates();
|
||||
if (candidates.isEmpty) return;
|
||||
|
||||
final emailRepo = EmailRepositoryImpl(_db, _accountRepo);
|
||||
|
||||
for (final emailId in candidates) {
|
||||
final currentSize = await _getCacheSizeBytes();
|
||||
if (currentSize >= limitBytes) break;
|
||||
try {
|
||||
await emailRepo.getEmailBody(emailId);
|
||||
} catch (_) {
|
||||
// Skip emails that fail to fetch.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _evictIfNeeded(int limitBytes) async {
|
||||
final currentSize = await _getCacheSizeBytes();
|
||||
if (currentSize <= limitBytes) return;
|
||||
|
||||
final bodies = await (_db.select(_db.emailBodies)
|
||||
..where((t) => t.cachedAt.isNotNull())
|
||||
..orderBy([(t) => OrderingTerm.asc(t.cachedAt)]))
|
||||
.get();
|
||||
|
||||
var remaining = currentSize;
|
||||
for (final body in bodies) {
|
||||
if (remaining <= limitBytes) break;
|
||||
final bodySize =
|
||||
(body.textBody?.length ?? 0) + (body.htmlBody?.length ?? 0);
|
||||
await (_db.delete(_db.emailBodies)
|
||||
..where((t) => t.emailId.equals(body.emailId)))
|
||||
.go();
|
||||
remaining -= bodySize;
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> _getCacheSizeBytes() async {
|
||||
final result = await _db
|
||||
.customSelect(
|
||||
"SELECT COALESCE(SUM(LENGTH(COALESCE(text_body, '')) + LENGTH(COALESCE(html_body, ''))), 0) AS total FROM email_bodies",
|
||||
)
|
||||
.getSingle();
|
||||
return result.read<int>('total');
|
||||
}
|
||||
|
||||
Future<List<String>> _fetchCandidates() async {
|
||||
final rows = await _db.customSelect(
|
||||
'SELECT e.id FROM emails e '
|
||||
'LEFT JOIN email_bodies eb ON eb.email_id = e.id '
|
||||
'WHERE eb.email_id IS NULL '
|
||||
'ORDER BY e.received_at DESC '
|
||||
'LIMIT ?',
|
||||
variables: [Variable.withInt(_batchSize)],
|
||||
).get();
|
||||
return rows.map((r) => r.read<String>('id')).toList();
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart' as model;
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||
import 'package:sharedinbox/core/services/body_cache_service.dart';
|
||||
import 'package:sharedinbox/core/services/notification_service.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||
@@ -21,6 +23,7 @@ import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
|
||||
const _kTaskName = 'si_bg_sync';
|
||||
const _kPrefetchTaskName = 'si_bg_prefetch';
|
||||
const _kResourceType = 'background_check';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
@@ -28,9 +31,13 @@ void callbackDispatcher() {
|
||||
// Required so that path_provider and other plugins are available in this
|
||||
// background isolate (issue #192).
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
Workmanager().executeTask((_, __) async {
|
||||
Workmanager().executeTask((taskName, __) async {
|
||||
try {
|
||||
await _doBackgroundSync();
|
||||
if (taskName == _kPrefetchTaskName) {
|
||||
await _doBodyPrefetch();
|
||||
} else {
|
||||
await _doBackgroundSync();
|
||||
}
|
||||
} catch (_) {}
|
||||
return true;
|
||||
});
|
||||
@@ -55,6 +62,31 @@ Future<void> registerBackgroundSync() async {
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers (or cancels) the body-prefetch WorkManager task based on [mode].
|
||||
/// Call on app startup and whenever the user changes the prefetch preference.
|
||||
Future<void> registerBodyPrefetchTask(PrefetchMode mode) async {
|
||||
try {
|
||||
if (mode == PrefetchMode.disabled) {
|
||||
await Workmanager().cancelByUniqueName(_kPrefetchTaskName);
|
||||
return;
|
||||
}
|
||||
final networkType = mode == PrefetchMode.wifiOnly
|
||||
? NetworkType.unmetered
|
||||
: NetworkType.connected;
|
||||
await Workmanager().registerPeriodicTask(
|
||||
_kPrefetchTaskName,
|
||||
_kPrefetchTaskName,
|
||||
frequency: const Duration(hours: 1),
|
||||
constraints: Constraints(networkType: networkType),
|
||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.replace,
|
||||
);
|
||||
} on PlatformException {
|
||||
// Ignore — WorkManager unavailable.
|
||||
} on MissingPluginException {
|
||||
// Ignore — plugin not registered.
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _doBackgroundSync() async {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final db = AppDatabase(
|
||||
@@ -76,6 +108,22 @@ Future<void> _doBackgroundSync() async {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _doBodyPrefetch() async {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final db = AppDatabase(
|
||||
NativeDatabase(File(p.join(dir.path, 'sharedinbox.db'))),
|
||||
);
|
||||
try {
|
||||
final accountRepo = AccountRepositoryImpl(
|
||||
db,
|
||||
const FlutterSecureStorageImpl(),
|
||||
);
|
||||
await BodyCacheService(db, accountRepo).run();
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkAccount(
|
||||
AppDatabase db,
|
||||
AccountRepository accountRepo,
|
||||
|
||||
@@ -330,6 +330,12 @@ class UserPreferences extends Table {
|
||||
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
|
||||
TextColumn get afterMailViewAction =>
|
||||
text().withDefault(const Constant('nextMessage'))();
|
||||
// Added in schema v38: 'disabled' | 'wifiOnly' (default) | 'always'
|
||||
TextColumn get prefetchMode =>
|
||||
text().withDefault(const Constant('wifiOnly'))();
|
||||
// Added in schema v38: max cache size for offline email bodies, in megabytes.
|
||||
IntColumn get bodyCacheLimitMb =>
|
||||
integer().withDefault(const Constant(100))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
@@ -626,6 +632,13 @@ class AppDatabase extends _$AppDatabase {
|
||||
if (from < 37) {
|
||||
await m.createTable(imageTrustedSenders);
|
||||
}
|
||||
if (from >= 34 && from < 38) {
|
||||
await m.addColumn(userPreferences, userPreferences.prefetchMode);
|
||||
await m.addColumn(
|
||||
userPreferences,
|
||||
userPreferences.bodyCacheLimitMb,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,26 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updatePrefetchMode(pref.PrefetchMode mode) async {
|
||||
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||
UserPreferencesCompanion(
|
||||
id: const Value(_rowId),
|
||||
prefetchMode: Value(mode.name),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateBodyCacheLimitMb(int mb) async {
|
||||
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||
UserPreferencesCompanion(
|
||||
id: const Value(_rowId),
|
||||
bodyCacheLimitMb: Value(mb),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<String>> observeTrustedImageSenders() {
|
||||
return (_db.select(_db.imageTrustedSenders)
|
||||
@@ -90,6 +110,8 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
||||
(e) => e.name == row.afterMailViewAction,
|
||||
orElse: () => pref.AfterMailViewAction.nextMessage,
|
||||
),
|
||||
prefetchMode: pref.PrefetchMode.fromString(row.prefetchMode),
|
||||
bodyCacheLimitMb: row.bodyCacheLimitMb,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/services/notification_service.dart';
|
||||
import 'package:sharedinbox/core/sync/background_sync.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
@@ -39,6 +40,7 @@ void main({List<Override> overrides = const []}) async {
|
||||
if (Platform.isAndroid) {
|
||||
await initNotifications();
|
||||
await registerBackgroundSync();
|
||||
await _registerPrefetchTaskFromStoredPrefs();
|
||||
}
|
||||
runApp(
|
||||
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
|
||||
@@ -52,6 +54,20 @@ void main({List<Override> overrides = const []}) async {
|
||||
);
|
||||
}
|
||||
|
||||
/// Reads the stored prefetch preference and registers the WorkManager task
|
||||
/// with the correct network constraint for it. Opens and immediately closes
|
||||
/// a temporary DB connection; safe because initDatabasePath() has already run.
|
||||
Future<void> _registerPrefetchTaskFromStoredPrefs() async {
|
||||
final db = AppDatabase();
|
||||
try {
|
||||
final row = await db.select(db.userPreferences).getSingleOrNull();
|
||||
final mode = PrefetchMode.fromString(row?.prefetchMode);
|
||||
await registerBodyPrefetchTask(mode);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
class SharedInboxApp extends ConsumerStatefulWidget {
|
||||
const SharedInboxApp({super.key});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/sync/background_sync.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
class UserPreferencesScreen extends ConsumerWidget {
|
||||
@@ -133,6 +134,83 @@ class UserPreferencesScreen extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: Text(
|
||||
'Offline email cache',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Pre-fetch email bodies in the background so they are available offline.',
|
||||
),
|
||||
),
|
||||
RadioGroup<PrefetchMode>(
|
||||
groupValue: prefs.prefetchMode,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
unawaited(
|
||||
ref
|
||||
.read(userPreferencesRepositoryProvider)
|
||||
.updatePrefetchMode(value),
|
||||
);
|
||||
unawaited(registerBodyPrefetchTask(value));
|
||||
},
|
||||
child: const Column(
|
||||
children: [
|
||||
RadioListTile<PrefetchMode>(
|
||||
title: Text('Wi-Fi only (default)'),
|
||||
subtitle: Text(
|
||||
'Pre-fetch bodies in the background when connected to Wi-Fi.',
|
||||
),
|
||||
value: PrefetchMode.wifiOnly,
|
||||
),
|
||||
RadioListTile<PrefetchMode>(
|
||||
title: Text('Any network'),
|
||||
subtitle: Text(
|
||||
'Pre-fetch bodies on Wi-Fi and mobile data.',
|
||||
),
|
||||
value: PrefetchMode.always,
|
||||
),
|
||||
RadioListTile<PrefetchMode>(
|
||||
title: Text('Disabled'),
|
||||
subtitle: Text(
|
||||
'Do not pre-fetch email bodies in the background.',
|
||||
),
|
||||
value: PrefetchMode.disabled,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (prefs.prefetchMode != PrefetchMode.disabled) ...[
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Cache size limit:'),
|
||||
const SizedBox(width: 16),
|
||||
DropdownButton<int>(
|
||||
value: _nearestCacheOption(prefs.bodyCacheLimitMb),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 50, child: Text('50 MB')),
|
||||
DropdownMenuItem(value: 100, child: Text('100 MB')),
|
||||
DropdownMenuItem(value: 200, child: Text('200 MB')),
|
||||
DropdownMenuItem(value: 500, child: Text('500 MB')),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
unawaited(
|
||||
ref
|
||||
.read(userPreferencesRepositoryProvider)
|
||||
.updateBodyCacheLimitMb(value),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: Text(
|
||||
'Trusted image senders',
|
||||
@@ -176,4 +254,11 @@ class UserPreferencesScreen extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int _nearestCacheOption(int mb) {
|
||||
const options = [50, 100, 200, 500];
|
||||
return options.reduce(
|
||||
(a, b) => (a - mb).abs() <= (b - mb).abs() ? a : b,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user