feat(mobile): new upload (#18726)

This commit is contained in:
Alex 2025-07-18 23:58:53 -05:00 committed by GitHub
parent f929dc0816
commit fafb88d31c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1733 additions and 102 deletions

View File

@ -16,6 +16,11 @@ const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB
// Secure storage keys
const String kSecuredPinCode = "secured_pin_code";
// background_downloader task groups
const String kManualUploadGroup = 'manual_upload_group';
const String kBackupGroup = 'backup_group';
const String kBackupLivePhotoGroup = 'backup_live_photo_group';
// Timeline constants
const int kTimelineNoneSegmentSize = 120;
const int kTimelineAssetLoadBatchSize = 256;

View File

@ -14,4 +14,8 @@ class LocalAlbumService {
Future<LocalAsset?> getThumbnail(String albumId) {
return _repository.getThumbnail(albumId);
}
Future<void> update(LocalAlbum album) {
return _repository.upsert(album);
}
}

View File

@ -0,0 +1,154 @@
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import "package:immich_mobile/utils/database.utils.dart";
final backupRepositoryProvider = Provider<DriftBackupRepository>(
(ref) => DriftBackupRepository(ref.watch(driftProvider)),
);
class DriftBackupRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftBackupRepository(this._db) : super(_db);
_getExcludedSubquery() {
return _db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
..join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
])
..where(
_db.localAlbumEntity.backupSelection
.equalsValue(BackupSelection.excluded),
);
}
Future<int> getTotalCount() async {
final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
..addColumns([_db.localAlbumAssetEntity.assetId])
..join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
])
..where(
_db.localAlbumEntity.backupSelection
.equalsValue(BackupSelection.selected) &
_db.localAlbumAssetEntity.assetId
.isNotInQuery(_getExcludedSubquery()),
);
return query.get().then((rows) => rows.length);
}
Future<int> getRemainderCount() async {
final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
..addColumns(
[_db.localAlbumAssetEntity.assetId],
)
..join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
innerJoin(
_db.localAssetEntity,
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
useColumns: false,
),
leftOuterJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum
.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])
..where(
_db.localAlbumEntity.backupSelection
.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.id.isNull() &
_db.localAlbumAssetEntity.assetId
.isNotInQuery(_getExcludedSubquery()),
);
return query.get().then((rows) => rows.length);
}
Future<int> getBackupCount() async {
final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
..addColumns(
[_db.localAlbumAssetEntity.assetId],
)
..join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
innerJoin(
_db.localAssetEntity,
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
useColumns: false,
),
innerJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum
.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])
..where(
_db.localAlbumEntity.backupSelection
.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.id.isNotNull() &
_db.localAlbumAssetEntity.assetId
.isNotInQuery(_getExcludedSubquery()),
);
return query.get().then((rows) => rows.length);
}
Future<List<LocalAsset>> getCandidates() async {
final selectedAlbumIds = _db.localAlbumEntity.selectOnly(distinct: true)
..addColumns([_db.localAlbumEntity.id])
..where(
_db.localAlbumEntity.backupSelection
.equalsValue(BackupSelection.selected),
);
final query = _db.localAssetEntity.select()
..where(
(lae) =>
existsQuery(
_db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
..where(
_db.localAlbumAssetEntity.albumId
.isInQuery(selectedAlbumIds) &
_db.localAlbumAssetEntity.assetId.equalsExp(lae.id),
),
) &
notExistsQuery(
_db.remoteAssetEntity.selectOnly()
..addColumns([_db.remoteAssetEntity.checksum])
..where(
_db.remoteAssetEntity.checksum.equalsExp(lae.checksum) &
lae.checksum.isNotNull(),
),
) &
lae.id.isNotInQuery(_getExcludedSubquery()),
);
return query.map((localAsset) => localAsset.toDto()).get();
}
}

View File

@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.d
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/database.utils.dart';
import 'package:platform/platform.dart';
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum }
@ -381,30 +382,3 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return results.isNotEmpty ? results.first : null;
}
}
extension on LocalAlbumEntityData {
LocalAlbum toDto({int assetCount = 0}) {
return LocalAlbum(
id: id,
name: name,
updatedAt: updatedAt,
assetCount: assetCount,
backupSelection: backupSelection,
);
}
}
extension on LocalAssetEntityData {
LocalAsset toDto() {
return LocalAsset(
id: id,
name: name,
checksum: checksum,
type: type,
createdAt: createdAt,
updatedAt: updatedAt,
durationInSeconds: durationInSeconds,
isFavorite: isFavorite,
);
}
}

View File

@ -56,4 +56,11 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
}
});
}
Future<LocalAsset?> getById(String id) {
final query = _db.localAssetEntity.select()
..where((lae) => lae.id.equals(id));
return query.map((row) => row.toDto()).getSingleOrNull();
}
}

