feat(backup): introduce backup status screen

This commit is contained in:
Prateek Sunal
2024-08-28 01:19:44 +05:30
committed by Neeraj Gupta
parent 2044d3eb6b
commit 864b5514be
7 changed files with 428 additions and 4 deletions

View File

@@ -0,0 +1,10 @@
import "dart:collection";
import "package:photos/events/event.dart";
import "package:photos/models/backup/backup_item.dart";
class BackupUpdatedEvent extends Event {
final LinkedHashMap<String, BackupItem> items;
BackupUpdatedEvent(this.items);
}

View File

@@ -0,0 +1,55 @@
import "dart:async";
import "package:photos/models/backup/backup_item_status.dart";
import "package:photos/models/file/file.dart";
class BackupItem {
final BackupItemStatus status;
final EnteFile file;
final int collectionID;
final Completer<EnteFile> completer;
BackupItem({
required this.status,
required this.file,
required this.collectionID,
required this.completer,
});
BackupItem copyWith({
BackupItemStatus? status,
EnteFile? file,
int? collectionID,
Completer<EnteFile>? completer,
}) {
return BackupItem(
status: status ?? this.status,
file: file ?? this.file,
collectionID: collectionID ?? this.collectionID,
completer: completer ?? this.completer,
);
}
@override
String toString() {
return 'BackupItem(status: $status, file: $file, collectionID: $collectionID)';
}
@override
bool operator ==(covariant BackupItem other) {
if (identical(this, other)) return true;
return other.status == status &&
other.file == file &&
other.collectionID == collectionID &&
other.completer == completer;
}
@override
int get hashCode {
return status.hashCode ^
file.hashCode ^
collectionID.hashCode ^
completer.hashCode;
}
}

View File

@@ -0,0 +1,7 @@
enum BackupItemStatus {
inBackground,
inQueue,
uploading,
completed,
retry,
}

View File

@@ -0,0 +1,171 @@
import "dart:typed_data";
import 'package:flutter/material.dart';
import "package:photos/models/backup/backup_item.dart";
import "package:photos/models/backup/backup_item_status.dart";
import 'package:photos/theme/ente_theme.dart';
import "package:photos/utils/file_uploader.dart";
import "package:photos/utils/thumbnail_util.dart";
class BackupItemCard extends StatefulWidget {
const BackupItemCard({
super.key,
required this.item,
});
final BackupItem item;
@override
State<BackupItemCard> createState() => _BackupItemCardState();
}
class _BackupItemCardState extends State<BackupItemCard> {
Uint8List? thumbnail;
String? folderName;
@override
void initState() {
super.initState();
_getThumbnail();
_getFolderName();
}
@override
void dispose() {
super.dispose();
}
_getThumbnail() async {
thumbnail = await getThumbnail(widget.item.file);
setState(() {});
}
_getFolderName() async {
folderName = widget.item.file.deviceFolder ?? '';
setState(() {});
}
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);
return Container(
height: 60,
margin: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Theme.of(context).brightness == Brightness.light
? const Color(0xFF000000).withOpacity(0.08)
: const Color(0xFFFFFFFF).withOpacity(0.08),
width: 1,
),
),
child: Row(
children: [
SizedBox(
width: 60,
height: 60,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: thumbnail != null
? Image.memory(
thumbnail!,
fit: BoxFit.cover,
)
: const SizedBox(),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.item.file.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16,
height: 20 / 16,
color: Theme.of(context).brightness == Brightness.light
? const Color(0xFF000000)
: const Color(0xFFFFFFFF),
),
),
const SizedBox(height: 4),
Text(
folderName ?? "",
style: TextStyle(
fontSize: 14,
height: 17 / 14,
color: Theme.of(context).brightness == Brightness.light
? const Color.fromRGBO(0, 0, 0, 0.7)
: const Color.fromRGBO(255, 255, 255, 0.7),
),
),
],
),
),
const SizedBox(width: 12),
SizedBox(
height: 48,
width: 48,
child: Center(
child: switch (widget.item.status) {
BackupItemStatus.uploading => SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2.0,
color: colorScheme.primary700,
),
),
BackupItemStatus.completed => const SizedBox(
width: 24,
height: 24,
child: Icon(
Icons.check,
color: Color(0xFF00B33C),
),
),
BackupItemStatus.inQueue => SizedBox(
width: 24,
height: 24,
child: Icon(
Icons.history,
color: Theme.of(context).brightness == Brightness.light
? const Color.fromRGBO(0, 0, 0, .6)
: const Color.fromRGBO(255, 255, 255, .6),
),
),
BackupItemStatus.retry => IconButton(
icon: const Icon(
Icons.sync,
color: Color(0xFFFDB816),
),
onPressed: () async {
await FileUploader.instance.forceUpload(
widget.item.file,
widget.item.collectionID,
);
},
),
BackupItemStatus.inBackground => SizedBox(
width: 24,
height: 24,
child: Icon(
Icons.lock_reset,
color: Theme.of(context).brightness == Brightness.light
? const Color.fromRGBO(0, 0, 0, .6)
: const Color.fromRGBO(255, 255, 255, .6),
),
),
},
),
),
],
),
);
}
}

