Files
sharedinbox/lib/core/services/body_cache_service.dart
T
Thomas SharedInboxandClaude Sonnet 4.6 49fdf83834 feat: pre-fetch email bodies for offline access (#373)
Adds a background body-prefetch mechanism with network-awareness and a
user-configurable cache size limit to keep email bodies available offline
without downloading the entire mailbox.

- Schema v38: adds `prefetch_mode` and `body_cache_limit_mb` columns to
  `user_preferences` (defaults: wifiOnly / 100 MB).
- `PrefetchMode` enum (disabled / wifiOnly / always) in the model.
- `BodyCacheService`: fetches bodies for uncached emails (newest first,
  batch of 20), evicts oldest cached bodies when the size limit is
  exceeded.
- Registers a separate WorkManager periodic task (`si_bg_prefetch`) with
  `NetworkType.unmetered` (Wi-Fi only) or `NetworkType.connected` (any)
  based on the stored preference; cancels the task when disabled.
- On app startup, reads the stored preference and re-registers the task
  with the correct constraint.
- Preferences screen: radio group for prefetch mode + dropdown for
  cache size limit (50 / 100 / 200 / 500 MB).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 03:29:03 +02:00

83 lines
2.6 KiB
Dart

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();
}
}