View File

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
@ -7,8 +8,9 @@ class StorageRepository {
const StorageRepository();
Future<File?> getFileForAsset(String assetId) async {
final log = Logger('StorageRepository');
File? file;
final log = Logger('StorageRepository');
try {
final entity = await AssetEntity.fromId(assetId);
file = await entity?.originFile;
@ -20,4 +22,48 @@ class StorageRepository {
}
return file;
}
Future<File?> getMotionFileForAsset(LocalAsset asset) async {
File? file;
final log = Logger('StorageRepository');
try {
final entity = await AssetEntity.fromId(asset.id);
file = await entity?.originFileWithSubtype;
if (file == null) {
log.warning(
"Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
);
}
} catch (error, stackTrace) {
log.warning(
"Error getting motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
error,
stackTrace,
);
}
return file;
}
Future<AssetEntity?> getAssetEntityForAsset(LocalAsset asset) async {
final log = Logger('StorageRepository');
AssetEntity? entity;
try {
entity = await AssetEntity.fromId(asset.id);
if (entity == null) {
log.warning(
"Cannot get AssetEntity for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
);
}
} catch (error, stackTrace) {
log.warning(
"Error getting AssetEntity for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
error,
stackTrace,
);
}
return entity;
}
}

View File

@ -93,6 +93,13 @@ Future<void> initApp() async {
initializeTimeZones();
// Initialize the file downloader
await FileDownloader().configure(
// maxConcurrent: 5, maxConcurrentByHost: 2, maxConcurrentByGroup: 3
globalConfig: (Config.holdingQueue, (5, 2, 3)),
);
await FileDownloader().trackTasksInGroup(
downloadGroupLivePhoto,
markDownloadedComplete: false,
@ -171,7 +178,21 @@ class ImmichAppState extends ConsumerState<ImmichApp>
}
void _configureFileDownloaderNotifications() {
FileDownloader().configureNotification(
FileDownloader().configureNotificationForGroup(
downloadGroupImage,
running: TaskNotification(
'downloading_media'.tr(),
'${'file_name'.tr()}: {filename}',
),
complete: TaskNotification(
'download_finished'.tr(),
'${'file_name'.tr()}: {filename}',
),
progressBar: true,
);
FileDownloader().configureNotificationForGroup(
downloadGroupVideo,
running: TaskNotification(
'downloading_media'.tr(),
'${'file_name'.tr()}: {filename}',

View File

@ -17,7 +17,7 @@ enum UploadStatus {
notFound,
failed,
canceled,
waitingtoRetry,
waitingToRetry,
paused,
}

View File

@ -0,0 +1,294 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
@RoutePage()
class DriftBackupPage extends HookConsumerWidget {
const DriftBackupPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
useEffect(
() {
ref.read(driftBackupProvider.notifier).getBackupStatus();
return null;
},
[],
);
Widget buildControlButtons() {
return Padding(
padding: const EdgeInsets.only(
top: 24,
),
child: Column(
children: [
ElevatedButton(
onPressed: () => ref.read(driftBackupProvider.notifier).backup(),
child: const Text(
"backup_controller_page_start_backup",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
).tr(),
),
OutlinedButton(
onPressed: () => ref.read(driftBackupProvider.notifier).cancel(),
child: const Text(
"cancel",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
).tr(),
),
OutlinedButton(
onPressed: () =>
ref.read(driftBackupProvider.notifier).getDataInfo(),
child: const Text(
"Get database info",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
).tr(),
),
],
),
);
}
return Scaffold(
appBar: AppBar(
elevation: 0,
title: const Text(
"Backup (Experimental)",
),
leading: IconButton(
onPressed: () {
ref.watch(websocketProvider.notifier).listenUploadEvent();
context.maybePop(true);
},
splashRadius: 24,
icon: const Icon(
Icons.arrow_back_ios_rounded,
),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
onPressed: () => context.pushRoute(const BackupOptionsRoute()),
splashRadius: 24,
icon: const Icon(
Icons.settings_outlined,
),
),
),
],
),
body: Stack(
children: [
Padding(
padding: const EdgeInsets.only(
left: 16.0,
right: 16,
bottom: 32,
),
child: ListView(
children: [
const SizedBox(height: 8),
const _BackupAlbumSelectionCard(),
const _TotalCard(),
const _BackupCard(),
const _RemainderCard(),
const Divider(),
buildControlButtons(),
],
),
),
],
),
);
}
}
class _BackupAlbumSelectionCard extends ConsumerWidget {
const _BackupAlbumSelectionCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget buildSelectedAlbumName() {
String text = "backup_controller_page_backup_selected".tr();
final albums = ref
.watch(backupAlbumProvider)
.where(
(album) => album.backupSelection == BackupSelection.selected,
)
.toList();
if (albums.isNotEmpty) {
for (var album in albums) {
if (album.name == "Recent" || album.name == "Recents") {
text += "${album.name} (${'all'.tr()}), ";
} else {
text += "${album.name}, ";
}
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
text.trim().substring(0, text.length - 2),
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
),
);
} else {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
"backup_controller_page_none_selected".tr(),
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
),
);
}
}
Widget buildExcludedAlbumName() {
String text = "backup_controller_page_excluded".tr();
final albums = ref
.watch(backupAlbumProvider)
.where(
(album) => album.backupSelection == BackupSelection.excluded,
)
.toList();
if (albums.isNotEmpty) {
for (var album in albums) {
text += "${album.name}, ";
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
text.trim().substring(0, text.length - 2),
style: context.textTheme.labelLarge?.copyWith(
color: Colors.red[300],
),
),
);
} else {
return const SizedBox();
}
}
return Card(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(20)),
side: BorderSide(
color: context.colorScheme.outlineVariant,
width: 1,
),
),
elevation: 0,
borderOnForeground: false,
child: ListTile(
minVerticalPadding: 18,
title: Text(
"backup_controller_page_albums",
style: context.textTheme.titleMedium,
).tr(),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"backup_controller_page_to_backup",
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurfaceSecondary,
),
).tr(),
buildSelectedAlbumName(),
buildExcludedAlbumName(),
],
),
),
trailing: ElevatedButton(
onPressed: () async {
await context.pushRoute(const DriftBackupAlbumSelectionRoute());
ref.read(driftBackupProvider.notifier).getBackupStatus();
},
child: const Text(
"select",
style: TextStyle(
fontWeight: FontWeight.bold,
),
).tr(),
),
),
);
}
}
class _TotalCard extends ConsumerWidget {
const _TotalCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final totalCount =
ref.watch(driftBackupProvider.select((p) => p.totalCount));
return BackupInfoCard(
title: "total".tr(),
subtitle: "backup_controller_page_total_sub".tr(),
info: totalCount.toString(),
);
}
}
class _BackupCard extends ConsumerWidget {
const _BackupCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final backupCount =
ref.watch(driftBackupProvider.select((p) => p.backupCount));
return BackupInfoCard(
title: "backup_controller_page_backup".tr(),
subtitle: "backup_controller_page_backup_sub".tr(),
info: backupCount.toString(),
);
}
}
class _RemainderCard extends ConsumerWidget {
const _RemainderCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final remainderCount =
ref.watch(driftBackupProvider.select((p) => p.remainderCount));
return BackupInfoCard(
title: "backup_controller_page_remainder".tr(),
subtitle: "backup_controller_page_remainder_sub".tr(),
info: remainderCount.toString(),
);
}
}