View File

@@ -6,6 +6,7 @@ import 'package:photos/ui/components/expandable_menu_item_widget.dart';
import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
import 'package:photos/ui/settings/backup/backup_folder_selection_page.dart'; import 'package:photos/ui/settings/backup/backup_folder_selection_page.dart';
import 'package:photos/ui/settings/backup/backup_settings_screen.dart'; import 'package:photos/ui/settings/backup/backup_settings_screen.dart';
import "package:photos/ui/settings/backup/backup_status_screen.dart";
import "package:photos/ui/settings/backup/free_space_options.dart"; import "package:photos/ui/settings/backup/free_space_options.dart";
import 'package:photos/ui/settings/common_settings.dart'; import 'package:photos/ui/settings/common_settings.dart';
import 'package:photos/utils/navigation_util.dart'; import 'package:photos/utils/navigation_util.dart';
@@ -47,6 +48,21 @@ class BackupSectionWidgetState extends State<BackupSectionWidget> {
}, },
), ),
sectionOptionSpacing, sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: S.of(context).backupStatus,
),
pressedColor: getEnteColorScheme(context).fillFaint,
trailingIcon: Icons.chevron_right_outlined,
trailingIconIsMuted: true,
onTap: () async {
await routeToPage(
context,
const BackupStatusScreen(),
);
},
),
sectionOptionSpacing,
MenuItemWidget( MenuItemWidget(
captionedTextWidget: CaptionedTextWidget( captionedTextWidget: CaptionedTextWidget(
title: S.of(context).backupSettings, title: S.of(context).backupSettings,

View File

@@ -0,0 +1,110 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import "dart:collection";
import 'package:flutter/material.dart';
import "package:photos/core/event_bus.dart";
import "package:photos/events/backup_updated_event.dart";
import "package:photos/generated/l10n.dart";
import "package:photos/models/backup/backup_item.dart";
import 'package:photos/ui/components/title_bar_title_widget.dart';
import 'package:photos/ui/components/title_bar_widget.dart';
import "package:photos/ui/settings/backup/backup_item_card.dart";
import "package:photos/utils/file_uploader.dart";
class BackupStatusScreen extends StatefulWidget {
const BackupStatusScreen({super.key});
@override
State<BackupStatusScreen> createState() => _BackupStatusScreenState();
}
class _BackupStatusScreenState extends State<BackupStatusScreen> {
LinkedHashMap<String, BackupItem> items = FileUploader.instance.allBackups;
@override
void initState() {
super.initState();
checkBackupUpdatedEvent();
}
void checkBackupUpdatedEvent() {
Bus.instance.on<BackupUpdatedEvent>().listen((event) {
items = event.items;
setState(() {});
});
}
@override
Widget build(BuildContext context) {
final List<BackupItem> items = this.items.values.toList();
return Scaffold(
body: CustomScrollView(
primary: false,
slivers: <Widget>[
TitleBarWidget(
flexibleSpaceTitle: TitleBarTitleWidget(
title: S.of(context).backupStatus,
),
),
items.isEmpty
? SliverFillRemaining(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 60,
vertical: 12,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_upload_outlined,
color:
Theme.of(context).brightness == Brightness.light
? const Color.fromRGBO(0, 0, 0, 0.6)
: const Color.fromRGBO(255, 255, 255, 0.6),
),
const SizedBox(height: 16),
Text(
S.of(context).backupStatusDescription,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
height: 20 / 16,
color:
Theme.of(context).brightness == Brightness.light
? const Color(0xFF000000).withOpacity(0.7)
: const Color(0xFFFFFFFF).withOpacity(0.7),
),
),
const SizedBox(height: 48),
],
),
),
)
: SliverList(
delegate: SliverChildBuilderDelegate(
(delegateBuildContext, index) {
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 20,
horizontal: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final item in items)
BackupItemCard(item: item),
],
),
);
},
childCount: 1,
),
),
],
),
);
}
}

View File

