Compare commits

...

14 Commits

Author SHA1 Message Date
Alex
a384798779 Up version for release 2022-11-30 11:18:06 -06:00
Alex
d31eddf32f chore(mobile) Improve mobile UI (#1038) 2022-11-30 10:58:07 -06:00
Fynn Petersen-Frey
1068c4ad23 feat(server,web): activate ETags for all API endpoints and asset serving (#1031)
This greatly reduces the network traffic by app/web.
2022-11-29 15:45:47 -06:00
Alex
cbc979263e chore(mobile): Improve readability of logs page (#1033) 2022-11-28 14:14:22 -06:00
Fynn Petersen-Frey
765181bbc0 chore(mobile): improve CSV log export (#1032) 2022-11-28 10:17:27 -06:00
Fynn Petersen-Frey
d82dec9773 fix(mobile): fix cache invalidation on logout (#1030)
await all the cache-invalidation operations during logout and catch errors to actually perform all operations.
2022-11-28 10:01:09 -06:00
Alex
024177515d feat(mobile) Add in app logging to show app's log information (#1014) 2022-11-27 14:34:19 -06:00
Alex Tran
fb3b36a569 Added test for user.service 2022-11-26 15:09:06 -06:00
Alex
614743c8f4 fix(server): Prevent delete admin user (#1023) 2022-11-26 15:02:23 -06:00
Fynn Petersen-Frey
47f5e4134e feat(mobile): use cached asset info if unchanged instead of downloading all assets (#1017)
* feat(mobile): use cached asset info if unchanged instead of downloading all assets

This adds an HTTP ETag to the getAllAssets endpoint and client-side support in the app.
If locally cache content is identical to the content on the server, the potentially large list of all assets does not need to be downloaded.

* use ts import instead of require
2022-11-26 10:16:02 -06:00
denck007
efa7b3ba54 Fix(web): navbar color overlap and scroll bar incorrect z index (#1018)
* fix(web): Navbar color overlaps tall images

* fix(web): Scroll bar date behind navbar when scrubbing (fixes issue #757)
2022-11-25 20:52:01 -06:00
Alex
1e9d67ec39 Up mobile version for hotfix release 2022-11-24 15:50:18 -06:00
Alex
80d0ddca9a fix(mobile): Fix not able to show device asset on Android 13 (#1016) 2022-11-24 15:47:55 -06:00
Kiel Hurley
976d347623 feat(server,web,mobile): Use binary prefixes for data sizes (#1009) 2022-11-24 11:39:27 -06:00
64 changed files with 1273 additions and 527 deletions

View File

@@ -52,7 +52,7 @@ android {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "app.alextran.immich"
minSdkVersion 23
targetSdkVersion flutter.targetSdkVersion
targetSdkVersion 33
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}

View File

@@ -38,6 +38,9 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <!-- If you want to read images-->
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <!-- If you want to read videos-->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> <!-- If you want to read audio-->
<queries>
<intent>

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 56,
"android.injected.version.name" => "1.36.1",
"android.injected.version.code" => 58,
"android.injected.version.name" => "1.37.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -0,0 +1,2 @@
* Show human readable file size in detail view
* Fix permission issue on Android 33

View File

@@ -0,0 +1,6 @@
* Use binary prefixes for data sizes
* Fix not able to show device asset on Android 13
* Use cached asset info if unchanged instead of downloading all assets
* Add in-app logging
* Add search mechanism to album selection page
* Improve UI

View File

@@ -17,7 +17,7 @@
"backup_album_selection_page_albums_device": "Albums on device ({})",
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
"backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.",
"backup_album_selection_page_select_albums": "Select Albums",
"backup_album_selection_page_select_albums": "Select albums",
"backup_album_selection_page_selection_info": "Selection Info",
"backup_album_selection_page_total_assets": "Total unique assets",
"backup_all": "All",
@@ -120,6 +120,7 @@
"profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
"profile_drawer_settings": "Settings",
"profile_drawer_sign_out": "Sign Out",
"profile_drawer_app_logs": "Logs",
"search_bar_hint": "Search your photos",
"search_page_no_objects": "No Objects Info Available",
"search_page_no_places": "No Places Info Available",

Binary file not shown.

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.36.1"
version_number: "1.37.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -4,6 +4,7 @@ const String accessTokenKey = "immichBoxAccessTokenKey"; // Key 1
const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3
const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4
const String assetEtagKey = 'immichAssetEtagKey'; // Key 5
// Login Info
const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box
@@ -29,3 +30,6 @@ const String backupRequireCharging = "immichBackupRequireCharging"; // Key 3
// Duplicate asset
const String duplicatedAssetsBox = "immichDuplicatedAssetsBox"; // Box
const String duplicatedAssetsKey = "immichDuplicatedAssetsKey"; // Key 1
// In app logger
const String immichLoggerBox = "immichInAppLogger"; // Box

View File

@@ -16,11 +16,13 @@ import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.d
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/release_info.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/services/immich_logger.service.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
@@ -31,8 +33,10 @@ void main() async {
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
Hive.registerAdapter(HiveBackupAlbumsAdapter());
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
Hive.registerAdapter(ImmichLoggerMessageAdapter());
await Future.wait([
Hive.openBox<ImmichLoggerMessage>(immichLoggerBox),
Hive.openBox(userInfoBox),
Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox),
Hive.openBox(hiveGithubReleaseInfoBox),
@@ -58,6 +62,9 @@ void main() async {
}
}
// Initialize Immich Logger Service
ImmichLogger().init();
runApp(
EasyLocalization(
supportedLocales: locales,

View File

@@ -21,8 +21,39 @@ class AlbumThumbnailCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
var box = Hive.box(userInfoBox);
var cardSize = MediaQuery.of(context).size.width / 2 - 18;
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
final cardSize = MediaQuery.of(context).size.width / 2 - 18;
buildEmptyThumbnail() {
return Container(
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.grey[200],
),
child: SizedBox(
height: cardSize,
width: cardSize,
child: const Center(
child: Icon(Icons.no_photography),
),
),
);
}
buildAlbumThumbnail() {
return CachedNetworkImage(
memCacheHeight: max(400, cardSize.toInt() * 3),
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 200),
imageUrl: getAlbumThumbnailUrl(
album,
type: ThumbnailFormat.JPEG,
),
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
cacheKey: "${album.albumThumbnailAssetId}",
);
}
return GestureDetector(
onTap: () {
@@ -35,19 +66,9 @@ class AlbumThumbnailCard extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
memCacheHeight: max(400, cardSize.toInt() * 3),
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 200),
imageUrl:
getAlbumThumbnailUrl(album, type: ThumbnailFormat.JPEG),
httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
cacheKey: "${album.albumThumbnailAssetId}",
),
child: album.albumThumbnailAssetId == null
? buildEmptyThumbnail()
: buildAlbumThumbnail(),
),
Padding(
padding: const EdgeInsets.only(top: 8.0),

View File

@@ -6,6 +6,7 @@ import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:latlong2/latlong.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
class ExifBottomSheet extends ConsumerWidget {
final Asset assetDetail;
@@ -162,7 +163,7 @@ class ExifBottomSheet extends ConsumerWidget {
),
subtitle: exifInfo.exifImageHeight != null
? Text(
"${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth} ${exifInfo.fileSizeInByte!}B ",
"${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth} ${formatBytes(exifInfo.fileSizeInByte!)} ",
)
: null,
),
@@ -178,7 +179,7 @@ class ExifBottomSheet extends ConsumerWidget {
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
"ƒ/${exifInfo.fNumber} 1/${(1 / (exifInfo.exposureTime ?? 1)).toStringAsFixed(0)} ${exifInfo.focalLength}mm ISO${exifInfo.iso} ",
"ƒ/${exifInfo.fNumber} 1/${(1 / (exifInfo.exposureTime ?? 1)).toStringAsFixed(0)} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ",
),
),
],

View File

@@ -349,7 +349,6 @@ class BackgroundService {
Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
]);
ApiService apiService = ApiService();
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));

View File

@@ -1,7 +1,7 @@
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
@@ -18,6 +18,7 @@ import 'package:immich_mobile/modules/login/models/authentication_state.model.da
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
@@ -62,11 +63,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
getBackupInfo();
}
final log = Logger('BackupNotifier');
final BackupService _backupService;
final ServerInfoService _serverInfoService;
final AuthenticationState _authState;
final BackgroundService _backgroundService;
final Ref ref;
var isGettingBackupInfo = false;
///
/// UI INTERACTION
@@ -171,9 +174,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// Get all album on the device
/// Get all selected and excluded album from the user's persistent storage
/// If this is the first time performing backup - set the default selected album to be
/// the one that has all assets (Recent on Android, Recents on iOS)
/// the one that has all assets (`Recent` on Android, `Recents` on iOS)
///
Future<void> _getBackupAlbumsInfo() async {
Stopwatch stopwatch = Stopwatch()..start();
// Get all albums on the device
List<AvailableAlbum> availableAlbums = [];
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
@@ -181,6 +185,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
type: RequestType.common,
);
log.info('Found ${albums.length} local albums');
for (AssetPathEntity album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
@@ -218,13 +224,16 @@ class BackupNotifier extends StateNotifier<BackUpState> {
);
if (backupAlbumInfo == null) {
debugPrint("[ERROR] getting Hive backup album infomation");
log.severe(
"backupAlbumInfo == null",
"Failed to get Hive backup album information",
);
return;
}
// First time backup - set isAll album is the default one for backup.
if (backupAlbumInfo.selectedAlbumIds.isEmpty) {
debugPrint("First time backup setup recent album as default");
log.info("First time backup; setup 'Recent(s)' album as default");
// Get album that contains all assets
var list = await PhotoManager.getAssetPathList(
@@ -286,9 +295,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
);
} catch (e) {
debugPrint("[ERROR] Failed to generate album from id $e");
} catch (e, stackTrace) {
log.severe("Failed to generate album from id", e, stackTrace);
}
debugPrint("_getBackupAlbumsInfo takes ${stopwatch.elapsedMilliseconds}ms");
}
///
@@ -338,7 +349,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
);
if (allUniqueAssets.isEmpty) {
debugPrint("No Asset On Device");
log.info("Not found albums or assets on the device to backup");
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
allAssetsInDatabase: allAssetsInDatabase,
@@ -360,25 +371,29 @@ class BackupNotifier extends StateNotifier<BackUpState> {
return;
}
///
/// Get all necessary information for calculating the available albums,
/// which albums are selected or excluded
/// and then update the UI according to those information
///
Future<void> getBackupInfo() async {
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
state = state.copyWith(backgroundBackup: isEnabled);
if (state.backupProgress != BackUpProgressEnum.inBackground) {
await _getBackupAlbumsInfo();
await _updateServerInfo();
await _updateBackupAssetCount();
if (!isGettingBackupInfo) {
isGettingBackupInfo = true;
var isEnabled = await _backgroundService.isBackgroundBackupEnabled();
state = state.copyWith(backgroundBackup: isEnabled);
if (state.backupProgress != BackUpProgressEnum.inBackground) {
await _getBackupAlbumsInfo();
await _updateServerInfo();
await _updateBackupAssetCount();
}
isGettingBackupInfo = false;
}
}
///
/// Save user selection of selected albums and excluded albums to
/// Hive database
///
void _updatePersistentAlbumsSelection() {
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
Box<HiveBackupAlbums> backupAlbumInfoBox =
@@ -398,9 +413,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
);
}
///
/// Invoke backup process
///
Future<void> startBackupProcess() async {
assert(state.backupProgress == BackUpProgressEnum.idle);
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
@@ -412,7 +425,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
await PhotoManager.clearFileCache();
if (state.allUniqueAssets.isEmpty) {
debugPrint("No Asset On Device - Abort Backup Process");
log.info("No Asset On Device - Abort Backup Process");
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
return;
}
@@ -530,7 +543,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// User has been logged out return
if (accessKey == null || !_authState.isAuthenticated) {
debugPrint("[resumeBackup] not authenticated - abort");
log.info("[_resumeBackup] not authenticated - abort");
return;
}
@@ -539,17 +552,17 @@ class BackupNotifier extends StateNotifier<BackUpState> {
_authState.deviceInfo.isAutoBackup) {
// check if backup is alreayd in process - then return
if (state.backupProgress == BackUpProgressEnum.inProgress) {
debugPrint("[resumeBackup] Backup is already in progress - abort");
log.info("[_resumeBackup] Backup is already in progress - abort");
return;
}
if (state.backupProgress == BackUpProgressEnum.inBackground) {
debugPrint("[resumeBackup] Background backup is running - abort");
log.info("[_resumeBackup] Background backup is running - abort");
return;
}
// Run backup
debugPrint("[resumeBackup] Start back up");
log.info("[_resumeBackup] Start back up");
await startBackupProcess();
}
@@ -565,7 +578,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
final bool hasLock = await _backgroundService.acquireLock();
if (!hasLock) {
debugPrint("WARNING [resumeBackup] failed to acquireLock");
log.warning("WARNING [resumeBackup] failed to acquireLock");
return;
}
await Future.wait([
@@ -612,7 +625,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
AvailableAlbum a = albums.firstWhere((e) => e.id == ids[i]);
result.add(a.copyWith(lastBackup: times[i]));
} on StateError {
debugPrint("[_updateAlbumBackupTime] failed to find album in state");
log.severe(
"[_updateAlbumBackupTime] failed to find album in state",
"State Error",
StackTrace.current,
);
}
}
return result;
@@ -631,21 +648,29 @@ class BackupNotifier extends StateNotifier<BackUpState> {
await Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).close();
}
} catch (error) {
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
log.info("[_notifyBackgroundServiceCanRun] failed to close box");
}
try {
if (Hive.isBoxOpen(duplicatedAssetsBox)) {
await Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox).close();
}
} catch (error) {
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
} catch (error, stackTrace) {
log.severe(
"[_notifyBackgroundServiceCanRun] failed to close box",
error,
stackTrace,
);
}
try {
if (Hive.isBoxOpen(backgroundBackupInfoBox)) {
await Hive.box(backgroundBackupInfoBox).close();
}
} catch (error) {
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
} catch (error, stackTrace) {
log.severe(
"[_notifyBackgroundServiceCanRun] failed to close box",
error,
stackTrace,
);
}
_backgroundService.releaseLock();
}

View File

@@ -36,7 +36,7 @@ class AlbumPreviewPage extends HookConsumerWidget {
title: Column(
children: [
Text(
"${album.name} (${album.assetCountAsync})",
album.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
Padding(

View File

@@ -5,6 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/ui/album_info_card.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
@@ -14,10 +15,13 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
const BackupAlbumSelectionPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final availableAlbums = ref.watch(backupProvider).availableAlbums;
// final availableAlbums = ref.watch(backupProvider).availableAlbums;
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
final albums = useState<List<AvailableAlbum>>(
ref.watch(backupProvider).availableAlbums,
);
useEffect(
() {
@@ -28,7 +32,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
);
buildAlbumSelectionList() {
if (availableAlbums.isEmpty) {
if (albums.value.isEmpty) {
return const Center(
child: ImmichLoadingIndicator(),
);
@@ -38,17 +42,17 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
height: 265,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: availableAlbums.length,
itemCount: albums.value.length,
physics: const BouncingScrollPhysics(),
itemBuilder: ((context, index) {
var thumbnailData = availableAlbums[index].thumbnailData;
var thumbnailData = albums.value[index].thumbnailData;
return Padding(
padding: index == 0
? const EdgeInsets.only(left: 16.00)
: const EdgeInsets.all(0),
child: AlbumInfoCard(
imageData: thumbnailData,
albumInfo: availableAlbums[index],
albumInfo: albums.value[index],
),
);
}),
@@ -79,15 +83,13 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
child: Chip(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
borderRadius: BorderRadius.circular(10),
),
label: Text(
album.name,
style: TextStyle(
fontSize: 10,
color: Theme.of(context).brightness == Brightness.dark
? Colors.black
: Colors.white,
color: isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
),
@@ -119,7 +121,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
child: Chip(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
borderRadius: BorderRadius.circular(10),
),
label: Text(
album.name,
@@ -143,6 +145,46 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
}).toSet();
}
buildSearchBar() {
return Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
child: TextFormField(
onChanged: (searchValue) {
albums.value = ref
.watch(backupProvider)
.availableAlbums
.where(
(album) => album.name
.toLowerCase()
.contains(searchValue.toLowerCase()),
)
.toList();
},
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 8.0,
),
hintText: "Search",
hintStyle: TextStyle(
color: isDarkTheme ? Colors.white : Colors.grey,
fontSize: 14.0,
),
prefixIcon: const Icon(
Icons.search,
color: Colors.grey,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
filled: true,
fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200],
),
),
);
}
return Scaffold(
appBar: AppBar(
leading: IconButton(
@@ -188,7 +230,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
child: Card(
margin: const EdgeInsets.all(0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: isDarkTheme
? const Color.fromARGB(255, 0, 0, 0)
@@ -225,8 +267,11 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
ListTile(
title: Text(
"backup_album_selection_page_albums_device"
.tr(args: [availableAlbums.length.toString()]),
"backup_album_selection_page_albums_device".tr(
args: [
ref.watch(backupProvider).availableAlbums.length.toString()
],
),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
subtitle: Padding(
@@ -254,7 +299,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(10),
),
elevation: 5,
title: Text(
@@ -284,6 +329,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
),
),
buildSearchBar(),
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: buildAlbumSelectionList(),

View File

@@ -10,8 +10,10 @@ import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/openapi_extensions.dart';
import 'package:immich_mobile/utils/tuple.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
final assetServiceProvider = Provider(
(ref) => AssetService(
@@ -25,42 +27,31 @@ class AssetService {
final ApiService _apiService;
final BackupService _backupService;
final BackgroundService _backgroundService;
final log = Logger('AssetService');
AssetService(this._apiService, this._backupService, this._backgroundService);
/// Returns all local, remote assets in that order
Future<List<Asset>> getAllAsset({bool urgent = false}) async {
final List<Asset> assets = [];
/// Returns `null` if the server state did not change, else list of assets
Future<List<Asset>?> getRemoteAssets({required bool hasCache}) async {
try {
// not using `await` here to fetch local & remote assets concurrently
final Future<List<AssetResponseDto>?> remoteTask =
_apiService.assetApi.getAllAssets();
final Iterable<AssetEntity> newLocalAssets;
final List<AssetEntity> localAssets = await _getLocalAssets(urgent);
final List<AssetResponseDto> remoteAssets = await remoteTask ?? [];
if (remoteAssets.isNotEmpty && localAssets.isNotEmpty) {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
final Set<String> existingIds = remoteAssets
.where((e) => e.deviceId == deviceId)
.map((e) => e.deviceAssetId)
.toSet();
newLocalAssets = localAssets.where((e) => !existingIds.contains(e.id));
} else {
newLocalAssets = localAssets;
final Box box = Hive.box(userInfoBox);
final Pair<List<AssetResponseDto>, String?>? remote = await _apiService
.assetApi
.getAllAssetsWithETag(eTag: hasCache ? box.get(assetEtagKey) : null);
if (remote == null) {
return null;
}
assets.addAll(newLocalAssets.map((e) => Asset.local(e)));
// the order (first all local, then remote assets) is important!
assets.addAll(remoteAssets.map((e) => Asset.remote(e)));
} catch (e) {
debugPrint("Error [getAllAsset] ${e.toString()}");
box.put(assetEtagKey, remote.second);
return remote.first.map(Asset.remote).toList(growable: false);
} catch (e, stack) {
log.severe('Error while getting remote assets', e, stack);
return null;
}
return assets;
}
/// if [urgent] is `true`, do not block by waiting on the background service
/// to finish running. Returns an empty list instead after a timeout.
Future<List<AssetEntity>> _getLocalAssets(bool urgent) async {
/// to finish running. Returns `null` instead after a timeout.
Future<List<Asset>?> getLocalAssets({bool urgent = false}) async {
try {
final Future<bool> hasAccess = urgent
? _backgroundService.hasAccess
@@ -71,15 +62,16 @@ class AssetService {
}
final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
return backupAlbumInfo != null
? await _backupService
.buildUploadCandidates(backupAlbumInfo.deepCopy())
: [];
if (backupAlbumInfo != null) {
return (await _backupService
.buildUploadCandidates(backupAlbumInfo.deepCopy()))
.map(Asset.local)
.toList(growable: false);
}
} catch (e) {
debugPrint("Error [_getLocalAssets] ${e.toString()}");
return [];
}
return null;
}
Future<Asset?> getAssetById(String assetId) async {

View File

@@ -32,7 +32,9 @@ class ImmichSliverAppBar extends ConsumerWidget {
snap: false,
backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(5)),
borderRadius: BorderRadius.all(
Radius.circular(5),
),
),
leading: Builder(
builder: (BuildContext context) {

View File

@@ -2,12 +2,12 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer_header.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer/server_info_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
class ProfileDrawer extends HookConsumerWidget {
@@ -70,6 +70,30 @@ class ProfileDrawer extends HookConsumerWidget {
);
}
buildAppLogButton() {
return ListTile(
horizontalTitleGap: 0,
leading: SizedBox(
height: double.infinity,
child: Icon(
Icons.assignment_outlined,
color: Theme.of(context).textTheme.labelMedium?.color,
size: 20,
),
),
title: Text(
"profile_drawer_app_logs",
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
onTap: () {
AutoRouter.of(context).push(const AppLogRoute());
},
);
}
return Drawer(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -80,6 +104,7 @@ class ProfileDrawer extends HookConsumerWidget {
children: [
const ProfileDrawerHeader(),
buildSettingButton(),
buildAppLogButton(),
buildSignoutButton(),
],
),

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -20,6 +22,7 @@ import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/services/share.service.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:openapi/api.dart';
@@ -37,6 +40,8 @@ class HomePage extends HookConsumerWidget {
final albums = ref.watch(albumProvider);
final albumService = ref.watch(albumServiceProvider);
final tipOneOpacity = useState(0.0);
useEffect(
() {
ref.read(websocketProvider.notifier).connect();
@@ -146,6 +151,49 @@ class HomePage extends HookConsumerWidget {
}
}
buildLoadingIndicator() {
Timer(const Duration(seconds: 2), () {
tipOneOpacity.value = 1;
});
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const ImmichLoadingIndicator(),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
'Building the timeline',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
color: Theme.of(context).primaryColor,
),
),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: tipOneOpacity.value,
child: const SizedBox(
width: 250,
child: Padding(
padding: EdgeInsets.only(top: 8.0),
child: Text(
'If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).',
textAlign: TextAlign.justify,
style: TextStyle(
fontSize: 12,
),
),
),
),
)
],
),
);
}
return SafeArea(
bottom: !multiselectEnabled.state,
top: true,
@@ -164,15 +212,17 @@ class HomePage extends HookConsumerWidget {
top: selectionEnabledHook.value ? 0 : 60,
bottom: 0.0,
),
child: ImmichAssetGrid(
renderList: renderList,
assetsPerRow:
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService
.getSetting(AppSettingsEnum.storageIndicator),
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
),
child: ref.watch(assetProvider).isEmpty
? buildLoadingIndicator()
: ImmichAssetGrid(
renderList: renderList,
assetsPerRow: appSettingService
.getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService
.getSetting(AppSettingsEnum.storageIndicator),
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
),
),
if (selectionEnabledHook.value)
ControlBottomAppBar(

View File

@@ -101,11 +101,14 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
}
Future<bool> logout() async {
Hive.box(userInfoBox).delete(accessTokenKey);
state = state.copyWith(isAuthenticated: false);
_assetCacheService.invalidate();
_albumCacheService.invalidate();
_sharedAlbumCacheService.invalidate();
await Future.wait([
Hive.box(userInfoBox).delete(accessTokenKey),
Hive.box(userInfoBox).delete(assetEtagKey),
_assetCacheService.invalidate(),
_albumCacheService.invalidate(),
_sharedAlbumCacheService.invalidate(),
]);
// Remove login info from local storage
var loginInfo =
@@ -115,7 +118,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
loginInfo.password = "";
loginInfo.isSaveLogin = false;
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
await Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
savedLoginInfoKey,
loginInfo,
);

View File

@@ -83,6 +83,13 @@ class LoginForm extends HookConsumerWidget {
[],
);
populateTestLoginInfo() {
usernameController.text = 'testuser@email.com';
passwordController.text = 'password';
serverEndpointController.text = 'http://10.1.15.216:2283/api';
isSaveLoginInfo.value = true;
}
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
@@ -92,10 +99,13 @@ class LoginForm extends HookConsumerWidget {
runSpacing: 16,
alignment: WrapAlignment.center,
children: [
const Image(
image: AssetImage('assets/immich-logo-no-outline.png'),
width: 100,
filterQuality: FilterQuality.high,
GestureDetector(
onDoubleTap: () => populateTestLoginInfo(),
child: const Image(
image: AssetImage('assets/immich-logo-no-outline.png'),
width: 100,
filterQuality: FilterQuality.high,
),
),
Text(
'IMMICH',

View File

@@ -1,14 +1,65 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/ui/login_form.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:package_info_plus/package_info_plus.dart';
class LoginPage extends HookConsumerWidget {
const LoginPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return const Scaffold(
body: LoginForm(),
final appVersion = useState('0.0.0');
getAppInfo() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
appVersion.value = packageInfo.version;
}
useEffect(
() {
getAppInfo();
return null;
},
);
return Scaffold(
body: const LoginForm(),
bottomNavigationBar: Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: SizedBox(
height: 50,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'v${appVersion.value}',
style: const TextStyle(
color: Colors.grey,
fontWeight: FontWeight.bold,
fontFamily: "Inconsolata",
),
),
const Text(' '),
GestureDetector(
child: Text(
'Logs',
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
fontFamily: "Inconsolata",
),
),
onTap: () {
AutoRouter.of(context).push(const AppLogRoute());
},
),
],
),
),
),
);
}
}

View File

@@ -1,33 +1,34 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/views/library_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
import 'package:immich_mobile/modules/backup/views/failed_backup_status_page.dart';
import 'package:immich_mobile/modules/login/views/change_password_page.dart';
import 'package:immich_mobile/modules/login/views/login_page.dart';
import 'package:immich_mobile/modules/home/views/home_page.dart';
import 'package:immich_mobile/modules/search/views/search_page.dart';
import 'package:immich_mobile/modules/search/views/search_result_page.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
import 'package:immich_mobile/modules/album/views/create_album_page.dart';
import 'package:immich_mobile/modules/album/views/library_page.dart';
import 'package:immich_mobile/modules/album/views/select_additional_user_for_sharing_page.dart';
import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart';
import 'package:immich_mobile/modules/album/views/sharing_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
import 'package:immich_mobile/modules/backup/views/failed_backup_status_page.dart';
import 'package:immich_mobile/modules/home/views/home_page.dart';
import 'package:immich_mobile/modules/login/views/change_password_page.dart';
import 'package:immich_mobile/modules/login/views/login_page.dart';
import 'package:immich_mobile/modules/search/views/search_page.dart';
import 'package:immich_mobile/modules/search/views/search_result_page.dart';
import 'package:immich_mobile/modules/settings/views/settings_page.dart';
import 'package:immich_mobile/routing/auth_guard.dart';
import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/views/app_log_page.dart';
import 'package:immich_mobile/shared/views/splash_screen.dart';
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
@@ -80,6 +81,10 @@ part 'router.gr.dart';
transitionsBuilder: TransitionsBuilders.slideBottom,
),
AutoRoute(page: SettingsPage, guards: [AuthGuard]),
CustomRoute(
page: AppLogPage,
transitionsBuilder: TransitionsBuilders.slideBottom,
),
],
)
class AppRouter extends _$AppRouter {

View File

@@ -142,6 +142,14 @@ class _$AppRouter extends RootStackRouter {
return MaterialPageX<dynamic>(
routeData: routeData, child: const SettingsPage());
},
AppLogRoute.name: (routeData) {
return CustomPage<dynamic>(
routeData: routeData,
child: const AppLogPage(),
transitionsBuilder: TransitionsBuilders.slideBottom,
opaque: true,
barrierDismissible: false);
},
HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const HomePage());
@@ -218,7 +226,8 @@ class _$AppRouter extends RootStackRouter {
RouteConfig(FailedBackupStatusRoute.name,
path: '/failed-backup-status-page', guards: [authGuard]),
RouteConfig(SettingsRoute.name,
path: '/settings-page', guards: [authGuard])
path: '/settings-page', guards: [authGuard]),
RouteConfig(AppLogRoute.name, path: '/app-log-page')
];
}
@@ -560,6 +569,14 @@ class SettingsRoute extends PageRouteInfo<void> {
static const String name = 'SettingsRoute';
}
/// generated route for
/// [AppLogPage]
class AppLogRoute extends PageRouteInfo<void> {
const AppLogRoute() : super(AppLogRoute.name, path: '/app-log-page');
static const String name = 'AppLogRoute';
}
/// generated route for
/// [HomePage]
class HomeRoute extends PageRouteInfo<void> {

View File

@@ -0,0 +1,34 @@
import 'package:hive/hive.dart';
part 'immich_logger_message.model.g.dart';
@HiveType(typeId: 3)
class ImmichLoggerMessage {
@HiveField(0)
String message;
@HiveField(1, defaultValue: "INFO")
String level;
@HiveField(2)
DateTime createdAt;
@HiveField(3)
String? context1;
@HiveField(4)
String? context2;
ImmichLoggerMessage({
required this.message,
required this.level,
required this.createdAt,
required this.context1,
required this.context2,
});
@override
String toString() {
return 'InAppLoggerMessage(message: $message, level: $level, createdAt: $createdAt)';
}
}

View File

@@ -0,0 +1,53 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'immich_logger_message.model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ImmichLoggerMessageAdapter extends TypeAdapter<ImmichLoggerMessage> {
@override
final int typeId = 3;
@override
ImmichLoggerMessage read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ImmichLoggerMessage(
message: fields[0] as String,
level: fields[1] == null ? 'INFO' : fields[1] as String,
createdAt: fields[2] as DateTime,
context1: fields[3] as String?,
context2: fields[4] as String?,
);
}
@override
void write(BinaryWriter writer, ImmichLoggerMessage obj) {
writer
..writeByte(5)
..writeByte(0)
..write(obj.message)
..writeByte(1)
..write(obj.level)
..writeByte(2)
..write(obj.createdAt)
..writeByte(3)
..write(obj.context1)
..writeByte(4)
..write(obj.context2);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ImmichLoggerMessageAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -1,20 +1,22 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:collection/collection.dart';
import 'package:intl/intl.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
class AssetNotifier extends StateNotifier<List<Asset>> {
final AssetService _assetService;
final AssetCacheService _assetCacheService;
final log = Logger('AssetNotifier');
final DeviceInfoService _deviceInfoService = DeviceInfoService();
bool _getAllAssetInProgress = false;
bool _deleteInProgress = false;
@@ -33,32 +35,61 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
final stopwatch = Stopwatch();
try {
_getAllAssetInProgress = true;
final bool isCacheValid = await _assetCacheService.isValid();
stopwatch.start();
final localTask = _assetService.getLocalAssets(urgent: !isCacheValid);
final remoteTask = _assetService.getRemoteAssets(hasCache: isCacheValid);
if (isCacheValid && state.isEmpty) {
stopwatch.start();
state = await _assetCacheService.get();
debugPrint(
log.info(
"Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms",
);
stopwatch.reset();
}
stopwatch.start();
var allAssets = await _assetService.getAllAsset(urgent: !isCacheValid);
debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms");
int remoteBegin = state.indexWhere((a) => a.isRemote);
remoteBegin = remoteBegin == -1 ? state.length : remoteBegin;
final List<Asset> currentLocal = state.slice(0, remoteBegin);
List<Asset>? newRemote = await remoteTask;
List<Asset>? newLocal = await localTask;
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
state = allAssets;
if (newRemote == null &&
(newLocal == null || currentLocal.equals(newLocal))) {
log.info("state is already up-to-date");
return;
}
newRemote ??= state.slice(remoteBegin);
newLocal ??= [];
state = _combineLocalAndRemoteAssets(local: newLocal, remote: newRemote);
log.info("Combining assets: ${stopwatch.elapsedMilliseconds}ms");
} finally {
_getAllAssetInProgress = false;
}
debugPrint("[getAllAsset] setting new asset state");
log.info("setting new asset state");
stopwatch.start();
_cacheState();
debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
_cacheState();
log.info("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
}
List<Asset> _combineLocalAndRemoteAssets({
required Iterable<Asset> local,
required List<Asset> remote,
}) {
final List<Asset> assets = [];
if (remote.isNotEmpty && local.isNotEmpty) {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
final Set<String> existingIds = remote
.where((e) => e.deviceId == deviceId)
.map((e) => e.deviceAssetId)
.toSet();
local = local.where((e) => !existingIds.contains(e.id));
}
assets.addAll(local);
// the order (first all local, then remote assets) is important!
assets.addAll(remote);
return assets;
}
clearAllAsset() {
@@ -124,8 +155,8 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
if (local.isNotEmpty) {
try {
return await PhotoManager.editor.deleteWithIds(local);
} catch (e) {
debugPrint("Delete asset from device failed: $e");
} catch (e, stack) {
log.severe("Failed to delete asset from device", e, stack);
}
}
return [];

View File

@@ -6,10 +6,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
import 'package:logging/logging.dart';
class ReleaseInfoNotifier extends StateNotifier<String> {
ReleaseInfoNotifier() : super("");
final log = Logger('ReleaseInfoNotifier');
void checkGithubReleaseInfo() async {
final Client client = Client();
var box = Hive.box(hiveGithubReleaseInfoBox);
@@ -28,9 +29,6 @@ class ReleaseInfoNotifier extends StateNotifier<String> {
String latestTagVersion = data["tag_name"];
state = latestTagVersion;
debugPrint("Local release version $localReleaseVersion");
debugPrint("Remote release veresion $latestTagVersion");
if (localReleaseVersion == null && latestTagVersion.isNotEmpty) {
VersionAnnouncementOverlayController.appLoader.show();
return;

View File

@@ -6,23 +6,24 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:socket_io_client/socket_io_client.dart';
class WebscoketState {
class WebsocketState {
final Socket? socket;
final bool isConnected;
WebscoketState({
WebsocketState({
this.socket,
required this.isConnected,
});
WebscoketState copyWith({
WebsocketState copyWith({
Socket? socket,
bool? isConnected,
}) {
return WebscoketState(
return WebsocketState(
socket: socket ?? this.socket,
isConnected: isConnected ?? this.isConnected,
);
@@ -30,13 +31,13 @@ class WebscoketState {
@override
String toString() =>
'WebscoketState(socket: $socket, isConnected: $isConnected)';
'WebsocketState(socket: $socket, isConnected: $isConnected)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is WebscoketState &&
return other is WebsocketState &&
other.socket == socket &&
other.isConnected == isConnected;
}
@@ -45,12 +46,11 @@ class WebscoketState {
int get hashCode => socket.hashCode ^ isConnected.hashCode;
}
class WebsocketNotifier extends StateNotifier<WebscoketState> {
class WebsocketNotifier extends StateNotifier<WebsocketState> {
WebsocketNotifier(this.ref)
: super(WebscoketState(socket: null, isConnected: false)) {
debugPrint("Init websocket instance");
}
: super(WebsocketState(socket: null, isConnected: false));
final log = Logger('WebsocketNotifier');
final Ref ref;
connect() {
@@ -60,8 +60,8 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
var accessToken = Hive.box(userInfoBox).get(accessTokenKey);
var endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
try {
debugPrint("[WEBSOCKET] Attempting to connect to ws");
// Configure socket transports must be sepecified
debugPrint("Attempting to connect to websocket");
// Configure socket transports must be specified
Socket socket = io(
endpoint.toString().replaceAll('/api', ''),
OptionBuilder()
@@ -76,18 +76,18 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
);
socket.onConnect((_) {
debugPrint("[WEBSOCKET] Established Websocket Connection");
state = WebscoketState(isConnected: true, socket: socket);
debugPrint("Established Websocket Connection");
state = WebsocketState(isConnected: true, socket: socket);
});
socket.onDisconnect((_) {
debugPrint("[WEBSOCKET] Disconnect to Websocket Connection");
state = WebscoketState(isConnected: false, socket: null);
debugPrint("Disconnect to Websocket Connection");
state = WebsocketState(isConnected: false, socket: null);
});
socket.on('error', (errorMessage) {
debugPrint("Webcoket Error - $errorMessage");
state = WebscoketState(isConnected: false, socket: null);
log.severe("Websocket Error - $errorMessage");
state = WebsocketState(isConnected: false, socket: null);
});
socket.on('on_upload_success', (data) {
@@ -105,21 +105,22 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
}
disconnect() {
debugPrint("[WEBSOCKET] Attempting to disconnect");
debugPrint("Attempting to disconnect from websocket");
var socket = state.socket?.disconnect();
if (socket?.disconnected == true) {
state = WebscoketState(isConnected: false, socket: null);
state = WebsocketState(isConnected: false, socket: null);
}
}
stopListenToEvent(String eventName) {
debugPrint("[Websocket] Stop listening to event $eventName");
debugPrint("Stop listening to event $eventName");
state.socket?.off(eventName);
}
listenUploadEvent() {
debugPrint("[Websocket] Start listening to event on_upload_success");
debugPrint("Start listening to event on_upload_success");
state.socket?.on('on_upload_success', (data) {
var jsonString = jsonDecode(data.toString());
AssetResponseDto? newAsset = AssetResponseDto.fromJson(jsonString);
@@ -132,6 +133,6 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
}
final websocketProvider =
StateNotifierProvider<WebsocketNotifier, WebscoketState>((ref) {
StateNotifierProvider<WebsocketNotifier, WebsocketState>((ref) {
return WebsocketNotifier(ref);
});

View File

@@ -0,0 +1,95 @@
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
/// [ImmichLogger] is a custom logger that is built on top of the [logging] package.
/// The logs are written to a Hive box and onto console, using `debugPrint` method.
///
/// The logs are deleted when exceeding the `maxLogEntries` (default 200) property
/// in the class.
///
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
/// and generate a csv file.
class ImmichLogger {
final maxLogEntries = 200;
final Box<ImmichLoggerMessage> _box = Hive.box(immichLoggerBox);
List<ImmichLoggerMessage> get messages =>
_box.values.toList().reversed.toList();
ImmichLogger() {
_removeOverflowMessages();
}
init() {
Logger.root.level = Level.INFO;
Logger.root.onRecord.listen(_writeLogToHiveBox);
}
_removeOverflowMessages() {
if (_box.length > maxLogEntries) {
var numberOfEntryToBeDeleted = _box.length - maxLogEntries;
for (var i = 0; i < numberOfEntryToBeDeleted; i++) {
_box.deleteAt(0);
}
}
}
_writeLogToHiveBox(LogRecord record) {
final Box<ImmichLoggerMessage> box = Hive.box(immichLoggerBox);
var formattedMessage = record.message;
debugPrint('[${record.level.name}] [${record.time}] ${record.message}');
box.add(
ImmichLoggerMessage(
message: formattedMessage,
level: record.level.name,
createdAt: record.time,
context1: record.loggerName,
context2: record.stackTrace?.toString(),
),
);
}
void clearLogs() {
_box.clear();
}
Future<void> shareLogs() async {
final tempDir = await getTemporaryDirectory();
final dateTime = DateTime.now().toIso8601String();
final filePath = '${tempDir.path}/Immich_log_$dateTime.csv';
final logFile = await File(filePath).create();
final io = logFile.openWrite();
try {
// Write header
io.write("created_at,level,context,message,stacktrace\n");
// Write messages
for (final m in messages) {
io.write(
'${m.createdAt},${m.level},"${m.context1 ?? ""}","${m.message}","${m.context2 ?? ""}"\n',
);
}
} finally {
await io.flush();
await io.close();
}
// Share file
await Share.shareFiles(
[filePath],
subject: "Immich logs $dateTime",
sharePositionOrigin: Rect.zero,
);
// Clean up temp file
await logFile.delete();
}
}

View File

@@ -23,8 +23,12 @@ abstract class JsonCache<T> {
}
Future<void> invalidate() async {
final file = await _getCacheFile();
await file.delete();
try {
final file = await _getCacheFile();
await file.delete();
} on FileSystemException {
// file is already deleted
}
}
Future<void> putRawData(dynamic data) async {
@@ -46,4 +50,4 @@ abstract class JsonCache<T> {
void put(T data);
Future<T> get();
}
}

View File

@@ -15,7 +15,10 @@ class ImmichLoadingIndicator extends StatelessWidget {
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.all(15),
child: const CircularProgressIndicator(color: Colors.white),
child: const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
);
}
}

View File

@@ -0,0 +1,165 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/services/immich_logger.service.dart';
import 'package:intl/intl.dart';
class AppLogPage extends HookConsumerWidget {
const AppLogPage({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final immichLogger = ImmichLogger();
final logMessages = useState(immichLogger.messages);
Widget colorStatusIndicator(Color color) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
],
);
}
Widget buildLeadingIcon(String level) {
switch (level) {
case "INFO":
return colorStatusIndicator(Theme.of(context).primaryColor);
case "SEVERE":
return colorStatusIndicator(Colors.redAccent);
case "WARNING":
return colorStatusIndicator(Colors.orangeAccent);
default:
return colorStatusIndicator(Colors.grey);
}
}
getTileColor(String level) {
switch (level) {
case "INFO":
return Colors.transparent;
case "SEVERE":
return Theme.of(context).brightness == Brightness.dark
? Colors.redAccent.withOpacity(0.25)
: Colors.redAccent.withOpacity(0.075);
case "WARNING":
return Theme.of(context).brightness == Brightness.dark
? Colors.orangeAccent.withOpacity(0.25)
: Colors.orangeAccent.withOpacity(0.075);
default:
return Theme.of(context).primaryColor.withOpacity(0.1);
}
}
return Scaffold(
appBar: AppBar(
title: Text(
"Logs - ${logMessages.value.length}",
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16.0,
),
),
scrolledUnderElevation: 1,
elevation: 2,
actions: [
IconButton(
icon: Icon(
Icons.delete_outline_rounded,
color: Theme.of(context).primaryColor,
semanticLabel: "Clear logs",
size: 20.0,
),
onPressed: () {
immichLogger.clearLogs();
logMessages.value = [];
},
),
IconButton(
icon: Icon(
Icons.share_rounded,
color: Theme.of(context).primaryColor,
semanticLabel: "Share logs",
size: 20.0,
),
onPressed: () {
immichLogger.shareLogs();
},
),
],
leading: IconButton(
onPressed: () {
AutoRouter.of(context).pop();
},
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
size: 20.0,
),
),
centerTitle: true,
),
body: ListView.separated(
separatorBuilder: (context, index) {
return Divider(
height: 0,
color: Theme.of(context).brightness == Brightness.dark
? Colors.white70
: Colors.grey[600],
);
},
itemCount: logMessages.value.length,
itemBuilder: (context, index) {
var logMessage = logMessages.value[index];
return ListTile(
visualDensity: VisualDensity.compact,
dense: true,
tileColor: getTileColor(logMessage.level),
minLeadingWidth: 10,
title: Text.rich(
TextSpan(
children: [
TextSpan(
text: "#$index ",
style: TextStyle(
color: Theme.of(context).brightness == Brightness.dark
? Colors.white70
: Colors.grey[600],
fontSize: 14.0,
fontWeight: FontWeight.bold,
),
),
TextSpan(
text: logMessage.message,
style: const TextStyle(
fontSize: 14.0,
),
),
],
),
style: const TextStyle(fontSize: 14.0, fontFamily: "Inconsolata"),
),
subtitle: Text(
"[${logMessage.context1}] Logged on ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)}",
style: TextStyle(
fontSize: 12.0,
color: Colors.grey[600],
),
),
leading: buildLeadingIcon(logMessage.level),
);
},
),
);
}
}

View File

@@ -1,15 +1,17 @@
String formatBytes(int bytes) {
if (bytes < 1000) {
return "$bytes B";
} else if (bytes < 1000000) {
final kb = (bytes / 1000).toStringAsFixed(1);
return "$kb kB";
} else if (bytes < 1000000000) {
final mb = (bytes / 1000000).toStringAsFixed(1);
return "$mb MB";
} else {
final gb = (bytes / 1000000000).toStringAsFixed(1);
return "$gb GB";
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
int magnitude = 0;
double remainder = bytes.toDouble();
while (remainder >= 1024) {
if (magnitude + 1 < units.length) {
magnitude++;
remainder /= 1024;
}
else {
break;
}
}
}
return "${remainder.toStringAsFixed(magnitude == 0 ? 0 : 1)} ${units[magnitude]}";
}

View File

@@ -0,0 +1,53 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart';
import 'package:openapi/api.dart';
import 'tuple.dart';
/// Extension methods to retrieve ETag together with the API call
extension WithETag on AssetApi {
/// Get all AssetEntity belong to the user
///
/// Parameters:
///
/// * [String] eTag:
/// ETag of data already cached on the client
Future<Pair<List<AssetResponseDto>, String?>?> getAllAssetsWithETag({
String? eTag,
}) async {
final response = await getAllAssetsWithHttpInfo(
ifNoneMatch: eTag,
);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty &&
response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
final etag = response.headers[HttpHeaders.etagHeader];
final data = (await apiClient.deserializeAsync(
responseBody, 'List<AssetResponseDto>') as List)
.cast<AssetResponseDto>()
.toList();
return Pair(data, etag);
}
return null;
}
}
/// Returns the decoded body as UTF-8 if the given headers indicate an 'application/json'
/// content type. Otherwise, returns the decoded body as decoded by dart:http package.
Future<String> _decodeBodyBytes(Response response) async {
final contentType = response.headers['content-type'];
return contentType != null &&
contentType.toLowerCase().startsWith('application/json')
? response.bodyBytes.isEmpty
? ''
: utf8.decode(response.bodyBytes)
: response.body;
}

View File

@@ -0,0 +1,8 @@
/// An immutable pair or 2-tuple
/// TODO replace with Record once Dart 2.19 is available
class Pair<T1, T2> {
final T1 first;
final T2 second;
const Pair(this.first, this.second);
}

View File

@@ -274,7 +274,7 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getAllAssets**
> List<AssetResponseDto> getAllAssets()
> List<AssetResponseDto> getAllAssets(ifNoneMatch)
@@ -291,9 +291,10 @@ import 'package:openapi/api.dart';
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = AssetApi();
final ifNoneMatch = ifNoneMatch_example; // String | ETag of data already cached on the client
try {
final result = api_instance.getAllAssets();
final result = api_instance.getAllAssets(ifNoneMatch);
print(result);
} catch (e) {
print('Exception when calling AssetApi->getAllAssets: $e\n');
@@ -301,7 +302,10 @@ try {
```
### Parameters
This endpoint does not need any parameter.
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**ifNoneMatch** | **String**| ETag of data already cached on the client | [optional]
### Return type

View File

@@ -21,10 +21,10 @@ Name | Type | Description | Notes
**mimeType** | **String** | |
**duration** | **String** | |
**webpPath** | **String** | |
**encodedVideoPath** | **String** | |
**encodedVideoPath** | **String** | | [optional]
**exifInfo** | [**ExifResponseDto**](ExifResponseDto.md) | | [optional]
**smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional]
**livePhotoVideoId** | **String** | |
**livePhotoVideoId** | **String** | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -16,7 +16,7 @@ Name | Type | Description | Notes
**profileImagePath** | **String** | |
**shouldChangePassword** | **bool** | |
**isAdmin** | **bool** | |
**deletedAt** | [**DateTime**](DateTime.md) | |
**deletedAt** | [**DateTime**](DateTime.md) | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -297,7 +297,12 @@ class AssetApi {
/// Get all AssetEntity belong to the user
///
/// Note: This method returns the HTTP [Response].
Future<Response> getAllAssetsWithHttpInfo() async {
///
/// Parameters:
///
/// * [String] ifNoneMatch:
/// ETag of data already cached on the client
Future<Response> getAllAssetsWithHttpInfo({ String? ifNoneMatch, }) async {
// ignore: prefer_const_declarations
final path = r'/asset';
@@ -308,6 +313,10 @@ class AssetApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (ifNoneMatch != null) {
headerParams[r'if-none-match'] = parameterToString(ifNoneMatch);
}
const contentTypes = <String>[];
@@ -325,8 +334,13 @@ class AssetApi {
///
///
/// Get all AssetEntity belong to the user
Future<List<AssetResponseDto>?> getAllAssets() async {
final response = await getAllAssetsWithHttpInfo();
///
/// Parameters:
///
/// * [String] ifNoneMatch:
/// ETag of data already cached on the client
Future<List<AssetResponseDto>?> getAllAssets({ String? ifNoneMatch, }) async {
final response = await getAllAssetsWithHttpInfo( ifNoneMatch: ifNoneMatch, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@@ -43,48 +43,51 @@ class AlbumResponseDto {
List<AssetResponseDto> assets;
@override
bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto &&
other.assetCount == assetCount &&
other.id == id &&
other.ownerId == ownerId &&
other.albumName == albumName &&
other.createdAt == createdAt &&
other.albumThumbnailAssetId == albumThumbnailAssetId &&
other.shared == shared &&
other.sharedUsers == sharedUsers &&
other.assets == assets;
bool operator ==(Object other) =>
identical(this, other) ||
other is AlbumResponseDto &&
other.assetCount == assetCount &&
other.id == id &&
other.ownerId == ownerId &&
other.albumName == albumName &&
other.createdAt == createdAt &&
other.albumThumbnailAssetId == albumThumbnailAssetId &&
other.shared == shared &&
other.sharedUsers == sharedUsers &&
other.assets == assets;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assetCount.hashCode) +
(id.hashCode) +
(ownerId.hashCode) +
(albumName.hashCode) +
(createdAt.hashCode) +
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
(shared.hashCode) +
(sharedUsers.hashCode) +
(assets.hashCode);
// ignore: unnecessary_parenthesis
(assetCount.hashCode) +
(id.hashCode) +
(ownerId.hashCode) +
(albumName.hashCode) +
(createdAt.hashCode) +
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
(shared.hashCode) +
(sharedUsers.hashCode) +
(assets.hashCode);
@override
String toString() => 'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
String toString() =>
'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'assetCount'] = assetCount;
_json[r'id'] = id;
_json[r'ownerId'] = ownerId;
_json[r'albumName'] = albumName;
_json[r'createdAt'] = createdAt;
_json[r'assetCount'] = assetCount;
_json[r'id'] = id;
_json[r'ownerId'] = ownerId;
_json[r'albumName'] = albumName;
_json[r'createdAt'] = createdAt;
if (albumThumbnailAssetId != null) {
_json[r'albumThumbnailAssetId'] = albumThumbnailAssetId;
} else {
_json[r'albumThumbnailAssetId'] = null;
}
_json[r'shared'] = shared;
_json[r'sharedUsers'] = sharedUsers;
_json[r'assets'] = assets;
_json[r'shared'] = shared;
_json[r'sharedUsers'] = sharedUsers;
_json[r'assets'] = assets;
return _json;
}
@@ -98,13 +101,13 @@ class AlbumResponseDto {
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
// assert(() {
// requiredKeys.forEach((key) {
// assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.');
// assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
// });
// return true;
// }());
return AlbumResponseDto(
assetCount: mapValueOfType<int>(json, r'assetCount')!,
@@ -112,7 +115,8 @@ class AlbumResponseDto {
ownerId: mapValueOfType<String>(json, r'ownerId')!,
albumName: mapValueOfType<String>(json, r'albumName')!,
createdAt: mapValueOfType<String>(json, r'createdAt')!,
albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
albumThumbnailAssetId:
mapValueOfType<String>(json, r'albumThumbnailAssetId'),
shared: mapValueOfType<bool>(json, r'shared')!,
sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers'])!,
assets: AssetResponseDto.listFromJson(json[r'assets'])!,
@@ -121,7 +125,10 @@ class AlbumResponseDto {
return null;
}
static List<AlbumResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
static List<AlbumResponseDto>? listFromJson(
dynamic json, {
bool growable = false,
}) {
final result = <AlbumResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
@@ -149,12 +156,18 @@ class AlbumResponseDto {
}
// maps a json object with a list of AlbumResponseDto-objects as value to a dart map
static Map<String, List<AlbumResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
static Map<String, List<AlbumResponseDto>> mapListFromJson(
dynamic json, {
bool growable = false,
}) {
final map = <String, List<AlbumResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AlbumResponseDto.listFromJson(entry.value, growable: growable,);
final value = AlbumResponseDto.listFromJson(
entry.value,
growable: growable,
);
if (value != null) {
map[entry.key] = value;
}
@@ -176,4 +189,3 @@ class AlbumResponseDto {
'assets',
};
}

View File

@@ -26,10 +26,10 @@ class AssetResponseDto {
required this.mimeType,
required this.duration,
required this.webpPath,
required this.encodedVideoPath,
this.encodedVideoPath,
this.exifInfo,
this.smartInfo,
required this.livePhotoVideoId,
this.livePhotoVideoId,
});
AssetTypeEnum type;
@@ -79,74 +79,71 @@ class AssetResponseDto {
String? livePhotoVideoId;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AssetResponseDto &&
other.type == type &&
other.id == id &&
other.deviceAssetId == deviceAssetId &&
other.ownerId == ownerId &&
other.deviceId == deviceId &&
other.originalPath == originalPath &&
other.resizePath == resizePath &&
other.createdAt == createdAt &&
other.modifiedAt == modifiedAt &&
other.isFavorite == isFavorite &&
other.mimeType == mimeType &&
other.duration == duration &&
other.webpPath == webpPath &&
other.encodedVideoPath == encodedVideoPath &&
other.exifInfo == exifInfo &&
other.smartInfo == smartInfo &&
other.livePhotoVideoId == livePhotoVideoId;
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
other.type == type &&
other.id == id &&
other.deviceAssetId == deviceAssetId &&
other.ownerId == ownerId &&
other.deviceId == deviceId &&
other.originalPath == originalPath &&
other.resizePath == resizePath &&
other.createdAt == createdAt &&
other.modifiedAt == modifiedAt &&
other.isFavorite == isFavorite &&
other.mimeType == mimeType &&
other.duration == duration &&
other.webpPath == webpPath &&
other.encodedVideoPath == encodedVideoPath &&
other.exifInfo == exifInfo &&
other.smartInfo == smartInfo &&
other.livePhotoVideoId == livePhotoVideoId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(type.hashCode) +
(id.hashCode) +
(deviceAssetId.hashCode) +
(ownerId.hashCode) +
(deviceId.hashCode) +
(originalPath.hashCode) +
(resizePath == null ? 0 : resizePath!.hashCode) +
(createdAt.hashCode) +
(modifiedAt.hashCode) +
(isFavorite.hashCode) +
(mimeType == null ? 0 : mimeType!.hashCode) +
(duration.hashCode) +
(webpPath == null ? 0 : webpPath!.hashCode) +
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
(exifInfo == null ? 0 : exifInfo!.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode) +
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode);
// ignore: unnecessary_parenthesis
(type.hashCode) +
(id.hashCode) +
(deviceAssetId.hashCode) +
(ownerId.hashCode) +
(deviceId.hashCode) +
(originalPath.hashCode) +
(resizePath == null ? 0 : resizePath!.hashCode) +
(createdAt.hashCode) +
(modifiedAt.hashCode) +
(isFavorite.hashCode) +
(mimeType == null ? 0 : mimeType!.hashCode) +
(duration.hashCode) +
(webpPath == null ? 0 : webpPath!.hashCode) +
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
(exifInfo == null ? 0 : exifInfo!.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode) +
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode);
@override
String toString() =>
'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId]';
String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'type'] = type;
_json[r'id'] = id;
_json[r'deviceAssetId'] = deviceAssetId;
_json[r'ownerId'] = ownerId;
_json[r'deviceId'] = deviceId;
_json[r'originalPath'] = originalPath;
_json[r'type'] = type;
_json[r'id'] = id;
_json[r'deviceAssetId'] = deviceAssetId;
_json[r'ownerId'] = ownerId;
_json[r'deviceId'] = deviceId;
_json[r'originalPath'] = originalPath;
if (resizePath != null) {
_json[r'resizePath'] = resizePath;
} else {
_json[r'resizePath'] = null;
}
_json[r'createdAt'] = createdAt;
_json[r'modifiedAt'] = modifiedAt;
_json[r'isFavorite'] = isFavorite;
_json[r'createdAt'] = createdAt;
_json[r'modifiedAt'] = modifiedAt;
_json[r'isFavorite'] = isFavorite;
if (mimeType != null) {
_json[r'mimeType'] = mimeType;
} else {
_json[r'mimeType'] = null;
}
_json[r'duration'] = duration;
_json[r'duration'] = duration;
if (webpPath != null) {
_json[r'webpPath'] = webpPath;
} else {
@@ -185,13 +182,13 @@ class AssetResponseDto {
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
// assert(() {
// requiredKeys.forEach((key) {
// assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
// assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
// });
// return true;
// }());
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
return AssetResponseDto(
type: AssetTypeEnum.fromJson(json[r'type'])!,
@@ -216,10 +213,7 @@ class AssetResponseDto {
return null;
}
static List<AssetResponseDto>? listFromJson(
dynamic json, {
bool growable = false,
}) {
static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
@@ -247,18 +241,12 @@ class AssetResponseDto {
}
// maps a json object with a list of AssetResponseDto-objects as value to a dart map
static Map<String, List<AssetResponseDto>> mapListFromJson(
dynamic json, {
bool growable = false,
}) {
static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetResponseDto.listFromJson(
entry.value,
growable: growable,
);
final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
@@ -282,7 +270,6 @@ class AssetResponseDto {
'mimeType',
'duration',
'webpPath',
'encodedVideoPath',
'livePhotoVideoId',
};
}

View File

@@ -21,7 +21,7 @@ class UserResponseDto {
required this.profileImagePath,
required this.shouldChangePassword,
required this.isAdmin,
required this.deletedAt,
this.deletedAt,
});
String id;
@@ -40,49 +40,52 @@ class UserResponseDto {
bool isAdmin;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? deletedAt;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is UserResponseDto &&
other.id == id &&
other.email == email &&
other.firstName == firstName &&
other.lastName == lastName &&
other.createdAt == createdAt &&
other.profileImagePath == profileImagePath &&
other.shouldChangePassword == shouldChangePassword &&
other.isAdmin == isAdmin &&
other.deletedAt == deletedAt;
bool operator ==(Object other) => identical(this, other) || other is UserResponseDto &&
other.id == id &&
other.email == email &&
other.firstName == firstName &&
other.lastName == lastName &&
other.createdAt == createdAt &&
other.profileImagePath == profileImagePath &&
other.shouldChangePassword == shouldChangePassword &&
other.isAdmin == isAdmin &&
other.deletedAt == deletedAt;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(id.hashCode) +
(email.hashCode) +
(firstName.hashCode) +
(lastName.hashCode) +
(createdAt.hashCode) +
(profileImagePath.hashCode) +
(shouldChangePassword.hashCode) +
(isAdmin.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode);
// ignore: unnecessary_parenthesis
(id.hashCode) +
(email.hashCode) +
(firstName.hashCode) +
(lastName.hashCode) +
(createdAt.hashCode) +
(profileImagePath.hashCode) +
(shouldChangePassword.hashCode) +
(isAdmin.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode);
@override
String toString() =>
'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt]';
String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'id'] = id;
_json[r'email'] = email;
_json[r'firstName'] = firstName;
_json[r'lastName'] = lastName;
_json[r'createdAt'] = createdAt;
_json[r'profileImagePath'] = profileImagePath;
_json[r'shouldChangePassword'] = shouldChangePassword;
_json[r'isAdmin'] = isAdmin;
_json[r'id'] = id;
_json[r'email'] = email;
_json[r'firstName'] = firstName;
_json[r'lastName'] = lastName;
_json[r'createdAt'] = createdAt;
_json[r'profileImagePath'] = profileImagePath;
_json[r'shouldChangePassword'] = shouldChangePassword;
_json[r'isAdmin'] = isAdmin;
if (deletedAt != null) {
_json[r'deletedAt'] = deletedAt!.toUtc().toIso8601String();
} else {
@@ -101,13 +104,13 @@ class UserResponseDto {
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
// assert(() {
// requiredKeys.forEach((key) {
// assert(json.containsKey(key), 'Required key "UserResponseDto[$key]" is missing from JSON.');
// assert(json[key] != null, 'Required key "UserResponseDto[$key]" has a null value in JSON.');
// });
// return true;
// }());
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "UserResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "UserResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
return UserResponseDto(
id: mapValueOfType<String>(json, r'id')!,
@@ -116,8 +119,7 @@ class UserResponseDto {
lastName: mapValueOfType<String>(json, r'lastName')!,
createdAt: mapValueOfType<String>(json, r'createdAt')!,
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
shouldChangePassword:
mapValueOfType<bool>(json, r'shouldChangePassword')!,
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
deletedAt: mapDateTime(json, r'deletedAt', ''),
);
@@ -125,10 +127,7 @@ class UserResponseDto {
return null;
}
static List<UserResponseDto>? listFromJson(
dynamic json, {
bool growable = false,
}) {
static List<UserResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <UserResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
@@ -156,18 +155,12 @@ class UserResponseDto {
}
// maps a json object with a list of UserResponseDto-objects as value to a dart map
static Map<String, List<UserResponseDto>> mapListFromJson(
dynamic json, {
bool growable = false,
}) {
static Map<String, List<UserResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<UserResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = UserResponseDto.listFromJson(
entry.value,
growable: growable,
);
final value = UserResponseDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
@@ -186,6 +179,6 @@ class UserResponseDto {
'profileImagePath',
'shouldChangePassword',
'isAdmin',
'deletedAt',
};
}

View File

@@ -266,7 +266,7 @@ packages:
name: ffi
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.1"
version: "2.0.1"
file:
dependency: transitive
description:
@@ -554,12 +554,12 @@ packages:
source: hosted
version: "1.0.1"
logging:
dependency: transitive
dependency: "direct main"
description:
name: logging
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
version: "1.1.0"
matcher:
dependency: transitive
description:
@@ -629,7 +629,7 @@ packages:
name: package_info_plus
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.2"
version: "1.4.3+1"
package_info_plus_linux:
dependency: transitive
description:
@@ -664,7 +664,7 @@ packages:
name: package_info_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.5"
version: "2.1.0"
path:
dependency: "direct main"
description:
@@ -699,7 +699,7 @@ packages:
name: path_provider_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.6"
version: "2.1.7"
path_provider_macos:
dependency: transitive
description:
@@ -720,7 +720,7 @@ packages:
name: path_provider_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.6"
version: "2.1.3"
pedantic:
dependency: transitive
description:
@@ -998,14 +998,14 @@ packages:
name: sqflite
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2+1"
version: "2.2.0+3"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.1+1"
version: "2.4.0+2"
stack_trace:
dependency: transitive
description:
@@ -1257,7 +1257,7 @@ packages:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "2.5.2"
version: "2.7.0"
wkt_parser:
dependency: transitive
description:

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.36.1+56
version: 1.37.0+58
environment:
sdk: ">=2.17.0 <3.0.0"
@@ -47,6 +47,7 @@ dependencies:
# easy to remove packages:
image_picker: ^0.8.5+3 # only used to select user profile image from system gallery -> we can simply select an image from within immich?
logging: ^1.1.0
dev_dependencies:
flutter_test:
@@ -71,7 +72,9 @@ flutter:
- family: SnowburstOne
fonts:
- asset: fonts/SnowburstOne.ttf
- family: Inconsolata
fonts:
- asset: fonts/Inconsolata-Regular.ttf
flutter_icons:
image_path_android: "assets/immich-logo-no-outline.png"
image_path_ios: "assets/immich-logo-no-outline.png"

View File

@@ -21,12 +21,12 @@ import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { assetUploadOption } from '../../config/asset-upload.config';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { ServeFileDto } from './dto/serve-file.dto';
import { Response as Res } from 'express';
import { Response as Res} from 'express';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetResponseDto } from './response-dto/asset-response.dto';
@@ -108,7 +108,7 @@ export class AssetController {
}
@Get('/file/:assetId')
@Header('Cache-Control', 'max-age=300')
@Header('Cache-Control', 'max-age=3600')
async serveFile(
@Headers() headers: Record<string, string>,
@Response({ passthrough: true }) res: Res,
@@ -119,13 +119,14 @@ export class AssetController {
}
@Get('/thumbnail/:assetId')
@Header('Cache-Control', 'max-age=300')
@Header('Cache-Control', 'max-age=3600')
async getAssetThumbnail(
@Headers() headers: Record<string, string>,
@Response({ passthrough: true }) res: Res,
@Param('assetId') assetId: string,
@Query(new ValidationPipe({ transform: true })) query: GetAssetThumbnailDto,
): Promise<any> {
return this.assetService.getAssetThumbnail(assetId, query, res);
return this.assetService.getAssetThumbnail(assetId, query, res, headers);
}
@Get('/curated-objects')
@@ -168,8 +169,15 @@ export class AssetController {
* Get all AssetEntity belong to the user
*/
@Get('/')
@ApiHeader({
name: 'if-none-match',
description: 'ETag of data already cached on the client',
required: false,
schema: { type: 'string' },
})
async getAllAssets(@GetAuthUser() authUser: AuthUserDto): Promise<AssetResponseDto[]> {
return await this.assetService.getAllAssets(authUser);
const assets = await this.assetService.getAllAssets(authUser);
return assets;
}
@Post('/time-bucket')

View File

@@ -306,7 +306,12 @@ export class AssetService {
}
}
public async getAssetThumbnail(assetId: string, query: GetAssetThumbnailDto, res: Res) {
public async getAssetThumbnail(
assetId: string,
query: GetAssetThumbnailDto,
res: Res,
headers: Record<string, string>,
) {
let fileReadStream: ReadStream;
const asset = await this.assetRepository.findOne({ where: { id: assetId } });
@@ -316,28 +321,22 @@ export class AssetService {
}
try {
if (query.format == GetAssetThumbnailFormatEnum.JPEG) {
if (query.format == GetAssetThumbnailFormatEnum.WEBP && asset.webpPath && asset.webpPath.length > 0) {
if (await processETag(asset.webpPath, res, headers)) {
return;
}
await fs.access(asset.webpPath, constants.R_OK);
fileReadStream = createReadStream(asset.webpPath);
} else {
if (!asset.resizePath) {
throw new NotFoundException('resizePath not set');
}
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath);
} else {
if (asset.webpPath && asset.webpPath.length > 0) {
await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.webpPath);
} else {
if (!asset.resizePath) {
throw new NotFoundException('resizePath not set');
}
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath);
if (await processETag(asset.resizePath, res, headers)) {
return;
}
await fs.access(asset.resizePath, constants.R_OK);
fileReadStream = createReadStream(asset.resizePath);
}
res.header('Cache-Control', 'max-age=300');
return new StreamableFile(fileReadStream);
} catch (e) {
res.header('Cache-Control', 'none');
@@ -349,7 +348,7 @@ export class AssetService {
}
}
public async serveFile(assetId: string, query: ServeFileDto, res: Res, headers: any) {
public async serveFile(assetId: string, query: ServeFileDto, res: Res, headers: Record<string, string>) {
let fileReadStream: ReadStream;
const asset = await this._assetRepository.getById(assetId);
@@ -371,6 +370,9 @@ export class AssetService {
Logger.error('Error serving IMAGE asset for web', 'ServeFile');
throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile');
}
if (await processETag(asset.resizePath, res, headers)) {
return;
}
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath);
@@ -384,7 +386,9 @@ export class AssetService {
res.set({
'Content-Type': asset.mimeType,
});
if (await processETag(asset.originalPath, res, headers)) {
return;
}
await fs.access(asset.originalPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.originalPath);
} else {
@@ -392,7 +396,9 @@ export class AssetService {
res.set({
'Content-Type': 'image/webp',
});
if (await processETag(asset.webpPath, res, headers)) {
return;
}
await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.webpPath);
} else {
@@ -403,6 +409,9 @@ export class AssetService {
if (!asset.resizePath) {
throw new Error('resizePath not set');
}
if (await processETag(asset.resizePath, res, headers)) {
return;
}
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath);
@@ -436,9 +445,9 @@ export class AssetService {
if (range) {
/** Extracting Start and End value from Range Header */
let [start, end] = range.replace(/bytes=/, '').split('-');
start = parseInt(start, 10);
end = end ? parseInt(end, 10) : size - 1;
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
let start = parseInt(startStr, 10);
let end = endStr ? parseInt(endStr, 10) : size - 1;
if (!isNaN(start) && isNaN(end)) {
start = start;
@@ -475,7 +484,9 @@ export class AssetService {
res.set({
'Content-Type': mimeType,
});
if (await processETag(asset.originalPath, res, headers)) {
return;
}
return new StreamableFile(createReadStream(videoPath));
}
} catch (e) {
@@ -632,3 +643,14 @@ export class AssetService {
return this._assetRepository.getAssetCountByUserId(authUser.id);
}
}
async function processETag(path: string, res: Res, headers: Record<string, string>): Promise<boolean> {
const { size, mtimeNs } = await fs.stat(path, { bigint: true });
const etag = `W/"${size}-${mtimeNs}"`;
res.setHeader('ETag', etag);
if (etag === headers['if-none-match']) {
res.status(304);
return true;
}
return false;
}

View File

@@ -19,10 +19,10 @@ export class AssetResponseDto {
mimeType!: string | null;
duration!: string;
webpPath!: string | null;
encodedVideoPath!: string | null;
encodedVideoPath?: string | null;
exifInfo?: ExifResponseDto;
smartInfo?: SmartInfoResponseDto;
livePhotoVideoId!: string | null;
livePhotoVideoId?: string | null;
}
export function mapAsset(entity: AssetEntity): AssetResponseDto {

View File

@@ -9,7 +9,7 @@ export class UserResponseDto {
profileImagePath!: string;
shouldChangePassword!: boolean;
isAdmin!: boolean;
deletedAt!: Date | null;
deletedAt?: Date;
}
export function mapUser(entity: UserEntity): UserResponseDto {
@@ -22,6 +22,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
profileImagePath: entity.profileImagePath,
shouldChangePassword: entity.shouldChangePassword,
isAdmin: entity.isAdmin,
deletedAt: entity.deletedAt || null,
deletedAt: entity.deletedAt,
};
}

View File

@@ -127,5 +127,16 @@ describe('UserService', () => {
});
expect(result).rejects.toBeInstanceOf(NotFoundException);
});
it('cannot delete admin user', () => {
const requestor = adminAuthUser;
userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(adminUser));
userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(adminUser));
const result = sui.deleteUser(requestor, adminAuthUser.id);
expect(result).rejects.toBeInstanceOf(BadRequestException);
});
});
});

View File

@@ -119,6 +119,11 @@ export class UserService {
if (!user) {
throw new BadRequestException('User not found');
}
if (user.isAdmin) {
throw new BadRequestException('Cannot delete admin user');
}
try {
const deletedUser = await this.userRepository.delete(user);
return mapUser(deletedUser);

View File

@@ -10,7 +10,7 @@ export interface IServerVersion {
export const serverVersion: IServerVersion = {
major: 1,
minor: 36,
patch: 2,
build: 56,
minor: 37,
patch: 0,
build: 58,
};

View File

@@ -14,6 +14,7 @@ async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.set('trust proxy');
app.set('etag', 'strong');
app.use(cookieParser());
app.use(json({ limit: '10mb' }));
if (process.env.NODE_ENV === 'development') {

View File

@@ -35,7 +35,7 @@ export class DownloadService {
fileCount++;
// for easier testing, can be changed before merging.
if (totalSize > HumanReadableSize.GB * 20) {
if (totalSize > HumanReadableSize.GiB * 20) {
complete = false;
this.logger.log(
`Archive size exceeded after ${fileCount} files, capping at ${totalSize} bytes (${asHumanReadable(

View File

@@ -1,31 +1,25 @@
const KB = 1000;
const MB = KB * 1000;
const GB = MB * 1000;
const TB = GB * 1000;
const PB = TB * 1000;
const KiB = Math.pow(1024, 1);
const MiB = Math.pow(1024, 2);
const GiB = Math.pow(1024, 3);
const TiB = Math.pow(1024, 4);
const PiB = Math.pow(1024, 5);
export const HumanReadableSize = { KB, MB, GB, TB, PB };
export const HumanReadableSize = { KiB, MiB, GiB, TiB, PiB };
export function asHumanReadable(bytes: number, precision = 1) {
if (bytes >= PB) {
return `${(bytes / PB).toFixed(precision)}PB`;
}
export function asHumanReadable(bytes: number, precision = 1): string {
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
if (bytes >= TB) {
return `${(bytes / TB).toFixed(precision)}TB`;
}
let magnitude = 0;
let remainder = bytes;
while (remainder >= 1024) {
if (magnitude + 1 < units.length) {
magnitude++;
remainder /= 1024;
}
else {
break;
}
}
if (bytes >= GB) {
return `${(bytes / GB).toFixed(precision)}GB`;
}
if (bytes >= MB) {
return `${(bytes / MB).toFixed(precision)}MB`;
}
if (bytes >= KB) {
return `${(bytes / KB).toFixed(precision)}KB`;
}
return `${bytes}B`;
return `${remainder.toFixed( magnitude == 0 ? 0 : precision )} ${units[magnitude]}`;
}

File diff suppressed because one or more lines are too long

View File

@@ -427,7 +427,7 @@ export interface AssetResponseDto {
* @type {string}
* @memberof AssetResponseDto
*/
'encodedVideoPath': string | null;
'encodedVideoPath'?: string | null;
/**
*
* @type {ExifResponseDto}
@@ -445,7 +445,7 @@ export interface AssetResponseDto {
* @type {string}
* @memberof AssetResponseDto
*/
'livePhotoVideoId': string | null;
'livePhotoVideoId'?: string | null;
}
/**
*
@@ -1729,7 +1729,7 @@ export interface UserResponseDto {
* @type {string}
* @memberof UserResponseDto
*/
'deletedAt': string | null;
'deletedAt'?: string;
}
/**
*
@@ -2788,10 +2788,11 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
/**
* Get all AssetEntity belong to the user
* @summary
* @param {string} [ifNoneMatch] ETag of data already cached on the client
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllAssets: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
getAllAssets: async (ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -2808,6 +2809,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (ifNoneMatch !== undefined && ifNoneMatch !== null) {
localVarHeaderParameter['if-none-match'] = String(ifNoneMatch);
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -3388,11 +3393,12 @@ export const AssetApiFp = function(configuration?: Configuration) {
/**
* Get all AssetEntity belong to the user
* @summary
* @param {string} [ifNoneMatch] ETag of data already cached on the client
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAllAssets(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(options);
async getAllAssets(ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(ifNoneMatch, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@@ -3590,11 +3596,12 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
/**
* Get all AssetEntity belong to the user
* @summary
* @param {string} [ifNoneMatch] ETag of data already cached on the client
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllAssets(options?: any): AxiosPromise<Array<AssetResponseDto>> {
return localVarFp.getAllAssets(options).then((request) => request(axios, basePath));
getAllAssets(ifNoneMatch?: string, options?: any): AxiosPromise<Array<AssetResponseDto>> {
return localVarFp.getAllAssets(ifNoneMatch, options).then((request) => request(axios, basePath));
},
/**
* Get a single asset\'s information
@@ -3788,12 +3795,13 @@ export class AssetApi extends BaseAPI {
/**
* Get all AssetEntity belong to the user
* @summary
* @param {string} [ifNoneMatch] ETag of data already cached on the client
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public getAllAssets(options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getAllAssets(options).then((request) => request(this.axios, this.basePath));
public getAllAssets(ifNoneMatch?: string, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getAllAssets(ifNoneMatch, options).then((request) => request(this.axios, this.basePath));
}
/**

View File

@@ -15,8 +15,8 @@
return name;
};
$: spaceUnit = stats.usage.slice(stats.usage.length - 2, stats.usage.length);
$: spaceUsage = stats.usage.slice(0, stats.usage.length - 2);
$: spaceUnit = stats.usage.split(' ')[1];
$: spaceUsage = stats.usage.split(' ')[0];
</script>
<div class="flex flex-col gap-5">

View File

@@ -46,7 +46,7 @@
</script>
<div
class="h-16 bg-black/5 flex justify-between place-items-center px-3 transition-transform duration-200 z-[9999]"
class="h-16 flex justify-between place-items-center px-3 transition-transform duration-200 z-[9999]"
>
<div>
<CircleIconButton logo={ArrowLeft} on:click={() => dispatch('goBack')} />

View File

@@ -8,6 +8,7 @@
import { createEventDispatcher, onMount } from 'svelte';
import { browser } from '$app/environment';
import { AssetResponseDto, AlbumResponseDto } from '@api';
import { getHumanReadableBytes } from '../../utils/byte-units';
type Leaflet = typeof import('leaflet');
type LeafletMap = import('leaflet').Map;
@@ -59,32 +60,6 @@
}
const dispatch = createEventDispatcher();
const getHumanReadableString = (sizeInByte: number) => {
const pepibyte = 1.126 * Math.pow(10, 15);
const tebibyte = 1.1 * Math.pow(10, 12);
const gibibyte = 1.074 * Math.pow(10, 9);
const mebibyte = 1.049 * Math.pow(10, 6);
const kibibyte = 1024;
// Pebibyte
if (sizeInByte >= pepibyte) {
// Pe
return `${(sizeInByte / pepibyte).toFixed(1)}PB`;
} else if (tebibyte <= sizeInByte && sizeInByte < pepibyte) {
// Te
return `${(sizeInByte / tebibyte).toFixed(1)}TB`;
} else if (gibibyte <= sizeInByte && sizeInByte < tebibyte) {
// Gi
return `${(sizeInByte / gibibyte).toFixed(1)}GB`;
} else if (mebibyte <= sizeInByte && sizeInByte < gibibyte) {
// Mega
return `${(sizeInByte / mebibyte).toFixed(1)}MB`;
} else if (kibibyte <= sizeInByte && sizeInByte < mebibyte) {
// Kibi
return `${(sizeInByte / kibibyte).toFixed(1)}KB`;
} else {
return `${sizeInByte}B`;
}
};
const getMegapixel = (width: number, height: number): number | undefined => {
const megapixel = Math.round((height * width) / 1_000_000);
@@ -143,13 +118,13 @@
{#if asset.exifInfo.exifImageHeight && asset.exifInfo.exifImageWidth}
{#if getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}
<p>
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}MP
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
</p>
{/if}
<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p>
{/if}
<p>{getHumanReadableString(asset.exifInfo.fileSizeInByte)}</p>
<p>{getHumanReadableBytes(asset.exifInfo.fileSizeInByte)}</p>
</div>
</div>
</div>
@@ -162,7 +137,7 @@
<div>
<p>{asset.exifInfo.make || ''} {asset.exifInfo.model || ''}</p>
<div class="flex text-sm gap-2">
<p>{`f/${asset.exifInfo.fNumber}` || ''}</p>
<p>{`ƒ/${asset.exifInfo.fNumber}` || ''}</p>
{#if asset.exifInfo.exposureTime}
<p>{`1/${Math.floor(1 / asset.exifInfo.exposureTime)}`}</p>

View File

@@ -94,7 +94,7 @@
<div
id="immich-scrubbable-scrollbar"
class="fixed right-0 bg-immich-bg z-[50] hover:cursor-row-resize select-none "
class="fixed right-0 bg-immich-bg z-[100] hover:cursor-row-resize select-none "
style:width={isDragging ? '100vw' : '60px'}
style:background-color={isDragging ? 'transparent' : 'transparent'}
on:mouseenter={() => (isHover = true)}
@@ -109,7 +109,7 @@
>
{#if isHover}
<div
class="border-b-2 border-immich-primary dark:border-immich-dark-primary w-[100px] right-0 pr-6 py-1 text-sm pl-1 font-medium absolute bg-immich-bg dark:bg-immich-dark-gray z-50 pointer-events-none rounded-tl-md shadow-lg dark:text-immich-dark-fg"
class="border-b-2 border-immich-primary dark:border-immich-dark-primary w-[100px] right-0 pr-6 py-1 text-sm pl-1 font-medium absolute bg-immich-bg dark:bg-immich-dark-gray z-[100] pointer-events-none rounded-tl-md shadow-lg dark:text-immich-dark-fg"
style:top={currentMouseYLocation + 'px'}
>
{hoveredDate?.toLocaleString('default', { month: 'short' })}

View File

@@ -6,6 +6,8 @@
import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
import type { UploadAsset } from '$lib/models/upload-asset';
import { notificationController, NotificationType } from './notification/notification';
import { getHumanReadableBytes } from '../../utils/byte-units';
let showDetail = true;
let uploadLength = 0;
@@ -30,33 +32,6 @@
}
};
function getSizeInHumanReadableFormat(sizeInByte: number) {
const pepibyte = 1.126 * Math.pow(10, 15);
const tebibyte = 1.1 * Math.pow(10, 12);
const gibibyte = 1.074 * Math.pow(10, 9);
const mebibyte = 1.049 * Math.pow(10, 6);
const kibibyte = 1024;
// Pebibyte
if (sizeInByte >= pepibyte) {
// Pe
return `${(sizeInByte / pepibyte).toFixed(1)}PB`;
} else if (tebibyte <= sizeInByte && sizeInByte < pepibyte) {
// Te
return `${(sizeInByte / tebibyte).toFixed(1)}TB`;
} else if (gibibyte <= sizeInByte && sizeInByte < tebibyte) {
// Gi
return `${(sizeInByte / gibibyte).toFixed(1)}GB`;
} else if (mebibyte <= sizeInByte && sizeInByte < gibibyte) {
// Mega
return `${(sizeInByte / mebibyte).toFixed(1)}MB`;
} else if (kibibyte <= sizeInByte && sizeInByte < mebibyte) {
// Kibi
return `${(sizeInByte / kibibyte).toFixed(1)}KB`;
} else {
return `${sizeInByte}B`;
}
}
// Reactive action to get thumbnail image of upload asset whenever there is a new one added to the list
$: {
if ($uploadAssetsStore.length != uploadLength) {
@@ -140,7 +115,7 @@
<input
disabled
class="bg-gray-100 border w-full p-1 rounded-md text-[10px] px-2"
value={`[${getSizeInHumanReadableFormat(uploadAsset.file.size)}] ${
value={`[${getHumanReadableBytes(uploadAsset.file.size)}] ${
uploadAsset.file.name
}`}
/>

View File

@@ -0,0 +1,16 @@
export function getHumanReadableBytes(bytes: number): string {
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
let magnitude = 0;
let remainder = bytes;
while (remainder >= 1024) {
if (magnitude + 1 < units.length) {
magnitude++;
remainder /= 1024;
} else {
break;
}
}
return `${remainder.toFixed(magnitude == 0 ? 0 : 1)} ${units[magnitude]}`;
}