Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 21 additions & 38 deletions integration_test/background_interaction_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:app4training/data/languages.dart';
import 'package:app4training/l10n/generated/app_localizations_de.dart';
import 'package:app4training/main.dart';
import 'package:app4training/routes/view_page.dart';
import 'package:file/memory.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
Expand All @@ -32,21 +33,17 @@ void main() async {
completer.complete(data);
});
await Workmanager().initialize(backgroundTask);
await Workmanager().registerOneOffTask(
"task-identifier",
"testTask",
initialDelay: const Duration(seconds: 2),
);

await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPrefsProvider.overrideWithValue(prefs),
packageInfoProvider.overrideWithValue(packageInfo),
],
child: const App4Training(),
),
);
await Workmanager().registerOneOffTask("task-identifier", "testTask",
initialDelay: const Duration(seconds: 2));

final fileSystem = MemoryFileSystem();
await tester.pumpWidget(ProviderScope(overrides: [
sharedPrefsProvider.overrideWithValue(prefs),
packageInfoProvider.overrideWithValue(packageInfo),
fileSystemProvider.overrideWith((ref) => fileSystem),
languageDownloaderProvider
.overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)),
], child: const App4Training()));
expect(find.text('Loading'), findsOneWidget);

// Wait for the background isolate to finish
Expand All @@ -70,24 +67,13 @@ void main() async {
});
var fileSystem = await createTestFileSystem();

await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPrefsProvider.overrideWithValue(prefs),
packageInfoProvider.overrideWithValue(packageInfo),
fileSystemProvider.overrideWith((ref) => fileSystem),
// We need to mock DownloadAssetsController because we can't use a memory
// file system to test it (it uses dart:io, not the file package)
languageProvider.overrideWith2(
(languageCode) => LanguageController(
languageCode: languageCode,
assetsController: createMockDownloadAssetsController(),
),
),
],
child: const App4Training(),
),
);
await tester.pumpWidget(ProviderScope(overrides: [
sharedPrefsProvider.overrideWithValue(prefs),
packageInfoProvider.overrideWithValue(packageInfo),
fileSystemProvider.overrideWith((ref) => fileSystem),
languageDownloaderProvider
.overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)),
], child: const App4Training()));
expect(find.text('Loading'), findsOneWidget);
await tester.pumpAndSettle();

