diff --git a/lib/data/remote/content_downloader.dart b/lib/data/remote/content_downloader.dart new file mode 100644 index 0000000..1bac535 --- /dev/null +++ b/lib/data/remote/content_downloader.dart @@ -0,0 +1,97 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; + +import 'manifest.dart'; + +/// Pulls the remote season manifest and downloads new/updated packs into +/// [cacheDir] with SHA256 verification and atomic (temp + rename) writes. +/// All failures are swallowed into a `false` return — remote content is an +/// enhancement, never a crash source. +class ContentDownloader { + ContentDownloader({ + required this.baseUrl, + required this.cacheDir, + http.Client? client, + }) : _client = client ?? http.Client() { + _loadIndex(); + } + + final String baseUrl; + final Directory cacheDir; + final http.Client _client; + final Map _index = {}; + + File get _indexFile => File('${cacheDir.path}/cache_index.json'); + + void _loadIndex() { + try { + if (_indexFile.existsSync()) { + final json = + jsonDecode(_indexFile.readAsStringSync()) as Map; + json.forEach((k, v) => _index[k] = v as int); + } + } catch (_) { + // Corrupt index: treat as empty; packs will re-download. + _index.clear(); + } + } + + Future _saveIndex() async { + await cacheDir.create(recursive: true); + await _indexFile.writeAsString(jsonEncode(_index)); + } + + int? cachedVersion(String seasonId) => _index[seasonId]; + + /// Checks the manifest and downloads anything new. Returns true when at + /// least one pack was added or updated. + Future sync() async { + final RemoteManifest manifest; + try { + final res = await _client + .get(Uri.parse('$baseUrl/manifest.json')) + .timeout(const Duration(seconds: 10)); + if (res.statusCode != 200) return false; + manifest = RemoteManifest.fromJson( + jsonDecode(res.body) as Map); + } catch (_) { + return false; + } + + var changed = false; + for (final season in manifest.seasons) { + if (_index[season.seasonId] == season.version) continue; + if (await _downloadPack(season)) changed = true; + } + return changed; + } + + Future _downloadPack(ManifestSeason season) async { + try { + final res = await _client + .get(Uri.parse('$baseUrl/${season.packUrl}')) + .timeout(const Duration(seconds: 30)); + if (res.statusCode != 200) return false; + + // Verify in memory before any file is touched, so a bad pack can + // never leave artifacts on disk. + final digest = sha256.convert(res.bodyBytes).toString(); + if (digest != season.sha256) return false; + + final dir = Directory('${cacheDir.path}/seasons/${season.seasonId}'); + await dir.create(recursive: true); + final tmp = File('${dir.path}/pack.json.tmp'); + await tmp.writeAsBytes(res.bodyBytes, flush: true); + await tmp.rename('${dir.path}/pack.json'); + + _index[season.seasonId] = season.version; + await _saveIndex(); + return true; + } catch (_) { + return false; + } + } +} diff --git a/test/data/remote/content_downloader_test.dart b/test/data/remote/content_downloader_test.dart new file mode 100644 index 0000000..4b8f3e1 --- /dev/null +++ b/test/data/remote/content_downloader_test.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:block_seasons/data/remote/content_downloader.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + late Directory tmp; + + setUp(() async { + tmp = await Directory.systemTemp.createTemp('bs_cache_'); + }); + + tearDown(() async { + if (await tmp.exists()) await tmp.delete(recursive: true); + }); + + String shaOf(String body) => sha256.convert(utf8.encode(body)).toString(); + + // Minimal pack body (repository parses later; downloader only verifies + // bytes). + const packBody = '{"schemaVersion":1,"seasonId":"season_002"}'; + + Map manifestJson({String? sha, int version = 1}) => { + 'schemaVersion': 1, + 'minAppBuild': 1, + 'current': 'season_002', + 'seasons': [ + { + 'seasonId': 'season_002', + 'version': version, + 'packUrl': 'seasons/season_002/pack.json', + 'sha256': sha ?? shaOf(packBody), + }, + ], + }; + + MockClient okClient({String? sha, int version = 1}) => + MockClient((request) async { + if (request.url.path.endsWith('manifest.json')) { + return http.Response( + jsonEncode(manifestJson(sha: sha, version: version)), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path.endsWith('pack.json')) { + return http.Response(packBody, 200); + } + return http.Response('not found', 404); + }); + + test('downloads, verifies, and caches a new season', () async { + final downloader = ContentDownloader( + baseUrl: 'https://example.com/content', + cacheDir: tmp, + client: okClient(), + ); + final updated = await downloader.sync(); + expect(updated, isTrue); + final cached = File('${tmp.path}/seasons/season_002/pack.json'); + expect(await cached.readAsString(), packBody); + expect(downloader.cachedVersion('season_002'), 1); + }); + + test('second sync with same version is a no-op', () async { + final downloader = ContentDownloader( + baseUrl: 'https://example.com/content', + cacheDir: tmp, + client: okClient(), + ); + await downloader.sync(); + expect(await downloader.sync(), isFalse); + }); + + test('checksum mismatch rejects the pack and keeps cache clean', () async { + final downloader = ContentDownloader( + baseUrl: 'https://example.com/content', + cacheDir: tmp, + client: okClient(sha: 'deadbeef'), + ); + final updated = await downloader.sync(); + expect(updated, isFalse); + expect( + File('${tmp.path}/seasons/season_002/pack.json').existsSync(), + isFalse, + ); + // No stray temp files left behind. + final leftovers = tmp + .listSync(recursive: true) + .whereType() + .where((f) => f.path.endsWith('.tmp')); + expect(leftovers, isEmpty); + }); + + test('network failure leaves existing cache untouched', () async { + final good = ContentDownloader( + baseUrl: 'https://example.com/content', + cacheDir: tmp, + client: okClient(), + ); + await good.sync(); + + final offline = ContentDownloader( + baseUrl: 'https://example.com/content', + cacheDir: tmp, + client: MockClient((_) async => throw const SocketException('off')), + ); + expect(await offline.sync(), isFalse); + expect( + File('${tmp.path}/seasons/season_002/pack.json').existsSync(), + isTrue, + ); + }); + + test('version bump re-downloads', () async { + await ContentDownloader( + baseUrl: 'https://example.com/content', + cacheDir: tmp, + client: okClient(version: 1), + ).sync(); + final updated = await ContentDownloader( + baseUrl: 'https://example.com/content', + cacheDir: tmp, + client: okClient(version: 2), + ).sync(); + expect(updated, isTrue); + }); +}