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
83 lines
2.6 KiB
Dart
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();
|
|
}
|
|
}
|