Expand All @@ -101,11 +87,8 @@ void main() async {
await tester.pumpAndSettle();

await Workmanager().initialize(backgroundTask);
await Workmanager().registerOneOffTask(
"task-identifier",
"testTask",
initialDelay: const Duration(seconds: 2),
);
await Workmanager().registerOneOffTask("task-identifier", "testTask",
initialDelay: const Duration(seconds: 2));

// Wait for the background isolate to finish
final msg = await completer.future.timeout(const Duration(seconds: 10));
Expand Down
21 changes: 15 additions & 6 deletions lib/background/background_task.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import 'dart:ui';

import 'package:app4training/background/background_test.dart';
import 'package:app4training/data/globals.dart';
import 'package:app4training/data/language_downloader.dart';
import 'package:app4training/data/languages.dart';
import 'package:app4training/data/updates.dart';
import 'package:dio/dio.dart';
import 'package:file/local.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
Expand Down Expand Up @@ -52,9 +55,18 @@ void backgroundTask() {
Future<void> backgroundMain() async {
await writeLog("Background task is starting...");
final prefs = await SharedPreferences.getInstance();
final appDocsDir = await getApplicationDocumentsDirectory();
final languageDownloader = LanguageDownloaderImpl(
root: appDocsDir.path,
dio: Dio(),
fileSystem: const LocalFileSystem(),
);

final ref = ProviderContainer(
overrides: [sharedPrefsProvider.overrideWithValue(prefs)],
overrides: [
sharedPrefsProvider.overrideWithValue(prefs),
languageDownloaderProvider.overrideWithValue(languageDownloader),
],
);
await backgroundCheck(ref);

Expand All @@ -73,11 +85,8 @@ Future<void> backgroundTestMain() async {
overrides: [
sharedPrefsProvider.overrideWithValue(prefs),
fileSystemProvider.overrideWith((ref) => fileSystem),
languageProvider.overrideWith2(
(languageCode) => LanguageController(
languageCode: languageCode,
assetsController: createMockDownloadAssetsController(),
),
languageDownloaderProvider.overrideWithValue(
FakeLanguageDownloader(fileSystem: fileSystem),
),
],
);
Expand Down
60 changes: 45 additions & 15 deletions lib/background/background_test.dart
Original file line number Diff line number Diff line change
@@ -1,24 +1,54 @@
import 'package:download_assets/download_assets.dart';
import 'package:app4training/data/globals.dart';
import 'package:app4training/data/language_downloader.dart';
import 'package:file/file.dart';
import 'package:file/memory.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p;

/* This are utility functions for the integration test.
/* These are utility functions for the integration test.
Unfortunately it seems not be easily possible to move this into
the test/ folder and include it from there in our background_task.dart,
so that's why it is in our "normal" code directory */

class MockDownloadAssetsController extends Mock
implements DownloadAssetsController {}

// Simulate that German files are already downloaded
MockDownloadAssetsController createMockDownloadAssetsController() {
final mock = MockDownloadAssetsController();
when(() => mock.init(assetDir: any(named: 'assetDir')))
.thenAnswer((_) async {});
when(mock.clearAssets).thenAnswer((_) async {});
when(() => mock.assetsDir).thenReturn('assets-de');
when(() => mock.assetsDirAlreadyExists()).thenAnswer((_) async => true);
return mock;
/// A test double for [LanguageDownloader] backed by a [FileSystem].
/// [download] records calls in [downloadCalls] but does not write files —
/// pre-seed the file system if a test needs a language to appear downloaded.
class FakeLanguageDownloader implements LanguageDownloader {
final FileSystem fileSystem;
final String root;
final bool throwOnDownload;
int downloadCalls = 0;
int deleteCalls = 0;

FakeLanguageDownloader({
required this.fileSystem,
this.root = '',
this.throwOnDownload = false,
});

@override
String pathFor(String langCode) =>
p.join(root, Globals.getAssetsDir(langCode));

@override
Future<bool> isDownloaded(String langCode) =>
fileSystem.directory(pathFor(langCode)).exists();

@override
Future<void> download(String langCode) async {
downloadCalls += 1;
if (throwOnDownload) {
throw Exception('Simulated download failure');
}
}

@override
Future<void> delete(String langCode) async {
deleteCalls += 1;
final dir = fileSystem.directory(pathFor(langCode));
if (await dir.exists()) {
await dir.delete(recursive: true);
}
}
}

// Simulate a file system where German is downloaded with one worksheet
Expand Down
2 changes: 2 additions & 0 deletions lib/data/globals.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:app4training/data/language_downloader.dart';
import 'package:app4training/l10n/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
Expand All @@ -6,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart';

final sharedPrefsProvider = MustOverrideProvider<SharedPreferences>();
final packageInfoProvider = MustOverrideProvider<PackageInfo>();
final languageDownloaderProvider = MustOverrideProvider<LanguageDownloader>();

/// ignore: non_constant_identifier_names
Provider<T> MustOverrideProvider<T>() {
Expand Down
122 changes: 122 additions & 0 deletions lib/data/language_downloader.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import 'dart:async';
import 'dart:typed_data';

import 'package:archive/archive.dart';
import 'package:app4training/data/globals.dart';
import 'package:dio/dio.dart';
import 'package:file/file.dart';
import 'package:path/path.dart' as p;

abstract interface class LanguageDownloader {
String pathFor(String langCode);
Future<bool> isDownloaded(String langCode);
Future<void> download(String langCode);
Future<void> delete(String langCode);
}

class LanguageDownloaderImpl implements LanguageDownloader {
final String _root;
final Dio _dio;
final FileSystem _fileSystem;
Completer<void>? _inFlight;

LanguageDownloaderImpl({
required String root,
required Dio dio,
required FileSystem fileSystem,
}) : _root = root,
_dio = dio,
_fileSystem = fileSystem;

@override
String pathFor(String langCode) =>
p.join(_root, Globals.getAssetsDir(langCode));

@override
Future<bool> isDownloaded(String langCode) =>
_fileSystem.directory(pathFor(langCode)).exists();

@override
Future<void> download(String langCode) async {
// Serialize: wait for any in-flight download to finish
while (_inFlight != null) {
await _inFlight!.future;
}
final completer = Completer<void>();
_inFlight = completer;

final dest = pathFor(langCode);
final staging = '$dest.staging';
final old = '$dest.old';

try {
// Crash recovery: remove leftover staging dir
final stagingDir = _fileSystem.directory(staging);
if (await stagingDir.exists()) {
await stagingDir.delete(recursive: true);
}

// Download both zips concurrently
final results = await Future.wait([
_dio.get<List<int>>(
Globals.getRemoteUrlHtml(langCode),
options: Options(responseType: ResponseType.bytes),
),
_dio.get<List<int>>(
Globals.getRemoteUrlPdf(langCode),
options: Options(responseType: ResponseType.bytes),
),
]);

// Extract both zips into staging
for (final response in results) {
final bytes = Uint8List.fromList(response.data!);
final archive = ZipDecoder().decodeBytes(bytes);
for (final file in archive) {
final filePath = p.join(staging, file.name);
if (file.isFile) {
final outFile = _fileSystem.file(filePath);
await outFile.parent.create(recursive: true);
await outFile.writeAsBytes(file.content as List<int>);
} else {
await _fileSystem.directory(filePath).create(recursive: true);
}
}
}

// Atomic swap
final destDir = _fileSystem.directory(dest);
final oldDir = _fileSystem.directory(old);

if (await destDir.exists()) {
await destDir.rename(old);
}
await _fileSystem.directory(staging).rename(dest);

// Best-effort cleanup of old
if (await oldDir.exists()) {
try {
await oldDir.delete(recursive: true);
} catch (_) {}
}
} catch (e) {
// Clean up staging on failure
final stagingDir = _fileSystem.directory(staging);
if (await stagingDir.exists()) {
await stagingDir.delete(recursive: true);
}
rethrow;
} finally {
_inFlight = null;
completer.complete();
}
}

@override
Future<void> delete(String langCode) async {
final dir = _fileSystem.directory(pathFor(langCode));
if (await dir.exists()) {
await dir.delete(recursive: true);
}
}
}
Loading
Loading