View File

@ -0,0 +1,307 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
@RoutePage()
class DriftBackupAlbumSelectionPage extends HookConsumerWidget {
const DriftBackupAlbumSelectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(backupAlbumProvider);
final selectedBackupAlbums = albums
.where((album) => album.backupSelection == BackupSelection.selected)
.toList();
final excludedBackupAlbums = albums
.where((album) => album.backupSelection == BackupSelection.excluded)
.toList();
final enableSyncUploadAlbum =
useAppSettingsState(AppSettingsEnum.syncAlbums);
final isDarkTheme = context.isDarkTheme;
useEffect(
() {
ref.watch(backupProvider.notifier).getBackupInfo();
ref.watch(backupAlbumProvider.notifier).getAll();
return null;
},
[],
);
buildAlbumSelectionList() {
if (albums.isEmpty) {
return const SliverToBoxAdapter(
child: Center(
child: CircularProgressIndicator(),
),
);
}
return SliverPadding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
((context, index) {
return DriftAlbumInfoListTile(
album: albums[index],
);
}),
childCount: albums.length,
),
),
);
}
buildAlbumSelectionGrid() {
if (albums.isEmpty) {
return const SliverToBoxAdapter(
child: Center(
child: CircularProgressIndicator(),
),
);
}
return SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 300,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
itemCount: albums.length,
itemBuilder: ((context, index) {
return DriftAlbumInfoListTile(
album: albums[index],
);
}),
),
);
}
buildSelectedAlbumNameChip() {
return selectedBackupAlbums.map((album) {
void removeSelection() {
ref.read(backupAlbumProvider.notifier).deselectAlbum(album);
}
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: GestureDetector(
onTap: removeSelection,
child: Chip(
label: Text(
album.name,
style: TextStyle(
fontSize: 12,
color: isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
),
backgroundColor: context.primaryColor,
deleteIconColor: isDarkTheme ? Colors.black : Colors.white,
deleteIcon: const Icon(
Icons.cancel_rounded,
size: 15,
),
onDeleted: removeSelection,
),
),
);
}).toSet();
}
buildExcludedAlbumNameChip() {
return excludedBackupAlbums.map((album) {
void removeSelection() {
ref.read(backupAlbumProvider.notifier).deselectAlbum(album);
}
return GestureDetector(
onTap: removeSelection,
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Chip(
label: Text(
album.name,
style: TextStyle(
fontSize: 12,
color: context.scaffoldBackgroundColor,
fontWeight: FontWeight.bold,
),
),
backgroundColor: Colors.red[300],
deleteIconColor: context.scaffoldBackgroundColor,
deleteIcon: const Icon(
Icons.cancel_rounded,
size: 15,
),
onDeleted: removeSelection,
),
),
);
}).toSet();
}
handleSyncAlbumToggle(bool isEnable) async {
if (isEnable) {
await ref.read(albumProvider.notifier).refreshRemoteAlbums();
for (final album in selectedBackupAlbums) {
await ref.read(albumProvider.notifier).createSyncAlbum(album.name);
}
}
}
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => context.maybePop(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
title: const Text(
"backup_album_selection_page_select_albums",
).tr(),
elevation: 0,
),
body: SafeArea(
child: CustomScrollView(
physics: const ClampingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
child: Text(
"backup_album_selection_page_selection_info",
style: context.textTheme.titleSmall,
).tr(),
),
// Selected Album Chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap(
children: [
...buildSelectedAlbumNameChip(),
...buildExcludedAlbumNameChip(),
],
),
),
SettingsSwitchListTile(
valueNotifier: enableSyncUploadAlbum,
title: "sync_albums".tr(),
subtitle: "sync_upload_album_setting_subtitle".tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
titleStyle: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
subtitleStyle: context.textTheme.labelLarge?.copyWith(
color: context.colorScheme.primary,
),
onChanged: handleSyncAlbumToggle,
),
ListTile(
title: Text(
"backup_album_selection_page_albums_device".tr(
namedArgs: {
'count': ref
.watch(backupProvider)
.availableAlbums
.length
.toString(),
},
),
style: context.textTheme.titleSmall,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
"backup_album_selection_page_albums_tap",
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
).tr(),
),
trailing: IconButton(
splashRadius: 16,
icon: Icon(
Icons.info,
size: 20,
color: context.primaryColor,
),
onPressed: () {
// show the dialog
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: const RoundedRectangleBorder(
borderRadius:
BorderRadius.all(Radius.circular(10)),
),
elevation: 5,
title: Text(
'backup_album_selection_page_selection_info',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: context.primaryColor,
),
).tr(),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text(
'backup_album_selection_page_assets_scatter',
style: TextStyle(
fontSize: 14,
),
).tr(),
],
),
),
);
},
);
},
),
),
// buildSearchBar(),
],
),
),
SliverLayoutBuilder(
builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) {
return buildAlbumSelectionGrid();
} else {
return buildAlbumSelectionList();
}
},
),
],
),
),
);
}
}

View File

