summaryrefslogtreecommitdiff
path: root/player-android/lib/services/progress_queue.dart
blob: 74b8e5eda23b942739062d78db7faee2d1fd1006 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
import 'dart:async';

import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:sqflite/sqflite.dart';

// SQLite table and column names — kept as constants to avoid typos and make
// schema migrations easy to spot.
const _kTable = 'progress_queue';
const _kColId = 'id';
const _kColMediaId = 'media_id';
const _kColPositionSeconds = 'position_seconds';
const _kColFinished = 'finished';
const _kColQueuedAt = 'queued_at';

// Database schema version. Bump when columns change so onUpgrade fires.
const _kDbVersion = 1;

// Database filename stored in the default sqflite databases path.
const _kDbName = 'progress_queue.db';

// ---------------------------------------------------------------------------
// ProgressSyncClient — narrow interface (ISP)
// ---------------------------------------------------------------------------

/// Narrow interface for the single API operation that [ProgressQueue] needs.
///
/// Interface Segregation: [ProgressQueue] depends only on
/// [batchUpdateProgress], not on the full [PlayerApiClient] surface.
/// Production code passes a [PlayerApiClient] (which implements this);
/// tests can provide a lightweight stub without subclassing the entire client.
abstract class ProgressSyncClient {
  /// Submits a batch of progress updates to the server.
  ///
  /// Each map must include `media_id`, `position_seconds`, and `observed_at`.
  Future<void> batchUpdateProgress(List<Map<String, dynamic>> updates);
}

// ---------------------------------------------------------------------------
// ProgressQueueBase — abstract lifecycle interface (LSP + DIP)
// ---------------------------------------------------------------------------

/// Abstract contract for an offline-capable progress queue.
///
/// Callers (provider, player screens) depend on this interface rather than the
/// concrete [ProgressQueue] class (Dependency Inversion).  Alternative
/// implementations (in-memory, no-op) are substitutable without breaking
/// callers (Liskov Substitution).
abstract class ProgressQueueBase {
  /// Opens the backing store and subscribes to connectivity changes.
  ///
  /// Must be called once before [enqueue].
  Future<void> init();

  /// Persists a playback-progress update and, if online, flushes immediately.
  Future<void> enqueue(int mediaId, double positionSeconds,
      {bool finished = false});

  /// Cancels subscriptions and closes the backing store.
  Future<void> dispose();
}

// ---------------------------------------------------------------------------
// ProgressUpdate value object
// ---------------------------------------------------------------------------

/// Immutable record of a single playback-progress update.
///
/// Used both as a value object passed from the player screens and as an
/// internal DTO deserialised from the SQLite row.  Keeping it in this file
/// avoids leaking a "models" dependency on the queue's persistence layer.
class ProgressUpdate {
  const ProgressUpdate({
    required this.mediaId,
    required this.positionSeconds,
    this.finished = false,
    required this.queuedAt,
    this.rowId,
  });

  final int mediaId;
  final double positionSeconds;
  final bool finished;

  /// Wall-clock time the update was created (ISO-8601 UTC string stored in DB).
  /// Used as the `observed_at` field in the batch request so the server applies
  /// updates in chronological order.
  final String queuedAt;

  /// Non-null after the row has been persisted; null for newly constructed
  /// updates that have not been written to the DB yet.
  final int? rowId;

  /// Converts this update to the JSON shape expected by
  /// [ProgressSyncClient.batchUpdateProgress].
  Map<String, dynamic> toBatchMap() => {
        'media_id': mediaId,
        'position_seconds': positionSeconds,
        'observed_at': queuedAt,
      };
}

// ---------------------------------------------------------------------------
// ProgressQueue
// ---------------------------------------------------------------------------