@@ -16,11 +16,14 @@ import 'package:photos/core/event_bus.dart';
import 'package:photos/core/network/network.dart'; import 'package:photos/core/network/network.dart';
import 'package:photos/db/files_db.dart'; import 'package:photos/db/files_db.dart';
import 'package:photos/db/upload_locks_db.dart'; import 'package:photos/db/upload_locks_db.dart';
import "package:photos/events/backup_updated_event.dart";
import "package:photos/events/file_uploaded_event.dart"; import "package:photos/events/file_uploaded_event.dart";
import 'package:photos/events/files_updated_event.dart'; import 'package:photos/events/files_updated_event.dart';
import 'package:photos/events/local_photos_updated_event.dart'; import 'package:photos/events/local_photos_updated_event.dart';
import 'package:photos/events/subscription_purchased_event.dart'; import 'package:photos/events/subscription_purchased_event.dart';
import 'package:photos/main.dart'; import 'package:photos/main.dart';
import "package:photos/models/backup/backup_item.dart";
import "package:photos/models/backup/backup_item_status.dart";
import 'package:photos/models/encryption_result.dart'; import 'package:photos/models/encryption_result.dart';
import 'package:photos/models/file/file.dart'; import 'package:photos/models/file/file.dart';
import 'package:photos/models/file/file_type.dart'; import 'package:photos/models/file/file_type.dart';
@@ -59,11 +62,15 @@ class FileUploader {
final _enteDio = NetworkClient.instance.enteDio; final _enteDio = NetworkClient.instance.enteDio;
final LinkedHashMap<String, FileUploadItem> _queue = final LinkedHashMap<String, FileUploadItem> _queue =
LinkedHashMap<String, FileUploadItem>(); LinkedHashMap<String, FileUploadItem>();
final LinkedHashMap<String, BackupItem> _allBackups =
LinkedHashMap<String, BackupItem>();
final _uploadLocks = UploadLocksDB.instance; final _uploadLocks = UploadLocksDB.instance;
final kSafeBufferForLockExpiry = const Duration(days: 1).inMicroseconds; final kSafeBufferForLockExpiry = const Duration(days: 1).inMicroseconds;
final kBGTaskDeathTimeout = const Duration(seconds: 5).inMicroseconds; final kBGTaskDeathTimeout = const Duration(seconds: 5).inMicroseconds;
final _uploadURLs = Queue<UploadURL>(); final _uploadURLs = Queue<UploadURL>();
LinkedHashMap<String, BackupItem> get allBackups => _allBackups;
// Maintains the count of files in the current upload session. // Maintains the count of files in the current upload session.
// Upload session is the period between the first entry into the _queue and last entry out of the _queue // Upload session is the period between the first entry into the _queue and last entry out of the _queue
int _totalCountInUploadSession = 0; int _totalCountInUploadSession = 0;
@@ -160,6 +167,13 @@ class FileUploader {
if (!_queue.containsKey(localID)) { if (!_queue.containsKey(localID)) {
final completer = Completer<EnteFile>(); final completer = Completer<EnteFile>();
_queue[localID] = FileUploadItem(file, collectionID, completer); _queue[localID] = FileUploadItem(file, collectionID, completer);
_allBackups[localID] = BackupItem(
status: BackupItemStatus.inQueue,
file: file,
collectionID: collectionID,
completer: completer,
);
Bus.instance.fire(BackupUpdatedEvent(_allBackups));
_pollQueue(); _pollQueue();
return completer.future; return completer.future;
} }
@@ -203,6 +217,10 @@ class FileUploader {
}); });
for (final id in uploadsToBeRemoved) { for (final id in uploadsToBeRemoved) {
_queue.remove(id)?.completer.completeError(reason); _queue.remove(id)?.completer.completeError(reason);
_allBackups[id] = _allBackups[id]!.copyWith(
status: BackupItemStatus.retry,
);
Bus.instance.fire(BackupUpdatedEvent(_allBackups));
} }
_totalCountInUploadSession = 0; _totalCountInUploadSession = 0;
} }
@@ -225,6 +243,9 @@ class FileUploader {
}); });
for (final id in uploadsToBeRemoved) { for (final id in uploadsToBeRemoved) {
_queue.remove(id)?.completer.completeError(reason); _queue.remove(id)?.completer.completeError(reason);
_allBackups[id] =
_allBackups[id]!.copyWith(status: BackupItemStatus.retry);
Bus.instance.fire(BackupUpdatedEvent(_allBackups));
} }
_logger.info( _logger.info(
'number of enteries removed from queue ${uploadsToBeRemoved.length}', 'number of enteries removed from queue ${uploadsToBeRemoved.length}',
@@ -291,13 +312,21 @@ class FileUploader {
}, },
); );
_queue.remove(localID)!.completer.complete(uploadedFile); _queue.remove(localID)!.completer.complete(uploadedFile);
_allBackups[localID] =
_allBackups[localID]!.copyWith(status: BackupItemStatus.completed);
return uploadedFile; return uploadedFile;
} catch (e) { } catch (e) {
if (e is LockAlreadyAcquiredError) { if (e is LockAlreadyAcquiredError) {
_queue[localID]!.status = UploadStatus.inBackground; _queue[localID]!.status = UploadStatus.inBackground;
_allBackups[localID] = _allBackups[localID]!
.copyWith(status: BackupItemStatus.inBackground);
Bus.instance.fire(BackupUpdatedEvent(_allBackups));
return _queue[localID]!.completer.future; return _queue[localID]!.completer.future;
} else { } else {
_queue.remove(localID)!.completer.completeError(e); _queue.remove(localID)!.completer.completeError(e);
_allBackups[localID] =
_allBackups[localID]!.copyWith(status: BackupItemStatus.retry);
Bus.instance.fire(BackupUpdatedEvent(_allBackups));
return null; return null;
} }
} finally { } finally {
@@ -406,7 +435,20 @@ class FileUploader {
Future<EnteFile> forceUpload(EnteFile file, int collectionID) async { Future<EnteFile> forceUpload(EnteFile file, int collectionID) async {
_hasInitiatedForceUpload = true; _hasInitiatedForceUpload = true;
return _tryToUpload(file, collectionID, true); try {
final result = await _tryToUpload(file, collectionID, true);
_allBackups[file.localID!] = _allBackups[file.localID]!.copyWith(
status: BackupItemStatus.completed,
);
Bus.instance.fire(BackupUpdatedEvent(_allBackups));
return result;
} catch (_) {
_allBackups[file.localID!] = _allBackups[file.localID]!.copyWith(
status: BackupItemStatus.retry,
);
Bus.instance.fire(BackupUpdatedEvent(_allBackups));
rethrow;
}
} }
Future<EnteFile> _tryToUpload( Future<EnteFile> _tryToUpload(
@@ -426,6 +468,14 @@ class FileUploader {
return fileOnDisk; return fileOnDisk;
} }
} }
if (_allBackups[file.localID!] != null &&
_allBackups[file.localID]!.status != BackupItemStatus.uploading) {
_allBackups[file.localID!] = _allBackups[file.localID]!.copyWith(
status: BackupItemStatus.uploading,
);
Bus.instance.fire(BackupUpdatedEvent(_allBackups));
}
if ((file.localID ?? '') == '') { if ((file.localID ?? '') == '') {
_logger.severe('Trying to upload file with missing localID'); _logger.severe('Trying to upload file with missing localID');
return file; return file;
@@ -442,7 +492,7 @@ class FileUploader {
} }
final String lockKey = file.localID!; final String lockKey = file.localID!;
bool _isMultipartUpload = false; bool isMultipartUpload = false;
try { try {
await _uploadLocks.acquireLock( await _uploadLocks.acquireLock(
@@ -589,7 +639,7 @@ class FileUploader {
final fileUploadURL = await _getUploadURL(); final fileUploadURL = await _getUploadURL();
fileObjectKey = await _putFile(fileUploadURL, encryptedFile); fileObjectKey = await _putFile(fileUploadURL, encryptedFile);
} else { } else {
_isMultipartUpload = true; isMultipartUpload = true;
_logger.finest( _logger.finest(
"Init multipartUpload $multipartEntryExists, isUpdate $isUpdatedFile", "Init multipartUpload $multipartEntryExists, isUpdate $isUpdatedFile",
); );
@@ -757,7 +807,7 @@ class FileUploader {
encryptedFilePath, encryptedFilePath,
encryptedThumbnailPath, encryptedThumbnailPath,
lockKey: lockKey, lockKey: lockKey,
isMultiPartUpload: _isMultipartUpload, isMultiPartUpload: isMultipartUpload,
); );
} }
} }
@@ -1280,10 +1330,15 @@ class FileUploader {
if (dbFile?.uploadedFileID != null) { if (dbFile?.uploadedFileID != null) {
_logger.info("Background upload success detected"); _logger.info("Background upload success detected");
completer?.complete(dbFile); completer?.complete(dbFile);
_allBackups[upload.key] = _allBackups[upload.key]!
.copyWith(status: BackupItemStatus.completed);
} else { } else {
_logger.info("Background upload failure detected"); _logger.info("Background upload failure detected");
completer?.completeError(SilentlyCancelUploadsError()); completer?.completeError(SilentlyCancelUploadsError());
_allBackups[upload.key] =
_allBackups[upload.key]!.copyWith(status: BackupItemStatus.retry);
} }
Bus.instance.fire(BackupUpdatedEvent(_allBackups));
} }
} }
Future.delayed(kBlockedUploadsPollFrequency, () async { Future.delayed(kBlockedUploadsPollFrequency, () async {