@ -8,6 +8,7 @@ import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:permission_handler/permission_handler.dart';
@ -42,6 +43,9 @@ class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
albumNotifier.dispose();
}
ref.read(websocketProvider.notifier).stopListenToOldEvents();
ref.read(websocketProvider.notifier).startListeningToBetaEvents();
final permission = await ref
.read(galleryPermissionNotifier.notifier)
.requestGalleryPermission();
@ -55,6 +59,8 @@ class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
}
} else {
await ref.read(backgroundSyncProvider).cancel();
ref.read(websocketProvider.notifier).stopListeningToBetaEvents();
ref.read(websocketProvider.notifier).startListeningToOldEvents();
}
if (mounted) {

View File

@ -268,7 +268,7 @@ class UploadStatusIcon extends StatelessWidget {
color: Colors.red,
semanticLabel: 'canceled'.tr(),
),
UploadStatus.waitingtoRetry || UploadStatus.paused => Icon(
UploadStatus.waitingToRetry || UploadStatus.paused => Icon(
Icons.pause_circle_rounded,
color: context.primaryColor,
semanticLabel: 'paused'.tr(),

View File

@ -91,6 +91,10 @@ final _features = [
),
_Feature(
name: 'Clear Local Data',
style: const TextStyle(
color: Colors.orange,
fontWeight: FontWeight.bold,
),
icon: Icons.delete_forever_rounded,
onTap: (_, ref) async {
final db = ref.read(driftProvider);
@ -101,6 +105,10 @@ final _features = [
),
_Feature(
name: 'Clear Remote Data',
style: const TextStyle(
color: Colors.orange,
fontWeight: FontWeight.bold,
),
icon: Icons.delete_sweep_rounded,
onTap: (_, ref) async {
final db = ref.read(driftProvider);
@ -117,17 +125,29 @@ final _features = [
),
_Feature(
name: 'Local Media Summary',
style: const TextStyle(
color: Colors.indigo,
fontWeight: FontWeight.bold,
),
icon: Icons.table_chart_rounded,
onTap: (ctx, _) => ctx.pushRoute(const LocalMediaSummaryRoute()),
),
_Feature(
name: 'Remote Media Summary',
style: const TextStyle(
color: Colors.indigo,
fontWeight: FontWeight.bold,
),
icon: Icons.summarize_rounded,
onTap: (ctx, _) => ctx.pushRoute(const RemoteMediaSummaryRoute()),
),
_Feature(
name: 'Reset Sqlite',
icon: Icons.table_view_rounded,
style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
onTap: (_, ref) async {
final drift = ref.read(driftProvider);
// ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
@ -160,7 +180,10 @@ class FeatInDevPage extends StatelessWidget {
final feat = _features[index];
return Consumer(
builder: (ctx, ref, _) => ListTile(
title: Text(feat.name),
title: Text(
feat.name,
style: feat.style,
),
trailing: Icon(feat.icon),
visualDensity: VisualDensity.compact,
onTap: () => unawaited(feat.onTap(ctx, ref)),
@ -183,10 +206,12 @@ class _Feature {
required this.name,
required this.icon,
required this.onTap,
this.style,
});
final String name;
final IconData icon;
final TextStyle? style;
final Future<void> Function(BuildContext, WidgetRef _) onTap;
}

View File

@ -69,6 +69,9 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
}
await _ref.read(serverInfoProvider.notifier).getServerVersion();
// TODO: Need to decide on how we want to handle uploads once the app is resumed
// await FileDownloader().start();
}
if (!Store.isBetaTimelineEnabled) {

View File

@ -1,7 +1,6 @@
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
@ -30,7 +29,7 @@ class ShareIntentUploadStateNotifier
this._uploadService,
this._shareIntentService,
) : super([]) {
_uploadService.onUploadStatus = _uploadStatusCallback;
_uploadService.onUploadStatus = _updateUploadStatus;
_uploadService.onTaskProgress = _taskProgressCallback;
}
@ -69,8 +68,8 @@ class ShareIntentUploadStateNotifier
state = [];
}
void _updateUploadStatus(TaskStatusUpdate task, TaskStatus status) async {
if (status == TaskStatus.canceled) {
void _updateUploadStatus(TaskStatusUpdate task) async {
if (task.status == TaskStatus.canceled) {
return;
}
@ -83,7 +82,7 @@ class ShareIntentUploadStateNotifier
TaskStatus.running => UploadStatus.running,
TaskStatus.paused => UploadStatus.paused,
TaskStatus.notFound => UploadStatus.notFound,
TaskStatus.waitingToRetry => UploadStatus.waitingtoRetry
TaskStatus.waitingToRetry => UploadStatus.waitingToRetry
};
state = [
@ -95,27 +94,6 @@ class ShareIntentUploadStateNotifier
];
}
void _uploadStatusCallback(TaskStatusUpdate update) {
_updateUploadStatus(update, update.status);
switch (update.status) {
case TaskStatus.complete:
if (update.responseStatusCode == 200) {
if (kDebugMode) {
debugPrint("[COMPLETE] ${update.task.taskId} - DUPLICATE");
}
} else {
if (kDebugMode) {
debugPrint("[COMPLETE] ${update.task.taskId}");
}
}
break;
default:
break;
}
}
void _taskProgressCallback(TaskProgressUpdate update) {
// Ignore if the task is canceled or completed
if (update.progress == downloadFailed ||
@ -134,10 +112,6 @@ class ShareIntentUploadStateNotifier
}
Future<void> upload(File file) {
return _uploadService.upload(file);
}
Future<bool> cancelUpload(String id) {
return _uploadService.cancelUpload(id);
return _uploadService.buildUploadTask(file, group: kManualUploadGroup);
}
}

View File

@ -0,0 +1,62 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/services/local_album.service.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
final backupAlbumProvider =
StateNotifierProvider<BackupAlbumNotifier, List<LocalAlbum>>(
(ref) => BackupAlbumNotifier(
ref.watch(localAlbumServiceProvider),
),
);
class BackupAlbumNotifier extends StateNotifier<List<LocalAlbum>> {
BackupAlbumNotifier(this._localAlbumService) : super([]) {
getAll();
}
final LocalAlbumService _localAlbumService;
Future<void> getAll() async {
state = await _localAlbumService.getAll();
}
Future<void> selectAlbum(LocalAlbum album) async {
album = album.copyWith(backupSelection: BackupSelection.selected);
await _localAlbumService.update(album);
state = state
.map(
(currentAlbum) => currentAlbum.id == album.id
? currentAlbum.copyWith(backupSelection: BackupSelection.selected)
: currentAlbum,
)
.toList();
}
Future<void> deselectAlbum(LocalAlbum album) async {
album = album.copyWith(backupSelection: BackupSelection.none);
await _localAlbumService.update(album);
state = state
.map(
(currentAlbum) => currentAlbum.id == album.id
? currentAlbum.copyWith(backupSelection: BackupSelection.none)
: currentAlbum,
)
.toList();
}
Future<void> excludeAlbum(LocalAlbum album) async {
album = album.copyWith(backupSelection: BackupSelection.excluded);
await _localAlbumService.update(album);
state = state
.map(
(currentAlbum) => currentAlbum.id == album.id
? currentAlbum.copyWith(backupSelection: BackupSelection.excluded)
: currentAlbum,
)
.toList();
}
}

View File

@ -0,0 +1,194 @@
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/services/drift_backup.service.dart';
import 'package:immich_mobile/services/upload.service.dart';
class DriftUploadStatus {
final String taskId;
final String filename;
final double progress;
const DriftUploadStatus({
required this.taskId,
required this.filename,
required this.progress,
});
DriftUploadStatus copyWith({
String? taskId,
String? filename,
double? progress,
}) {
return DriftUploadStatus(
taskId: taskId ?? this.taskId,
filename: filename ?? this.filename,
progress: progress ?? this.progress,
);
}
@override
String toString() =>
'ExpUploadStatus(taskId: $taskId, filename: $filename, progress: $progress)';
@override
bool operator ==(covariant DriftUploadStatus other) {
if (identical(this, other)) return true;
return other.taskId == taskId &&
other.filename == filename &&
other.progress == progress;
}
@override
int get hashCode => taskId.hashCode ^ filename.hashCode ^ progress.hashCode;
}
class DriftBackupState {
final int totalCount;
final int backupCount;
final int remainderCount;
final Map<String, DriftUploadStatus> uploadItems;
const DriftBackupState({
required this.totalCount,
required this.backupCount,
required this.remainderCount,
required this.uploadItems,
});
DriftBackupState copyWith({
int? totalCount,
int? backupCount,
int? remainderCount,
Map<String, DriftUploadStatus>? uploadItems,
}) {
return DriftBackupState(
totalCount: totalCount ?? this.totalCount,
backupCount: backupCount ?? this.backupCount,
remainderCount: remainderCount ?? this.remainderCount,
uploadItems: uploadItems ?? this.uploadItems,
);
}
@override
String toString() {
return 'ExpBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, uploadItems: $uploadItems)';
}
@override
bool operator ==(covariant DriftBackupState other) {
if (identical(this, other)) return true;
final mapEquals = const DeepCollectionEquality().equals;
return other.totalCount == totalCount &&
other.backupCount == backupCount &&
other.remainderCount == remainderCount &&
mapEquals(other.uploadItems, uploadItems);
}
@override
int get hashCode {
return totalCount.hashCode ^
backupCount.hashCode ^
remainderCount.hashCode ^
uploadItems.hashCode;
}
}
final driftBackupProvider =
StateNotifierProvider<ExpBackupNotifier, DriftBackupState>((ref) {
return ExpBackupNotifier(
ref.watch(driftBackupServiceProvider),
ref.watch(uploadServiceProvider),
);
});
class ExpBackupNotifier extends StateNotifier<DriftBackupState> {
ExpBackupNotifier(
this._backupService,
this._uploadService,
) : super(
const DriftBackupState(
totalCount: 0,
backupCount: 0,
remainderCount: 0,
uploadItems: {},
),
) {
{
_uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
_uploadService.taskProgressStream.listen(_handleTaskProgressUpdate);
}
}
final DriftBackupService _backupService;
final UploadService _uploadService;
StreamSubscription<TaskStatusUpdate>? _statusSubscription;
StreamSubscription<TaskProgressUpdate>? _progressSubscription;
void _handleTaskStatusUpdate(TaskStatusUpdate update) {
switch (update.status) {
case TaskStatus.complete:
state = state.copyWith(
backupCount: state.backupCount + 1,
remainderCount: state.remainderCount - 1,
);
break;
default:
break;
}
}
void _handleTaskProgressUpdate(TaskProgressUpdate update) {}
Future<void> getBackupStatus() async {
final [totalCount, backupCount, remainderCount] = await Future.wait([
_backupService.getTotalCount(),
_backupService.getBackupCount(),
_backupService.getRemainderCount(),
]);
state = state.copyWith(
totalCount: totalCount,
backupCount: backupCount,
remainderCount: remainderCount,
);
}
Future<void> backup() {
return _backupService.backup();
}
Future<void> cancel() async {
await _backupService.cancel();
await getDataInfo();
}
Future<void> getDataInfo() async {
final a = await FileDownloader().database.allRecordsWithStatus(
TaskStatus.enqueued,
group: kBackupGroup,
);
final b = await FileDownloader().allTasks(
group: kBackupGroup,
);
debugPrint(
"Enqueued tasks: ${a.length}, All tasks: ${b.length}",
);
}
@override
void dispose() {
_statusSubscription?.cancel();
_progressSubscription?.cancel();
super.dispose();
}
}

View File

@ -176,16 +176,14 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
);
});
socket.on('on_upload_success', _handleOnUploadSuccess);
if (!Store.isBetaTimelineEnabled) {
startListeningToOldEvents();
} else {
startListeningToBetaEvents();
}
socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_asset_delete', _handleOnAssetDelete);
socket.on('on_asset_trash', _handleOnAssetTrash);
socket.on('on_asset_restore', _handleServerUpdates);
socket.on('on_asset_update', _handleServerUpdates);
socket.on('on_asset_stack_update', _handleServerUpdates);
socket.on('on_asset_hidden', _handleOnAssetHidden);
socket.on('on_new_release', _handleReleaseUpdates);
socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
} catch (e) {
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
}
@ -213,6 +211,34 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
state.socket?.off(eventName);
}
void stopListenToOldEvents() {
state.socket?.off('on_upload_success');
state.socket?.off('on_asset_delete');
state.socket?.off('on_asset_trash');
state.socket?.off('on_asset_restore');
state.socket?.off('on_asset_update');
state.socket?.off('on_asset_stack_update');
state.socket?.off('on_asset_hidden');
}
void startListeningToOldEvents() {
state.socket?.on('on_upload_success', _handleOnUploadSuccess);
state.socket?.on('on_asset_delete', _handleOnAssetDelete);
state.socket?.on('on_asset_trash', _handleOnAssetTrash);
state.socket?.on('on_asset_restore', _handleServerUpdates);
state.socket?.on('on_asset_update', _handleServerUpdates);
state.socket?.on('on_asset_stack_update', _handleServerUpdates);
state.socket?.on('on_asset_hidden', _handleOnAssetHidden);
}
void stopListeningToBetaEvents() {
state.socket?.off('AssetUploadReadyV1');
}
void startListeningToBetaEvents() {
state.socket?.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
}
void listenUploadEvent() {
debugPrint("Start listening to event on_upload_success");
state.socket?.on('on_upload_success', _handleOnUploadSuccess);

View File

@ -1,6 +1,6 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/utils/upload.dart';
import 'package:immich_mobile/constants/constants.dart';
final uploadRepositoryProvider = Provider((ref) => UploadRepository());
@ -11,25 +11,30 @@ class UploadRepository {
UploadRepository() {
FileDownloader().registerCallbacks(
group: uploadGroup,
group: kBackupGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kBackupLivePhotoGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
}
Future<bool> upload(UploadTask task) {
return FileDownloader().enqueue(task);
void enqueueAll(List<UploadTask> tasks) {
FileDownloader().enqueueAll(tasks);
}
Future<void> deleteAllTrackingRecords() {
return FileDownloader().database.deleteAllRecords();
Future<void> deleteAllTrackingRecords(String group) {
return FileDownloader().database.deleteAllRecords(group: group);
}
Future<bool> cancel(String id) {
return FileDownloader().cancelTaskWithId(id);
Future<bool> cancelAll(String group) {
return FileDownloader().cancelAll(group: group);
}
Future<void> deleteRecordsWithIds(List<String> ids) {
return FileDownloader().database.deleteRecordsWithIds(ids);
Future<int> reset(String group) {
return FileDownloader().reset(group: group);
}
}

View File

@ -22,6 +22,8 @@ import 'package:immich_mobile/pages/album/album_shared_user_selection.page.dart'
import 'package:immich_mobile/pages/album/album_viewer.page.dart';
import 'package:immich_mobile/pages/albums/albums.page.dart';
import 'package:immich_mobile/pages/backup/album_preview.page.dart';
import 'package:immich_mobile/pages/backup/drift_backup_album_selection.page.dart';
import 'package:immich_mobile/pages/backup/drift_backup.page.dart';
import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart';
import 'package:immich_mobile/pages/backup/backup_controller.page.dart';
import 'package:immich_mobile/pages/backup/backup_options.page.dart';
@ -385,6 +387,14 @@ class AppRouter extends RootStackRouter {
page: RemoteMediaSummaryRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
page: DriftBackupRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
page: DriftBackupAlbumSelectionRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
page: LocalTimelineRoute.page,
guards: [_authGuard, _duplicateGuard],

View File

@ -726,6 +726,38 @@ class DriftAssetSelectionTimelineRouteArgs {
}
}
/// generated route for
/// [DriftBackupAlbumSelectionPage]
class DriftBackupAlbumSelectionRoute extends PageRouteInfo<void> {
const DriftBackupAlbumSelectionRoute({List<PageRouteInfo>? children})
: super(DriftBackupAlbumSelectionRoute.name, initialChildren: children);
static const String name = 'DriftBackupAlbumSelectionRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DriftBackupAlbumSelectionPage();
},
);
}
/// generated route for
/// [DriftBackupPage]
class DriftBackupRoute extends PageRouteInfo<void> {
const DriftBackupRoute({List<PageRouteInfo>? children})
: super(DriftBackupRoute.name, initialChildren: children);
static const String name = 'DriftBackupRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DriftBackupPage();
},
);
}
/// generated route for
/// [DriftCreateAlbumPage]
class DriftCreateAlbumRoute extends PageRouteInfo<void> {

View File

@ -0,0 +1,286 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:riverpod_annotation/riverpod_annotation.dart';
final driftBackupServiceProvider = Provider<DriftBackupService>(
(ref) => DriftBackupService(
ref.watch(backupRepositoryProvider),
ref.watch(storageRepositoryProvider),
ref.watch(uploadServiceProvider),
ref.watch(localAssetRepository),
),
);
class DriftBackupService {
DriftBackupService(
this._backupRepository,
this._storageRepository,
this._uploadService,
this._localAssetRepository,
) {
_uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
}
final DriftBackupRepository _backupRepository;
final StorageRepository _storageRepository;
final DriftLocalAssetRepository _localAssetRepository;
final UploadService _uploadService;
final _log = Logger("DriftBackupService");
bool shouldCancel = false;
Future<int> getTotalCount() {
return _backupRepository.getTotalCount();
}
Future<int> getRemainderCount() {
return _backupRepository.getRemainderCount();
}
Future<int> getBackupCount() {
return _backupRepository.getBackupCount();
}
Future<void> backup() async {
shouldCancel = false;
final candidates = await _backupRepository.getCandidates();
if (candidates.isEmpty) {
return;
}
const batchSize = 100;
int count = 0;
for (int i = 0; i < candidates.length; i += batchSize) {
if (shouldCancel) {
break;
}
final batch = candidates.skip(i).take(batchSize).toList();
List<UploadTask> tasks = [];
for (final asset in batch) {
final task = await _getUploadTask(asset);
if (task != null) {
tasks.add(task);
}
}
if (tasks.isNotEmpty && !shouldCancel) {
count += tasks.length;
_uploadService.enqueueTasks(tasks);
debugPrint(
"Enqueued $count/${candidates.length} tasks for backup",
);
}
}
}
void _handleTaskStatusUpdate(TaskStatusUpdate update) {
switch (update.status) {
case TaskStatus.complete:
_handleLivePhoto(update);
break;
default:
break;
}
}
Future<void> _handleLivePhoto(TaskStatusUpdate update) async {
try {
if (update.task.metaData.isEmpty || update.task.metaData == '') {
return;
}
final metadata = UploadTaskMetadata.fromJson(update.task.metaData);
if (!metadata.isLivePhotos) {
return;
}
if (update.responseBody == null || update.responseBody!.isEmpty) {
return;
}
final response = jsonDecode(update.responseBody!);
final localAsset =
await _localAssetRepository.getById(metadata.localAssetId);
if (localAsset == null) {
return;
}
final uploadTask = await _getLivePhotoUploadTask(
localAsset,
response['id'] as String,
);
if (uploadTask == null) {
return;
}
_uploadService.enqueueTasks([uploadTask]);
} catch (error, stackTrace) {
_log.severe("Error handling live photo upload task", error, stackTrace);
debugPrint("Error handling live photo upload task: $error $stackTrace");
}
}
Future<UploadTask?> _getUploadTask(LocalAsset asset) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) {
return null;
}
File? file;
/// iOS LivePhoto has two files: a photo and a video.
/// They are uploaded separately, with video file being upload first, then returned with the assetId
/// The assetId is then used as a metadata for the photo file upload task.
///
/// We implement two separate upload groups for this, the normal one for the video file
/// and the higher priority group for the photo file because the video file is already uploaded.
///
/// The cancel operation will only cancel the video group (normal group), the photo group will not
/// be touched, as the video file is already uploaded.
if (entity.isLivePhoto) {
file = await _storageRepository.getMotionFileForAsset(asset);
} else {
file = await _storageRepository.getFileForAsset(asset.id);
}
if (file == null) {
return null;
}
final originalFileName = entity.isLivePhoto
? p.setExtension(
asset.name,
p.extension(file.path),
)
: asset.name;
String metadata = UploadTaskMetadata(
localAssetId: asset.id,
isLivePhotos: entity.isLivePhoto,
livePhotoVideoId: '',
).toJson();
return _uploadService.buildUploadTask(
file,
originalFileName: originalFileName,
deviceAssetId: asset.id,
metadata: metadata,
group: kBackupGroup,
);
}
Future<UploadTask?> _getLivePhotoUploadTask(
LocalAsset asset,
String livePhotoVideoId,
) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) {
return null;
}
final file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) {
return null;
}
final fields = {
'livePhotoVideoId': livePhotoVideoId,
};
return _uploadService.buildUploadTask(
file,
originalFileName: asset.name,
deviceAssetId: asset.id,
fields: fields,
group: kBackupLivePhotoGroup,
priority: 0,
);
}
Future<void> cancel() async {
shouldCancel = true;
await _uploadService.cancelAllForGroup(kBackupGroup);
}
}
class UploadTaskMetadata {
final String localAssetId;
final bool isLivePhotos;
final String livePhotoVideoId;
const UploadTaskMetadata({
required this.localAssetId,
required this.isLivePhotos,
required this.livePhotoVideoId,
});
UploadTaskMetadata copyWith({
String? localAssetId,
bool? isLivePhotos,
String? livePhotoVideoId,
}) {
return UploadTaskMetadata(
localAssetId: localAssetId ?? this.localAssetId,
isLivePhotos: isLivePhotos ?? this.isLivePhotos,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'localAssetId': localAssetId,
'isLivePhotos': isLivePhotos,
'livePhotoVideoId': livePhotoVideoId,
};
}
factory UploadTaskMetadata.fromMap(Map<String, dynamic> map) {
return UploadTaskMetadata(
localAssetId: map['localAssetId'] as String,
isLivePhotos: map['isLivePhotos'] as bool,
livePhotoVideoId: map['livePhotoVideoId'] as String,
);
}
String toJson() => json.encode(toMap());
factory UploadTaskMetadata.fromJson(String source) =>
UploadTaskMetadata.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() =>
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)';
@override
bool operator ==(covariant UploadTaskMetadata other) {
if (identical(this, other)) return true;
return other.localAssetId == localAssetId &&
other.isLivePhotos == isLivePhotos &&
other.livePhotoVideoId == livePhotoVideoId;
}
@override
int get hashCode =>
localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode;
}

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
@ -6,22 +7,28 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/upload.dart';
import 'package:path/path.dart';
// import 'package:logging/logging.dart';
final uploadServiceProvider = Provider(
(ref) => UploadService(
ref.watch(uploadRepositoryProvider),
),
);
final uploadServiceProvider = Provider((ref) {
final service = UploadService(ref.watch(uploadRepositoryProvider));
ref.onDispose(service.dispose);
return service;
});
class UploadService {
final UploadRepository _uploadRepository;
// final Logger _log = Logger("UploadService");
void Function(TaskStatusUpdate)? onUploadStatus;
void Function(TaskProgressUpdate)? onTaskProgress;
final StreamController<TaskStatusUpdate> _taskStatusController =
StreamController<TaskStatusUpdate>.broadcast();
final StreamController<TaskProgressUpdate> _taskProgressController =
StreamController<TaskProgressUpdate>.broadcast();
Stream<TaskStatusUpdate> get taskStatusStream => _taskStatusController.stream;
Stream<TaskProgressUpdate> get taskProgressStream =>
_taskProgressController.stream;
UploadService(
this._uploadRepository,
) {
@ -31,29 +38,65 @@ class UploadService {
void _onTaskProgressCallback(TaskProgressUpdate update) {
onTaskProgress?.call(update);
if (!_taskProgressController.isClosed) {
_taskProgressController.add(update);
}
}
void _onUploadCallback(TaskStatusUpdate update) {
onUploadStatus?.call(update);
if (!_taskStatusController.isClosed) {
_taskStatusController.add(update);
}
}
void dispose() {
_taskStatusController.close();
_taskProgressController.close();
}
Future<bool> cancelUpload(String id) {
return FileDownloader().cancelTaskWithId(id);
}
Future<void> upload(File file) async {
final task = await _buildUploadTask(
hash(file.path).toString(),
file,
);
await _uploadRepository.upload(task);
Future<void> cancelAllForGroup(String group) async {
await _uploadRepository.cancelAll(group);
await _uploadRepository.reset(group);
await _uploadRepository.deleteAllTrackingRecords(group);
}
Future<UploadTask> _buildUploadTask(
void enqueueTasks(List<UploadTask> tasks) {
_uploadRepository.enqueueAll(tasks);
}
Future<UploadTask> buildUploadTask(
File file, {
required String group,
Map<String, String>? fields,
String? originalFileName,
String? deviceAssetId,
String? metadata,
int? priority,
}) async {
return _buildTask(
deviceAssetId ?? hash(file.path).toString(),
file,
fields: fields,
originalFileName: originalFileName,
metadata: metadata,
group: group,
priority: priority,
);
}
Future<UploadTask> _buildTask(
String id,
File file, {
required String group,
Map<String, String>? fields,
String? originalFileName,
String? metadata,
int? priority,
}) async {
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final url = Uri.parse('$serverEndpoint/assets').toString();
@ -65,9 +108,8 @@ class UploadService {
final stats = await file.stat();
final fileCreatedAt = stats.changed;
final fileModifiedAt = stats.modified;
final fieldsMap = {
'filename': filename,
'filename': originalFileName ?? filename,
'deviceAssetId': id,
'deviceId': deviceId,
'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
@ -79,6 +121,7 @@ class UploadService {
return UploadTask(
taskId: id,
displayName: originalFileName ?? filename,
httpRequestMethod: 'POST',
url: url,
headers: headers,
@ -87,7 +130,9 @@ class UploadService {
baseDirectory: baseDirectory,
directory: directory,
fileField: 'assetData',
group: uploadGroup,
metaData: metadata ?? '',
group: group,
priority: priority ?? 5,
updates: Updates.statusAndProgress,
);
}

View File

@ -0,0 +1,31 @@
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
extension LocalAlbumEntityDataHelper on LocalAlbumEntityData {
LocalAlbum toDto({int assetCount = 0}) {
return LocalAlbum(
id: id,
name: name,
updatedAt: updatedAt,
assetCount: assetCount,
backupSelection: backupSelection,
);
}
}
extension LocalAssetEntityDataHelper on LocalAssetEntityData {
LocalAsset toDto() {
return LocalAsset(
id: id,
name: name,
checksum: checksum,
type: type,
createdAt: createdAt,
updatedAt: updatedAt,
durationInSeconds: durationInSeconds,
isFavorite: isFavorite,
);
}
}

View File

@ -1 +0,0 @@
const uploadGroup = 'upload_group';

View File

@ -0,0 +1,121 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class DriftAlbumInfoListTile extends HookConsumerWidget {
final LocalAlbum album;
const DriftAlbumInfoListTile({super.key, required this.album});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bool isSelected = album.backupSelection == BackupSelection.selected;
final bool isExcluded = album.backupSelection == BackupSelection.excluded;
final syncAlbum = ref
.watch(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.syncAlbums);
buildTileColor() {
if (isSelected) {
return context.isDarkTheme
? context.primaryColor.withAlpha(100)
: context.primaryColor.withAlpha(25);
} else if (isExcluded) {
return context.isDarkTheme
? Colors.red[300]?.withAlpha(150)
: Colors.red[100]?.withAlpha(150);
} else {
return Colors.transparent;
}
}
buildIcon() {
if (isSelected) {
return Icon(
Icons.check_circle_rounded,
color: context.colorScheme.primary,
);
}
if (isExcluded) {
return Icon(
Icons.remove_circle_rounded,
color: context.colorScheme.error,
);
}
return Icon(
Icons.circle,
color: context.colorScheme.surfaceContainerHighest,
);
}
return GestureDetector(
onDoubleTap: () {
ref.watch(hapticFeedbackProvider.notifier).selectionClick();
if (isExcluded) {
ref.read(backupAlbumProvider.notifier).deselectAlbum(album);
} else {
if (album.id == 'isAll' || album.name == 'Recents') {
ImmichToast.show(
context: context,
msg: 'Cannot exclude album contains all assets',
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
return;
}
ref.read(backupAlbumProvider.notifier).excludeAlbum(album);
}
},
child: ListTile(
tileColor: buildTileColor(),
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
onTap: () {
ref.read(hapticFeedbackProvider.notifier).selectionClick();
if (isSelected) {
ref.read(backupAlbumProvider.notifier).deselectAlbum(album);
} else {
ref.read(backupAlbumProvider.notifier).selectAlbum(album);
if (syncAlbum) {
ref.read(albumProvider.notifier).createSyncAlbum(album.name);
}
}
},
leading: buildIcon(),
title: Text(
album.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
subtitle: Text(album.assetCount.toString()),
trailing: IconButton(
onPressed: () {
context.pushRoute(LocalTimelineRoute(album: album));
},
icon: Icon(
Icons.image_outlined,
color: context.primaryColor,
size: 24,
),
splashRadius: 25,
),
),
);
}
}

View File

@ -205,7 +205,7 @@ class _BackupIndicator extends ConsumerWidget {
final badgeBackground = context.colorScheme.surfaceContainer;
return InkWell(
onTap: () => context.pushRoute(const BackupControllerRoute()),
onTap: () => context.pushRoute(const DriftBackupRoute()),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Badge(
label: Container(