313 lines
9.5 KiB
Dart
313 lines
9.5 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:background_downloader/background_downloader.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../core/app_config.dart';
|
|
import '../services/token_store.dart';
|
|
|
|
class UploadRecord {
|
|
const UploadRecord({
|
|
required this.taskId,
|
|
required this.name,
|
|
required this.filename,
|
|
required this.createdAt,
|
|
this.progress = 0,
|
|
this.status = TaskStatus.enqueued,
|
|
this.sizeBytes = -1,
|
|
this.errorMessage,
|
|
this.organizationId,
|
|
});
|
|
|
|
final String taskId;
|
|
final String name;
|
|
final String filename;
|
|
final DateTime createdAt;
|
|
final double progress;
|
|
final TaskStatus status;
|
|
final int sizeBytes;
|
|
final String? errorMessage;
|
|
final String? organizationId;
|
|
|
|
bool get isFinished => status.isFinalState;
|
|
bool get canRetry => status == TaskStatus.failed;
|
|
|
|
UploadRecord copyWith({
|
|
double? progress,
|
|
TaskStatus? status,
|
|
int? sizeBytes,
|
|
String? errorMessage,
|
|
}) => UploadRecord(
|
|
taskId: taskId,
|
|
name: name,
|
|
filename: filename,
|
|
createdAt: createdAt,
|
|
progress: progress ?? this.progress,
|
|
status: status ?? this.status,
|
|
sizeBytes: sizeBytes ?? this.sizeBytes,
|
|
errorMessage: errorMessage ?? this.errorMessage,
|
|
organizationId: organizationId,
|
|
);
|
|
}
|
|
|
|
class UploadController extends ChangeNotifier {
|
|
UploadController(this._tokens) {
|
|
_taskSubscription = FileDownloader().updates.listen(_onUpdate);
|
|
_databaseSubscription = FileDownloader().database.updates.listen(
|
|
_onDatabaseUpdate,
|
|
);
|
|
unawaited(_restoreHistory());
|
|
}
|
|
|
|
final TokenStore _tokens;
|
|
late final StreamSubscription<TaskUpdate> _taskSubscription;
|
|
late final StreamSubscription<TaskRecord> _databaseSubscription;
|
|
final Map<String, UploadRecord> _uploads = {};
|
|
final Map<String, Task> _tasks = {};
|
|
bool _notificationPermissionChecked = false;
|
|
bool _disposed = false;
|
|
String? _organizationId;
|
|
|
|
List<UploadRecord> get uploads {
|
|
final result =
|
|
_uploads.values
|
|
.where(
|
|
(item) =>
|
|
item.organizationId == null ||
|
|
item.organizationId == _organizationId,
|
|
)
|
|
.toList()
|
|
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
return result;
|
|
}
|
|
|
|
int completedVersion = 0;
|
|
|
|
void selectOrganization(String? organizationId) {
|
|
if (_organizationId == organizationId) return;
|
|
_organizationId = organizationId;
|
|
}
|
|
|
|
Future<void> _restoreHistory() async {
|
|
// start() initializes tracking asynchronously during application startup.
|
|
// A short retry also covers slower devices on their first launch.
|
|
for (var attempt = 0; attempt < 3; attempt++) {
|
|
try {
|
|
final records = await FileDownloader().database.allRecords(
|
|
group: 'uploads',
|
|
);
|
|
for (final record in records) {
|
|
_storeDatabaseRecord(record);
|
|
}
|
|
if (!_disposed) notifyListeners();
|
|
return;
|
|
} catch (_) {
|
|
await Future<void>.delayed(Duration(milliseconds: 400 * (attempt + 1)));
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> refreshHistory() => _restoreHistory();
|
|
|
|
Future<void> enqueue({
|
|
required String filename,
|
|
required String displayName,
|
|
String? folderId,
|
|
}) async {
|
|
await _ensureNotificationPermission();
|
|
final token = await _tokens.read();
|
|
if (token == null) throw StateError('Your session has expired');
|
|
final organizationId = await _tokens.readOrganization();
|
|
if (organizationId == null) throw StateError('Select an organization');
|
|
final task = UploadTask(
|
|
url: '${AppConfig.apiBaseUrl}/files/upload',
|
|
filename: filename,
|
|
directory: 'upload_queue',
|
|
baseDirectory: BaseDirectory.applicationSupport,
|
|
fileField: 'file',
|
|
fields: folderId == null ? const {} : {'folderId': folderId},
|
|
headers: {
|
|
'Authorization': 'Bearer $token',
|
|
'X-Organization-Id': organizationId,
|
|
},
|
|
group: 'uploads',
|
|
updates: Updates.statusAndProgress,
|
|
retries: 2,
|
|
displayName: displayName,
|
|
metaData: jsonEncode({
|
|
'name': displayName,
|
|
'organizationId': organizationId,
|
|
}),
|
|
);
|
|
_tasks[task.taskId] = task;
|
|
_uploads[task.taskId] = UploadRecord(
|
|
taskId: task.taskId,
|
|
name: displayName,
|
|
filename: filename,
|
|
createdAt: task.creationTime,
|
|
organizationId: organizationId,
|
|
);
|
|
notifyListeners();
|
|
final accepted = await FileDownloader().enqueue(task);
|
|
if (!accepted) {
|
|
_uploads[task.taskId] = _uploads[task.taskId]!.copyWith(
|
|
status: TaskStatus.failed,
|
|
errorMessage: 'The background upload queue rejected this file.',
|
|
);
|
|
notifyListeners();
|
|
throw StateError('The background upload queue rejected this file');
|
|
}
|
|
}
|
|
|
|
Future<void> _ensureNotificationPermission() async {
|
|
if (_notificationPermissionChecked) return;
|
|
_notificationPermissionChecked = true;
|
|
final status = await FileDownloader().permissions.status(
|
|
PermissionType.notifications,
|
|
);
|
|
if (status != PermissionStatus.granted) {
|
|
await FileDownloader().permissions.request(PermissionType.notifications);
|
|
}
|
|
}
|
|
|
|
void _onUpdate(TaskUpdate update) {
|
|
_tasks[update.task.taskId] = update.task;
|
|
final current = _uploads[update.task.taskId] ?? _fromTask(update.task);
|
|
switch (update) {
|
|
case TaskProgressUpdate():
|
|
_uploads[update.task.taskId] = current.copyWith(
|
|
progress: update.progress.clamp(0, 1),
|
|
);
|
|
case TaskStatusUpdate():
|
|
_uploads[update.task.taskId] = current.copyWith(
|
|
status: update.status,
|
|
progress: update.status == TaskStatus.complete ? 1 : current.progress,
|
|
errorMessage: update.exception?.description,
|
|
);
|
|
if (update.status == TaskStatus.complete) completedVersion++;
|
|
if (update.status == TaskStatus.complete ||
|
|
update.status == TaskStatus.canceled ||
|
|
update.status == TaskStatus.notFound) {
|
|
unawaited(_deleteStagedFile(update.task));
|
|
}
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
void _onDatabaseUpdate(TaskRecord record) {
|
|
if (record.group != 'uploads') return;
|
|
_storeDatabaseRecord(record);
|
|
if (!_disposed) notifyListeners();
|
|
}
|
|
|
|
void _storeDatabaseRecord(TaskRecord record) {
|
|
_tasks[record.taskId] = record.task;
|
|
_uploads[record.taskId] = UploadRecord(
|
|
taskId: record.taskId,
|
|
name: _taskName(record.task),
|
|
filename: record.task.filename,
|
|
createdAt: record.task.creationTime,
|
|
progress: record.progress.clamp(0, 1),
|
|
status: record.status,
|
|
sizeBytes: record.expectedFileSize,
|
|
errorMessage: record.exception?.description,
|
|
organizationId: _taskOrganization(record.task),
|
|
);
|
|
}
|
|
|
|
UploadRecord _fromTask(Task task) => UploadRecord(
|
|
taskId: task.taskId,
|
|
name: _taskName(task),
|
|
filename: task.filename,
|
|
createdAt: task.creationTime,
|
|
organizationId: _taskOrganization(task),
|
|
);
|
|
|
|
Map<String, dynamic>? _taskMetadata(Task task) {
|
|
try {
|
|
final value = jsonDecode(task.metaData);
|
|
return value is Map<String, dynamic> ? value : null;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
String _taskName(Task task) =>
|
|
_taskMetadata(task)?['name'] as String? ?? task.displayName;
|
|
|
|
String? _taskOrganization(Task task) =>
|
|
_taskMetadata(task)?['organizationId'] as String?;
|
|
|
|
Future<void> _deleteStagedFile(Task task) async {
|
|
final file = File(await task.filePath());
|
|
if (await file.exists()) await file.delete();
|
|
}
|
|
|
|
Future<void> cancel(String taskId) =>
|
|
FileDownloader().cancelTaskWithId(taskId);
|
|
|
|
Future<void> retry(String taskId) async {
|
|
final original = _tasks[taskId];
|
|
if (original is! UploadTask) {
|
|
throw StateError('The original upload information is unavailable.');
|
|
}
|
|
final file = File(await original.filePath());
|
|
if (!await file.exists()) {
|
|
throw StateError(
|
|
'The staged file is no longer available. Select it again.',
|
|
);
|
|
}
|
|
final token = await _tokens.read();
|
|
if (token == null) throw StateError('Your session has expired');
|
|
final organizationId = await _tokens.readOrganization();
|
|
if (organizationId == null) throw StateError('Select an organization');
|
|
final retried = original.copyWith(
|
|
taskId: DateTime.now().microsecondsSinceEpoch.toString(),
|
|
headers: {
|
|
'Authorization': 'Bearer $token',
|
|
'X-Organization-Id': organizationId,
|
|
},
|
|
creationTime: DateTime.now(),
|
|
retriesRemaining: original.retries,
|
|
);
|
|
_tasks[retried.taskId] = retried;
|
|
_uploads[retried.taskId] = _fromTask(retried);
|
|
notifyListeners();
|
|
if (!await FileDownloader().enqueue(retried)) {
|
|
_uploads[retried.taskId] = _uploads[retried.taskId]!.copyWith(
|
|
status: TaskStatus.failed,
|
|
errorMessage: 'The background upload queue rejected this retry.',
|
|
);
|
|
notifyListeners();
|
|
throw StateError('The background upload queue rejected this retry.');
|
|
}
|
|
}
|
|
|
|
Future<void> removeHistory(String taskId) async {
|
|
final task = _tasks.remove(taskId);
|
|
_uploads.remove(taskId);
|
|
await FileDownloader().database.deleteRecordWithId(taskId);
|
|
if (task != null && task.group == 'uploads') {
|
|
await _deleteStagedFile(task);
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> clearFinished() async {
|
|
final finished = _uploads.values.where((item) => item.isFinished).toList();
|
|
for (final item in finished) {
|
|
await removeHistory(item.taskId);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_disposed = true;
|
|
_taskSubscription.cancel();
|
|
_databaseSubscription.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|