/// Offline-capable progress queue backed by SQLite.
///
/// Responsibilities (Single Responsibility: one per bullet):
///   - Persist [enqueue] calls to a local SQLite table so updates survive
///     process restarts while the device is offline.
///   - Watch network connectivity via [Connectivity] and trigger a flush
///     automatically when the device goes from offline to online.
///   - Flush pending rows by calling [ProgressSyncClient.batchUpdateProgress];
///     remove successfully sent rows and retain any that fail (for retry).
///
/// Design notes:
///   - No Flutter imports — this is a pure-Dart service (can be unit tested
///     without a widget tree).
///   - [ProgressSyncClient] is injected (Interface Segregation + Dependency
///     Inversion); [ProgressQueue] only depends on the one method it uses.
///   - [databaseFactory] is injected so tests can supply an in-memory opener
///     without touching the filesystem (Dependency Inversion).
///   - [Database] may also be injected directly via [db] for tests that have
///     already opened a connection.
///   - Concurrent flush is prevented with [_isFlushing]; a second connectivity
///     event while a flush is in progress is silently ignored — the flush will
///     drain all rows anyway.
class ProgressQueue implements ProgressQueueBase {
  /// Creates the queue.
  ///
  /// [apiClient] must implement [ProgressSyncClient]; in production this is
  /// a [PlayerApiClient].  Tests can pass a lightweight stub.
  ///
  /// [databaseFactory] is an optional factory for opening the SQLite database.
  /// When null, [init] calls [openDatabase] with the default on-disk path.
  /// Inject a custom factory in tests to get an in-memory database without
  /// touching the filesystem (Dependency Inversion).
  ///
  /// [db] is an already-opened [Database]; when non-null it takes precedence
  /// over [databaseFactory] and no additional open call is made.
  ///
  /// [connectivity] is optional; when null the default [Connectivity()] is
  /// used in production.  Pass a fake in tests.
  ProgressQueue({
    required ProgressSyncClient apiClient,
    Future<Database> Function()? databaseFactory,
    Database? db,
    Connectivity? connectivity,
  })  : _apiClient = apiClient,
        _databaseFactory = databaseFactory,
        _db = db,
        _connectivity = connectivity ?? Connectivity();

  final ProgressSyncClient _apiClient;

  // Optional factory for opening the on-disk database; null means use the
  // built-in [_openDatabase] helper which calls sqflite's openDatabase().
  final Future<Database> Function()? _databaseFactory;
  final Connectivity _connectivity;

  // Non-null after [init] has been called.
  Database? _db;

  // Guards against concurrent flush operations.
  bool _isFlushing = false;

  // Holds the in-flight flush future so [dispose] can await it before closing
  // the database, preventing "database_closed" errors on shutdown.
  Future<void>? _flushFuture;

  // Subscription to connectivity changes; cancelled in [dispose].
  StreamSubscription<List<ConnectivityResult>>? _connectivitySub;

  // ---------------------------------------------------------------------------
  // Lifecycle
  // ---------------------------------------------------------------------------

  /// Opens the SQLite database (if not already provided) and subscribes to
  /// connectivity changes.
  ///
  /// Must be called once before any other method.  Safe to call multiple times
  /// (subsequent calls are no-ops if the DB is already open).
  ///
  /// The database is obtained from the injected [_databaseFactory] when
  /// supplied, falling back to [_openDatabase] which calls sqflite's
  /// [openDatabase] with the default on-disk path.
  @override
  Future<void> init() async {
    _db ??= await (_databaseFactory?.call() ?? _openDatabase());
    _subscribeToConnectivity();
  }

  /// Cancels the connectivity subscription and closes the database.
  ///
  /// Awaits any in-flight flush before closing the DB so that a concurrent
  /// flush does not attempt to use the database after it has been closed
  /// (prevents "database_closed" errors during app shutdown or test teardown).
  @override
  Future<void> dispose() async {
    await _connectivitySub?.cancel();
    _connectivitySub = null;
    // Wait for any ongoing flush to finish before closing the database.
    // Ignore errors from the in-flight flush — they are already handled inside
    // [_flush] via try/finally; swallowing here avoids double-reporting.
    await _flushFuture?.catchError((_) {});
    await _db?.close();
    _db = null;
  }

  // ---------------------------------------------------------------------------
  // Public API
  // ---------------------------------------------------------------------------

  /// Persists a progress update locally and, if the device is currently online,
  /// triggers an immediate flush.
  ///
  /// Fire-and-forget in the player screens: any DB write failure is swallowed
  /// so a storage error never interrupts playback.
  @override
  Future<void> enqueue(
    int mediaId,
    double positionSeconds, {
    bool finished = false,
  }) async {
    final db = _db;
    if (db == null) return; // Defensive: init not called.

    final now = DateTime.now().toUtc().toIso8601String();
    await db.insert(_kTable, {
      _kColMediaId: mediaId,
      _kColPositionSeconds: positionSeconds,
      _kColFinished: finished ? 1 : 0,
      _kColQueuedAt: now,
    });

    // Opportunistic online flush: attempt immediately on enqueue so that
    // updates sent while online bypass the DB round-trip latency.
    // Errors are swallowed — the row is already persisted so the next
    // connectivity event will retry.
    final results = await _connectivity.checkConnectivity();
    if (_isOnline(results)) {
      await _flush().catchError((_) {});
    }
  }

  // ---------------------------------------------------------------------------
  // Internal: flush
  // ---------------------------------------------------------------------------

  /// Sends all queued rows to the server via [batchUpdateProgress].
  ///
  /// Rows that are successfully sent are deleted from the DB.  Rows that fail
  /// (e.g., the server returns an error for a specific item) are retained for
  /// the next flush.  The entire batch succeeds or fails atomically from the
  /// client perspective — if the call throws, no rows are deleted.
  ///
  /// [_isFlushing] prevents re-entrant flushes.  The flag is cleared in a
  /// `finally` block so a thrown exception never permanently blocks flushing.
  ///
  /// The future is stored in [_flushFuture] so [dispose] can await it before
  /// closing the database, preventing use-after-close crashes on shutdown.
  Future<void> _flush() {
    if (_isFlushing) return Future.value();
    _isFlushing = true;
    _flushFuture = _flushPendingRows().whenComplete(() {
      _isFlushing = false;
      _flushFuture = null;
    });
    return _flushFuture!;
  }

  /// Loads pending rows, sends them, and removes the ones that succeeded.
  ///
  /// Extracted from [_flush] to keep each method under ~30 lines and make the
  /// "load → send → delete" pipeline independently readable.
  Future<void> _flushPendingRows() async {
    final db = _db;
    if (db == null) return;

    final rows = await db.query(
      _kTable,
      orderBy: '$_kColQueuedAt ASC',
    );
    if (rows.isEmpty) return;

    final updates = rows.map(_rowToUpdate).toList();

    // Build the batch payload for the server.
    final payload = updates.map((u) => u.toBatchMap()).toList();

    // Send — if this throws (network error, server 5xx) we skip deletion and
    // let the next connectivity event retry.
    await _apiClient.batchUpdateProgress(payload);

    // Delete the rows that were just sent successfully.
    final ids = updates.map((u) => u.rowId!).toList();
    await _deleteRows(db, ids);
  }

  // ---------------------------------------------------------------------------
  // Internal: connectivity
  // ---------------------------------------------------------------------------

  /// Subscribes to connectivity changes and flushes when online is detected.
  ///
  /// The subscription is only set up once; subsequent [init] calls are no-ops
  /// because [_connectivitySub] is already non-null.
  ///
  /// Errors from [_flush] are swallowed inside the listener — the flush
  /// already handles its own error recovery (rows retained on failure) and
  /// an unhandled stream error would tear down the subscription.
  void _subscribeToConnectivity() {
    _connectivitySub ??= _connectivity.onConnectivityChanged.listen(
      (results) async {
        if (_isOnline(results)) {
          await _flush().catchError((_) {});
        }
      },
    );
  }

  // ---------------------------------------------------------------------------
  // Internal: helpers
  // ---------------------------------------------------------------------------

  /// Opens (or creates) the on-disk SQLite database and runs migrations.
  Future<Database> _openDatabase() {
    return openDatabase(
      _kDbName,
      version: _kDbVersion,
      onCreate: (db, version) => _createSchema(db),
    );
  }

  /// Creates the progress_queue table on first run.
  Future<void> _createSchema(Database db) {
    return db.execute('''
      CREATE TABLE $_kTable (
        $_kColId             INTEGER PRIMARY KEY AUTOINCREMENT,
        $_kColMediaId        INTEGER NOT NULL,
        $_kColPositionSeconds REAL    NOT NULL,
        $_kColFinished       INTEGER NOT NULL DEFAULT 0,
        $_kColQueuedAt       TEXT    NOT NULL
      )
    ''');
  }

  /// Converts a raw SQLite row map into a [ProgressUpdate].
  ProgressUpdate _rowToUpdate(Map<String, dynamic> row) {
    return ProgressUpdate(
      rowId: row[_kColId] as int,
      mediaId: row[_kColMediaId] as int,
      positionSeconds: (row[_kColPositionSeconds] as num).toDouble(),
      finished: (row[_kColFinished] as int) != 0,
      queuedAt: row[_kColQueuedAt] as String,
    );
  }

  /// Deletes rows with the given [ids] from the queue table.
  ///
  /// Uses a single DELETE … WHERE id IN (…) statement for efficiency.
  Future<void> _deleteRows(Database db, List<int> ids) async {
    if (ids.isEmpty) return;
    final placeholders = List.filled(ids.length, '?').join(', ');
    await db.rawDelete(
      'DELETE FROM $_kTable WHERE $_kColId IN ($placeholders)',
      ids,
    );
  }

  /// Returns `true` when at least one connectivity result indicates an active
  /// network interface (WiFi, mobile, ethernet, or VPN).
  ///
  /// [ConnectivityResult.none] is the only value treated as offline.
  bool _isOnline(List<ConnectivityResult> results) {
    return results.any((r) => r != ConnectivityResult.none);
  }
}