diff --git a/mobile-v2/analysis_options.yaml b/mobile-v2/analysis_options.yaml index a74bafe91a..8eefcda6d0 100644 --- a/mobile-v2/analysis_options.yaml +++ b/mobile-v2/analysis_options.yaml @@ -4,7 +4,8 @@ include: package:flutter_lints/flutter.yaml linter: rules: - avoid_single_cascade_in_expression_statements: false + - avoid_single_cascade_in_expression_statements: false + - unawaited_futures analyzer: exclude: diff --git a/mobile-v2/assets/i18n/strings.i18n.json b/mobile-v2/assets/i18n/strings.i18n.json index 5534412991..63ac6f2583 100644 --- a/mobile-v2/assets/i18n/strings.i18n.json +++ b/mobile-v2/assets/i18n/strings.i18n.json @@ -16,7 +16,9 @@ "error": { "empty_server_url": "Kindly provide a server URL", "invalid_server_url": "Invalid URL", - "server_not_reachable": "Server is not reachable" + "server_not_reachable": "Server is not reachable", + "error_login": "Error logging in", + "error_login_oauth": "Error logging using OAuth, check server URL" }, "label": { "email": "Email", diff --git a/mobile-v2/lib/domain/entities/album.entity.dart b/mobile-v2/lib/domain/entities/album.entity.dart index 64422c172a..077e518fee 100644 --- a/mobile-v2/lib/domain/entities/album.entity.dart +++ b/mobile-v2/lib/domain/entities/album.entity.dart @@ -4,7 +4,7 @@ class LocalAlbum extends Table { const LocalAlbum(); IntColumn get id => integer().autoIncrement()(); - TextColumn get localId => text()(); + TextColumn get localId => text().unique()(); TextColumn get name => text()(); DateTimeColumn get modifiedTime => dateTime().withDefault(currentDateAndTime)(); diff --git a/mobile-v2/lib/domain/entities/asset.entity.dart b/mobile-v2/lib/domain/entities/asset.entity.dart index ba4961d3c6..2daf326207 100644 --- a/mobile-v2/lib/domain/entities/asset.entity.dart +++ b/mobile-v2/lib/domain/entities/asset.entity.dart @@ -5,9 +5,9 @@ class LocalAsset extends Table { const LocalAsset(); IntColumn get id => integer().autoIncrement()(); - TextColumn get localId => text()(); + TextColumn get localId => text().unique()(); TextColumn get name => text()(); - TextColumn get checksum => text()(); + TextColumn get checksum => text().unique()(); IntColumn get height => integer()(); IntColumn get width => integer()(); IntColumn get type => intEnum()(); diff --git a/mobile-v2/lib/domain/entities/store.entity.dart b/mobile-v2/lib/domain/entities/store.entity.dart index c7d7cee09c..e7ef589d15 100644 --- a/mobile-v2/lib/domain/entities/store.entity.dart +++ b/mobile-v2/lib/domain/entities/store.entity.dart @@ -6,7 +6,10 @@ class Store extends Table { @override String get tableName => 'store'; - IntColumn get id => integer().autoIncrement()(); + IntColumn get id => integer()(); IntColumn get intValue => integer().nullable()(); TextColumn get stringValue => text().nullable()(); + + @override + Set get primaryKey => {id}; } diff --git a/mobile-v2/lib/domain/entities/user.entity.dart b/mobile-v2/lib/domain/entities/user.entity.dart new file mode 100644 index 0000000000..c9adc59c03 --- /dev/null +++ b/mobile-v2/lib/domain/entities/user.entity.dart @@ -0,0 +1,24 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; + +class User extends Table { + const User(); + + TextColumn get id => text()(); + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); + TextColumn get name => text()(); + TextColumn get email => text()(); + BoolColumn get isAdmin => boolean().withDefault(const Constant(false))(); + // Quota + IntColumn get quotaSizeInBytes => integer().withDefault(const Constant(0))(); + IntColumn get quotaUsageInBytes => integer().withDefault(const Constant(0))(); + // Sharing + BoolColumn get inTimeline => boolean().withDefault(const Constant(false))(); + // User prefs + TextColumn get profileImagePath => text()(); + BoolColumn get memoryEnabled => boolean().withDefault(const Constant(true))(); + IntColumn get avatarColor => intEnum()(); + + @override + Set get primaryKey => {id}; +} diff --git a/mobile-v2/lib/domain/interfaces/log.interface.dart b/mobile-v2/lib/domain/interfaces/log.interface.dart index c410e41725..89135826d3 100644 --- a/mobile-v2/lib/domain/interfaces/log.interface.dart +++ b/mobile-v2/lib/domain/interfaces/log.interface.dart @@ -6,6 +6,15 @@ abstract class ILogRepository { /// Fetches all logs FutureOr> fetchLogs(); + /// Inserts a new log into the DB + FutureOr add(LogMessage log); + + /// Bulk insert logs into DB + FutureOr addAll(List log); + + /// Clears all logs + FutureOr clear(); + /// Truncates the logs to the most recent [limit]. Defaults to recent 250 logs FutureOr truncateLogs({int limit = 250}); } diff --git a/mobile-v2/lib/domain/interfaces/store.interface.dart b/mobile-v2/lib/domain/interfaces/store.interface.dart index 323c58138a..348eabf507 100644 --- a/mobile-v2/lib/domain/interfaces/store.interface.dart +++ b/mobile-v2/lib/domain/interfaces/store.interface.dart @@ -9,19 +9,19 @@ abstract class IStoreConverter { U toPrimitive(T value); /// Converts the value back to T? from the primitive type U from the Store - T? fromPrimitive(U value); + FutureOr fromPrimitive(U value); } abstract class IStoreRepository { - FutureOr getValue(StoreKey key); + FutureOr tryGet(StoreKey key); - FutureOr setValue(StoreKey key, T value); + FutureOr get(StoreKey key); - FutureOr deleteValue(StoreKey key); + FutureOr set(StoreKey key, T value); - Stream watchValue(StoreKey key); + FutureOr delete(StoreKey key); - Stream> watchStore(); + Stream watch(StoreKey key); FutureOr clearStore(); } diff --git a/mobile-v2/lib/domain/interfaces/user.interface.dart b/mobile-v2/lib/domain/interfaces/user.interface.dart new file mode 100644 index 0000000000..9d5c3c6bac --- /dev/null +++ b/mobile-v2/lib/domain/interfaces/user.interface.dart @@ -0,0 +1,11 @@ +import 'dart:async'; + +import 'package:immich_mobile/domain/models/user.model.dart'; + +abstract class IUserRepository { + /// Fetches user + FutureOr getUser(String userId); + + /// Insert user + FutureOr insertUser(User user); +} diff --git a/mobile-v2/lib/domain/models/log.model.dart b/mobile-v2/lib/domain/models/log.model.dart index d6731ea449..d90e3b7329 100644 --- a/mobile-v2/lib/domain/models/log.model.dart +++ b/mobile-v2/lib/domain/models/log.model.dart @@ -24,7 +24,6 @@ extension LevelExtension on Level { @immutable class LogMessage { - final int id; final String content; final LogLevel level; final DateTime createdAt; @@ -33,7 +32,6 @@ class LogMessage { final String? stack; const LogMessage({ - required this.id, required this.content, required this.level, required this.createdAt, @@ -51,8 +49,7 @@ class LogMessage { @override int get hashCode { - return id.hashCode ^ - content.hashCode ^ + return content.hashCode ^ level.hashCode ^ createdAt.hashCode ^ logger.hashCode ^ diff --git a/mobile-v2/lib/domain/models/store.model.dart b/mobile-v2/lib/domain/models/store.model.dart index 8807a4326f..7329fe3270 100644 --- a/mobile-v2/lib/domain/models/store.model.dart +++ b/mobile-v2/lib/domain/models/store.model.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/utils/store_converters.dart'; import 'package:immich_mobile/presentation/modules/theme/models/app_theme.model.dart'; @@ -21,14 +22,33 @@ class StoreValue { int get hashCode => id.hashCode ^ value.hashCode; } +class StoreKeyNotFoundException implements Exception { + final StoreKey key; + const StoreKeyNotFoundException(this.key); + + @override + String toString() => "Key '${key.name}' not found in Store"; +} + /// Key for each possible value in the `Store`. /// Also stores the converter to convert the value to and from the store and the type of value stored in the Store enum StoreKey { serverEndpoint( 0, - converter: StorePrimitiveConverter(), + converter: StoreStringConverter(), type: String, ), + accessToken( + 1, + converter: StoreStringConverter(), + type: String, + ), + currentUser( + 2, + converter: StoreUserConverter(), + type: String, + ), + // App settings appTheme( 1000, converter: StoreEnumConverter(AppTheme.values), @@ -44,7 +64,7 @@ enum StoreKey { const StoreKey(this.id, {required this.converter, required this.type}); final int id; - /// Type is also stored here easily fetch it during runtime + /// Primitive Type is also stored here to easily fetch it during runtime final Type type; final IStoreConverter converter; } diff --git a/mobile-v2/lib/domain/models/user.model.dart b/mobile-v2/lib/domain/models/user.model.dart new file mode 100644 index 0000000000..ec1c794947 --- /dev/null +++ b/mobile-v2/lib/domain/models/user.model.dart @@ -0,0 +1,187 @@ +import 'dart:ui'; + +import 'package:openapi/openapi.dart' as api; + +class User { + const User({ + required this.id, + required this.updatedAt, + required this.name, + required this.email, + required this.isAdmin, + required this.quotaSizeInBytes, + required this.quotaUsageInBytes, + required this.inTimeline, + required this.profileImagePath, + required this.memoryEnabled, + required this.avatarColor, + }); + + final String id; + final DateTime updatedAt; + final String name; + final String email; + final bool isAdmin; + // Quota + final int quotaSizeInBytes; + final int quotaUsageInBytes; + // Sharing + final bool inTimeline; + // User prefs + final String profileImagePath; + final bool memoryEnabled; + final UserAvatarColor avatarColor; + + User copyWith({ + String? id, + DateTime? updatedAt, + String? name, + String? email, + bool? isAdmin, + int? quotaSizeInBytes, + int? quotaUsageInBytes, + bool? inTimeline, + String? profileImagePath, + bool? memoryEnabled, + UserAvatarColor? avatarColor, + }) { + return User( + id: id ?? this.id, + updatedAt: updatedAt ?? this.updatedAt, + name: name ?? this.name, + email: email ?? this.email, + isAdmin: isAdmin ?? this.isAdmin, + quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes, + quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes, + inTimeline: inTimeline ?? this.inTimeline, + profileImagePath: profileImagePath ?? this.profileImagePath, + memoryEnabled: memoryEnabled ?? this.memoryEnabled, + avatarColor: avatarColor ?? this.avatarColor, + ); + } + + @override + String toString() { + return 'User(id: $id, updatedAt: $updatedAt, name: $name, email: $email, isAdmin: $isAdmin, quotaSizeInBytes: $quotaSizeInBytes, quotaUsageInBytes: $quotaUsageInBytes, inTimeline: $inTimeline, profileImagePath: $profileImagePath, memoryEnabled: $memoryEnabled, avatarColor: $avatarColor)'; + } + + @override + bool operator ==(covariant User other) { + if (identical(this, other)) return true; + + return other.id == id && + other.updatedAt == updatedAt && + other.name == name && + other.email == email && + other.isAdmin == isAdmin && + other.quotaSizeInBytes == quotaSizeInBytes && + other.quotaUsageInBytes == quotaUsageInBytes && + other.inTimeline == inTimeline && + other.profileImagePath == profileImagePath && + other.memoryEnabled == memoryEnabled && + other.avatarColor == avatarColor; + } + + @override + int get hashCode { + return id.hashCode ^ + updatedAt.hashCode ^ + name.hashCode ^ + email.hashCode ^ + isAdmin.hashCode ^ + quotaSizeInBytes.hashCode ^ + quotaUsageInBytes.hashCode ^ + inTimeline.hashCode ^ + profileImagePath.hashCode ^ + memoryEnabled.hashCode ^ + avatarColor.hashCode; + } + + factory User.fromAdminDto( + api.UserAdminResponseDto userDto, [ + api.UserPreferencesResponseDto? userPreferences, + ]) { + return User( + id: userDto.id, + updatedAt: DateTime.now(), + name: userDto.name, + email: userDto.email, + isAdmin: userDto.isAdmin, + quotaSizeInBytes: userDto.quotaSizeInBytes ?? 0, + quotaUsageInBytes: userDto.quotaUsageInBytes ?? 0, + inTimeline: true, + profileImagePath: userDto.profileImagePath, + memoryEnabled: userPreferences?.memories.enabled ?? true, + avatarColor: userDto.avatarColor.toEnum(), + ); + } +} + +enum UserAvatarColor { + // do not change this order or reuse indices for other purposes, adding is OK + primary, + pink, + red, + yellow, + blue, + green, + purple, + orange, + gray, + amber, +} + +extension AvatarColorEnumHelper on api.UserAvatarColor { + UserAvatarColor toEnum() { + switch (this) { + case api.UserAvatarColor.primary: + return UserAvatarColor.primary; + case api.UserAvatarColor.pink: + return UserAvatarColor.pink; + case api.UserAvatarColor.red: + return UserAvatarColor.red; + case api.UserAvatarColor.yellow: + return UserAvatarColor.yellow; + case api.UserAvatarColor.blue: + return UserAvatarColor.blue; + case api.UserAvatarColor.green: + return UserAvatarColor.green; + case api.UserAvatarColor.purple: + return UserAvatarColor.purple; + case api.UserAvatarColor.orange: + return UserAvatarColor.orange; + case api.UserAvatarColor.gray: + return UserAvatarColor.gray; + case api.UserAvatarColor.amber: + return UserAvatarColor.amber; + } + return UserAvatarColor.primary; + } +} + +extension AvatarColorToColorHelper on UserAvatarColor { + Color toColor([bool isDarkTheme = false]) { + switch (this) { + case UserAvatarColor.primary: + return isDarkTheme ? const Color(0xFFABCBFA) : const Color(0xFF4250AF); + case UserAvatarColor.pink: + return const Color.fromARGB(255, 244, 114, 182); + case UserAvatarColor.red: + return const Color.fromARGB(255, 239, 68, 68); + case UserAvatarColor.yellow: + return const Color.fromARGB(255, 234, 179, 8); + case UserAvatarColor.blue: + return const Color.fromARGB(255, 59, 130, 246); + case UserAvatarColor.green: + return const Color.fromARGB(255, 22, 163, 74); + case UserAvatarColor.purple: + return const Color.fromARGB(255, 147, 51, 234); + case UserAvatarColor.orange: + return const Color.fromARGB(255, 234, 88, 12); + case UserAvatarColor.gray: + return const Color.fromARGB(255, 75, 85, 99); + case UserAvatarColor.amber: + return const Color.fromARGB(255, 217, 119, 6); + } + } +} diff --git a/mobile-v2/lib/domain/repositories/database.repository.dart b/mobile-v2/lib/domain/repositories/database.repository.dart index d0d0edb205..4a5f4a6fc2 100644 --- a/mobile-v2/lib/domain/repositories/database.repository.dart +++ b/mobile-v2/lib/domain/repositories/database.repository.dart @@ -2,10 +2,14 @@ import 'dart:io'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; +// ignore: depend_on_referenced_packages +import 'package:drift_dev/api/migrations.dart'; +import 'package:flutter/foundation.dart'; import 'package:immich_mobile/domain/entities/album.entity.dart'; import 'package:immich_mobile/domain/entities/asset.entity.dart'; import 'package:immich_mobile/domain/entities/log.entity.dart'; import 'package:immich_mobile/domain/entities/store.entity.dart'; +import 'package:immich_mobile/domain/entities/user.entity.dart'; import 'package:immich_mobile/domain/interfaces/database.interface.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -14,7 +18,7 @@ import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; import 'database.repository.drift.dart'; -@DriftDatabase(tables: [Logs, Store, LocalAlbum, LocalAsset]) +@DriftDatabase(tables: [Logs, Store, LocalAlbum, LocalAsset, User]) class DriftDatabaseRepository extends $DriftDatabaseRepository implements IDatabaseRepository { DriftDatabaseRepository() : super(_openConnection()); @@ -51,6 +55,18 @@ class DriftDatabaseRepository extends $DriftDatabaseRepository @override // ignore: no-empty-block void migrateDB() { - // No migrations yet + // Migrations are handled automatically using the migrator field } + + @override + MigrationStrategy get migration => MigrationStrategy( + onCreate: (m) => m.createAll(), + beforeOpen: (details) async { + if (kDebugMode) { + await validateDatabaseSchema(); + } + }, + // ignore: no-empty-block + onUpgrade: (m, from, to) async {}, + ); } diff --git a/mobile-v2/lib/domain/repositories/log.repository.dart b/mobile-v2/lib/domain/repositories/log.repository.dart index 09a8f5a8c0..c834a19fab 100644 --- a/mobile-v2/lib/domain/repositories/log.repository.dart +++ b/mobile-v2/lib/domain/repositories/log.repository.dart @@ -1,3 +1,7 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; import 'package:immich_mobile/domain/entities/log.entity.drift.dart'; import 'package:immich_mobile/domain/interfaces/log.interface.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; @@ -10,7 +14,7 @@ class LogDriftRepository implements ILogRepository { @override Future> fetchLogs() async { - return await db.select(db.logs).map((l) => l.toModel()).get(); + return await db.managers.logs.map((l) => l.toModel()).get(); } @override @@ -26,12 +30,65 @@ class LogDriftRepository implements ILogRepository { } }); } + + @override + FutureOr add(LogMessage log) async { + try { + await db.transaction(() async { + await db.into(db.logs).insert(LogsCompanion.insert( + content: log.content, + level: log.level, + createdAt: Value(log.createdAt), + error: Value(log.error), + logger: Value(log.logger), + stack: Value(log.stack), + )); + }); + return true; + } catch (e) { + debugPrint("Error while adding a log to the DB - $e"); + return false; + } + } + + @override + FutureOr addAll(List logs) async { + try { + await db.batch((b) { + b.insertAll( + db.logs, + logs.map((log) => LogsCompanion.insert( + content: log.content, + level: log.level, + createdAt: Value(log.createdAt), + error: Value(log.error), + logger: Value(log.logger), + stack: Value(log.stack), + )), + ); + }); + return true; + } catch (e) { + debugPrint("Error while adding a log to the DB - $e"); + return false; + } + } + + @override + FutureOr clear() async { + try { + await db.managers.logs.delete(); + return true; + } catch (e) { + debugPrint("Error while clearning the logs in DB - $e"); + return false; + } + } } extension _LogToLogMessage on Log { LogMessage toModel() { return LogMessage( - id: id, content: content, createdAt: createdAt, level: level, diff --git a/mobile-v2/lib/domain/repositories/store.repository.dart b/mobile-v2/lib/domain/repositories/store.repository.dart index 5a98e7b970..54e314e2f8 100644 --- a/mobile-v2/lib/domain/repositories/store.repository.dart +++ b/mobile-v2/lib/domain/repositories/store.repository.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/entities/store.entity.drift.dart'; import 'package:immich_mobile/domain/interfaces/store.interface.dart'; @@ -14,7 +13,7 @@ class StoreDriftRepository with LogContext implements IStoreRepository { const StoreDriftRepository(this.db); @override - FutureOr getValue(StoreKey key) async { + FutureOr tryGet(StoreKey key) async { final storeData = await db.managers.store .filter((s) => s.id.equals(key.id)) .getSingleOrNull(); @@ -22,7 +21,16 @@ class StoreDriftRepository with LogContext implements IStoreRepository { } @override - FutureOr setValue(StoreKey key, T value) async { + FutureOr get(StoreKey key) async { + final value = await tryGet(key); + if (value == null) { + throw StoreKeyNotFoundException(key); + } + return value; + } + + @override + FutureOr set(StoreKey key, T value) async { try { await db.transaction(() async { final storeValue = key.converter.toPrimitive(value); @@ -42,30 +50,18 @@ class StoreDriftRepository with LogContext implements IStoreRepository { } @override - FutureOr deleteValue(StoreKey key) async { + FutureOr delete(StoreKey key) async { return await db.transaction(() async { await db.managers.store.filter((s) => s.id.equals(key.id)).delete(); }); } @override - Stream> watchStore() { - return (db.select(db.store).map((s) { - final key = StoreKey.values.firstWhereOrNull((e) => e.id == s.id); - if (key != null) { - final value = _getValueFromStoreData(key, s); - return StoreValue(id: s.id, value: value); - } - return StoreValue(id: s.id, value: null); - })).watch(); - } - - @override - Stream watchValue(StoreKey key) { + Stream watch(StoreKey key) { return db.managers.store .filter((s) => s.id.equals(key.id)) .watchSingleOrNull() - .map((e) => _getValueFromStoreData(key, e)); + .asyncMap((e) async => await _getValueFromStoreData(key, e)); } @override @@ -75,7 +71,10 @@ class StoreDriftRepository with LogContext implements IStoreRepository { }); } - T? _getValueFromStoreData(StoreKey key, StoreData? data) { + FutureOr _getValueFromStoreData( + StoreKey key, + StoreData? data, + ) async { final primitive = switch (key.type) { const (int) => data?.intValue, const (String) => data?.stringValue, diff --git a/mobile-v2/lib/domain/repositories/user.repository.dart b/mobile-v2/lib/domain/repositories/user.repository.dart new file mode 100644 index 0000000000..36af9e1034 --- /dev/null +++ b/mobile-v2/lib/domain/repositories/user.repository.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/entities/user.entity.drift.dart'; +import 'package:immich_mobile/domain/interfaces/user.interface.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/domain/repositories/database.repository.dart'; +import 'package:immich_mobile/utils/mixins/log_context.mixin.dart'; + +class UserDriftRepository with LogContext implements IUserRepository { + final DriftDatabaseRepository db; + + const UserDriftRepository(this.db); + + @override + FutureOr getUser(String userId) async { + return await db.managers.user + .filter((f) => f.id.equals(userId)) + .map((u) => u.toModel()) + .getSingleOrNull(); + } + + @override + FutureOr insertUser(User user) async { + try { + return await db.transaction(() async { + await db.into(db.user).insertOnConflictUpdate(UserCompanion.insert( + id: user.id, + name: user.name, + email: user.email, + profileImagePath: user.profileImagePath, + avatarColor: user.avatarColor, + inTimeline: Value(user.inTimeline), + isAdmin: Value(user.isAdmin), + memoryEnabled: Value(user.memoryEnabled), + quotaSizeInBytes: Value(user.quotaSizeInBytes), + quotaUsageInBytes: Value(user.quotaSizeInBytes), + updatedAt: Value(user.updatedAt), + )); + return true; + }); + } catch (e, s) { + log.severe("Cannot insert User into table - $user", e, s); + return false; + } + } +} + +extension _UserDataToUser on UserData { + User toModel() { + return User( + id: id, + email: email, + avatarColor: avatarColor, + inTimeline: inTimeline, + isAdmin: isAdmin, + memoryEnabled: memoryEnabled, + name: name, + profileImagePath: profileImagePath, + quotaSizeInBytes: quotaSizeInBytes, + quotaUsageInBytes: quotaUsageInBytes, + updatedAt: updatedAt, + ); + } +} diff --git a/mobile-v2/lib/domain/services/app_setting.service.dart b/mobile-v2/lib/domain/services/app_setting.service.dart index e73120c1c1..2213d1cde9 100644 --- a/mobile-v2/lib/domain/services/app_setting.service.dart +++ b/mobile-v2/lib/domain/services/app_setting.service.dart @@ -1,17 +1,18 @@ +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; import 'package:immich_mobile/domain/models/app_setting.model.dart'; -import 'package:immich_mobile/domain/store_manager.dart'; class AppSettingService { - final StoreManager store; + final IStoreRepository store; const AppSettingService(this.store); - T getSetting(AppSetting setting) { - return store.get(setting.storeKey, setting.defaultValue); + Future getSetting(AppSetting setting) async { + final value = await store.tryGet(setting.storeKey); + return value ?? setting.defaultValue; } Future setSetting(AppSetting setting, T value) async { - return await store.put(setting.storeKey, value); + return await store.set(setting.storeKey, value); } Stream watchSetting(AppSetting setting) { diff --git a/mobile-v2/lib/domain/services/login.service.dart b/mobile-v2/lib/domain/services/login.service.dart index 3d6476603b..b419c209ab 100644 --- a/mobile-v2/lib/domain/services/login.service.dart +++ b/mobile-v2/lib/domain/services/login.service.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'package:dio/dio.dart'; +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'package:immich_mobile/service_locator.dart'; import 'package:immich_mobile/utils/mixins/log_context.mixin.dart'; import 'package:openapi/openapi.dart'; @@ -52,4 +54,58 @@ class LoginService with LogContext { // No well-known, return the baseUrl return baseUrl; } + + Future passwordLogin(String email, String password) async { + try { + final loginResponse = await di().getAuthenticationApi().login( + loginCredentialDto: LoginCredentialDto((builder) { + builder.email = email; + builder.password = password; + }), + ); + + return loginResponse.data?.accessToken; + } catch (e, s) { + log.severe("Exception occured while performing password login", e, s); + } + return null; + } + + Future oAuthLogin() async { + const String oAuthCallbackSchema = 'app.immich'; + + final oAuthApi = di().getOAuthApi(); + + try { + final oAuthUrl = await oAuthApi.startOAuth( + oAuthConfigDto: OAuthConfigDto((builder) { + builder.redirectUri = "$oAuthCallbackSchema:/"; + }), + ); + + final oAuthUrlRes = oAuthUrl.data?.url; + if (oAuthUrlRes == null) { + log.severe( + "oAuth Server URL not available. Kindly ensure oAuth login is enabled in the server", + ); + return null; + } + + final oAuthCallbackUrl = await FlutterWebAuth2.authenticate( + url: oAuthUrlRes, + callbackUrlScheme: oAuthCallbackSchema, + ); + + final loginResponse = await oAuthApi.finishOAuth( + oAuthCallbackDto: OAuthCallbackDto((builder) { + builder.url = oAuthCallbackUrl; + }), + ); + + return loginResponse.data?.accessToken; + } catch (e) { + log.severe("Exception occured while performing oauth login", e); + } + return null; + } } diff --git a/mobile-v2/lib/domain/services/user.service.dart b/mobile-v2/lib/domain/services/user.service.dart new file mode 100644 index 0000000000..799c5e38a7 --- /dev/null +++ b/mobile-v2/lib/domain/services/user.service.dart @@ -0,0 +1,28 @@ +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/utils/mixins/log_context.mixin.dart'; +import 'package:openapi/openapi.dart'; + +class UserService with LogContext { + final Openapi _api; + + UsersApi get _userApi => _api.getUsersApi(); + + UserService(this._api); + + Future getMyUser() async { + try { + final response = await _userApi.getMyUser(); + final dto = response.data; + if (dto == null) { + log.severe("Cannot fetch my user."); + return null; + } + + final preferences = await _userApi.getMyPreferences(); + return User.fromAdminDto(dto, preferences.data); + } catch (e, s) { + log.severe("Error while fetching server features", e, s); + } + return null; + } +} diff --git a/mobile-v2/lib/domain/store_manager.dart b/mobile-v2/lib/domain/store_manager.dart deleted file mode 100644 index 1671623737..0000000000 --- a/mobile-v2/lib/domain/store_manager.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'dart:async'; - -import 'package:immich_mobile/domain/interfaces/store.interface.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/service_locator.dart'; -import 'package:immich_mobile/utils/mixins/log_context.mixin.dart'; - -class StoreKeyNotFoundException implements Exception { - final StoreKey key; - const StoreKeyNotFoundException(this.key); - - @override - String toString() => "Key '${key.name}' not found in Store"; -} - -/// Key-value cache for individual items enumerated in StoreKey. -class StoreManager with LogContext { - late final IStoreRepository _db; - late final StreamSubscription _subscription; - final Map _cache = {}; - - StoreManager(IStoreRepository db) { - _db = db; - _subscription = _db.watchStore().listen(_onChangeListener); - _populateCache(); - } - - void dispose() { - _subscription.cancel(); - } - - FutureOr _populateCache() async { - for (StoreKey key in StoreKey.values) { - final value = await _db.getValue(key); - if (value != null) { - _cache[key.id] = value; - } - } - - /// Signal ready once the cache is populated - di.signalReady(this); - } - - /// clears all values from this store (cache and DB), only for testing! - Future clear() async { - _cache.clear(); - return await _db.clearStore(); - } - - /// Returns the stored value for the given key (possibly null) - T? tryGet(StoreKey key) => _cache[key.id] as T?; - - /// Returns the stored value for the given key or if null the [defaultValue] - /// Throws a [StoreKeyNotFoundException] if both are null - T get(StoreKey key, [T? defaultValue]) { - final value = _cache[key.id] ?? defaultValue; - if (value == null) { - throw StoreKeyNotFoundException(key); - } - return value; - } - - /// Watches a specific key for changes - Stream watch(StoreKey key) => _db.watchValue(key); - - /// Stores the value synchronously in the cache and asynchronously in the DB - FutureOr put(StoreKey key, T value) async { - if (_cache[key.id] == value) return Future.value(true); - _cache[key.id] = value; - return await _db.setValue(key, value); - } - - /// Removes the value synchronously from the cache and asynchronously from the DB - Future delete(StoreKey key) async { - if (_cache[key.id] == null) return Future.value(); - _cache.remove(key.id); - return await _db.deleteValue(key); - } - - /// Updates the state in cache if a value is updated in any isolate - void _onChangeListener(List? data) { - if (data != null) { - for (StoreValue storeValue in data) { - if (storeValue.value != null) { - _cache[storeValue.id] = storeValue.value; - } - } - } - } -} diff --git a/mobile-v2/lib/domain/utils/store_converters.dart b/mobile-v2/lib/domain/utils/store_converters.dart index 29d151553e..29ef7e8b26 100644 --- a/mobile-v2/lib/domain/utils/store_converters.dart +++ b/mobile-v2/lib/domain/utils/store_converters.dart @@ -1,4 +1,9 @@ +import 'dart:async'; + import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/interfaces/user.interface.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/service_locator.dart'; class StoreEnumConverter extends IStoreConverter { const StoreEnumConverter(this.values); @@ -22,8 +27,8 @@ class StoreBooleanConverter extends IStoreConverter { int toPrimitive(bool value) => value ? 1 : 0; } -class StorePrimitiveConverter extends IStoreConverter { - const StorePrimitiveConverter(); +class _StorePrimitiveConverter extends IStoreConverter { + const _StorePrimitiveConverter(); @override T fromPrimitive(T value) => value; @@ -31,3 +36,23 @@ class StorePrimitiveConverter extends IStoreConverter { @override T toPrimitive(T value) => value; } + +class StoreStringConverter extends _StorePrimitiveConverter { + const StoreStringConverter(); +} + +class StoreIntConverter extends _StorePrimitiveConverter { + const StoreIntConverter(); +} + +class StoreUserConverter extends IStoreConverter { + const StoreUserConverter(); + + @override + Future fromPrimitive(String value) async { + return await di().getUser(value); + } + + @override + String toPrimitive(User value) => value.id; +} diff --git a/mobile-v2/lib/main.dart b/mobile-v2/lib/main.dart index 886d686945..13cc9c8651 100644 --- a/mobile-v2/lib/main.dart +++ b/mobile-v2/lib/main.dart @@ -2,11 +2,14 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/i18n/strings.g.dart'; import 'package:immich_mobile/immich_app.dart'; import 'package:immich_mobile/service_locator.dart'; +import 'package:immich_mobile/utils/log_manager.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); // DI Injection ServiceLocator.configureServices(); + // Init logging + LogManager.I.init(); // Init localization LocaleSettings.useDeviceLocale(); diff --git a/mobile-v2/lib/presentation/components/common/loading_indaticator.widget.dart b/mobile-v2/lib/presentation/components/common/loading_indicator.widget.dart similarity index 98% rename from mobile-v2/lib/presentation/components/common/loading_indaticator.widget.dart rename to mobile-v2/lib/presentation/components/common/loading_indicator.widget.dart index b1c2756b20..c3f7209e21 100644 --- a/mobile-v2/lib/presentation/components/common/loading_indaticator.widget.dart +++ b/mobile-v2/lib/presentation/components/common/loading_indicator.widget.dart @@ -15,7 +15,7 @@ class ImLoadingIndicator extends StatelessWidget { width: dimension ?? 24, height: dimension ?? 24, child: FittedBox( - child: CircularProgressIndicator(strokeWidth: strokeWidth ?? 2), + child: CircularProgressIndicator(strokeWidth: strokeWidth ?? 4), ), ); } diff --git a/mobile-v2/lib/presentation/components/input/switch_list.widget.dart b/mobile-v2/lib/presentation/components/input/switch_list.widget.dart index 11380d7b8e..e7bfbbe15f 100644 --- a/mobile-v2/lib/presentation/components/input/switch_list.widget.dart +++ b/mobile-v2/lib/presentation/components/input/switch_list.widget.dart @@ -48,8 +48,13 @@ class _ImSwitchListTileState extends State> { @override void initState() { super.initState(); - final value = _appSettingService.getSetting(widget.setting); - isEnabled = T != bool ? widget.fromAppSetting!(value) : value as bool; + _appSettingService.getSetting(widget.setting).then((value) { + if (context.mounted) { + setState(() { + isEnabled = T != bool ? widget.fromAppSetting!(value) : value as bool; + }); + } + }); } @override diff --git a/mobile-v2/lib/presentation/modules/common/states/current_user.state.dart b/mobile-v2/lib/presentation/modules/common/states/current_user.state.dart new file mode 100644 index 0000000000..c04c0969e1 --- /dev/null +++ b/mobile-v2/lib/presentation/modules/common/states/current_user.state.dart @@ -0,0 +1,6 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; + +class CurrentUserCubit extends Cubit { + CurrentUserCubit(super.initialState); +} diff --git a/mobile-v2/lib/presentation/modules/login/models/login_page.model.dart b/mobile-v2/lib/presentation/modules/login/models/login_page.model.dart index 67f7ba94a6..ef51b23806 100644 --- a/mobile-v2/lib/presentation/modules/login/models/login_page.model.dart +++ b/mobile-v2/lib/presentation/modules/login/models/login_page.model.dart @@ -4,43 +4,51 @@ import 'package:flutter/material.dart'; class LoginPageState { final bool isServerValidated; final bool isValidationInProgress; + final bool isLoginSuccessful; const LoginPageState({ required this.isServerValidated, required this.isValidationInProgress, + required this.isLoginSuccessful, }); factory LoginPageState.reset() { return const LoginPageState( isServerValidated: false, isValidationInProgress: false, + isLoginSuccessful: false, ); } LoginPageState copyWith({ bool? isServerValidated, bool? isValidationInProgress, + bool? isLoginSuccessful, }) { return LoginPageState( isServerValidated: isServerValidated ?? this.isServerValidated, isValidationInProgress: isValidationInProgress ?? this.isValidationInProgress, + isLoginSuccessful: isLoginSuccessful ?? this.isLoginSuccessful, ); } @override String toString() => - 'LoginPageState(isServerValidated: $isServerValidated, isValidationInProgress: $isValidationInProgress)'; + 'LoginPageState(isServerValidated: $isServerValidated, isValidationInProgress: $isValidationInProgress, isLoginSuccessful: $isLoginSuccessful)'; @override bool operator ==(covariant LoginPageState other) { if (identical(this, other)) return true; return other.isServerValidated == isServerValidated && - other.isValidationInProgress == isValidationInProgress; + other.isValidationInProgress == isValidationInProgress && + other.isLoginSuccessful == isLoginSuccessful; } @override int get hashCode => - isServerValidated.hashCode ^ isValidationInProgress.hashCode; + isServerValidated.hashCode ^ + isValidationInProgress.hashCode ^ + isLoginSuccessful.hashCode; } diff --git a/mobile-v2/lib/presentation/modules/login/pages/login.page.dart b/mobile-v2/lib/presentation/modules/login/pages/login.page.dart index 1fd23f7891..4d04b83eaf 100644 --- a/mobile-v2/lib/presentation/modules/login/pages/login.page.dart +++ b/mobile-v2/lib/presentation/modules/login/pages/login.page.dart @@ -152,13 +152,20 @@ class _LoginPageState extends State ); } - return Scaffold( - resizeToAvoidBottomInset: false, - appBar: appBar, - body: SafeArea( - child: ImAdaptiveScaffoldBody( - primaryBody: (_) => primaryBody, - secondaryBody: (_) => secondaryBody, + return BlocListener( + listener: (_, loginState) { + if (loginState.isLoginSuccessful) { + context.replaceRoute(const TabControllerRoute()); + } + }, + child: Scaffold( + resizeToAvoidBottomInset: false, + appBar: appBar, + body: SafeArea( + child: ImAdaptiveScaffoldBody( + primaryBody: (_) => primaryBody, + secondaryBody: (_) => secondaryBody, + ), ), ), ); diff --git a/mobile-v2/lib/presentation/modules/login/states/login_page.state.dart b/mobile-v2/lib/presentation/modules/login/states/login_page.state.dart index cf5f324313..518ff96b35 100644 --- a/mobile-v2/lib/presentation/modules/login/states/login_page.state.dart +++ b/mobile-v2/lib/presentation/modules/login/states/login_page.state.dart @@ -1,15 +1,20 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/interfaces/user.interface.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/login.service.dart'; -import 'package:immich_mobile/domain/store_manager.dart'; +import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/i18n/strings.g.dart'; import 'package:immich_mobile/presentation/modules/common/states/server_info/server_feature_config.state.dart'; import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart'; import 'package:immich_mobile/service_locator.dart'; +import 'package:immich_mobile/utils/immich_auth_interceptor.dart'; import 'package:immich_mobile/utils/mixins/log_context.mixin.dart'; import 'package:immich_mobile/utils/snackbar_manager.dart'; +import 'package:openapi/openapi.dart'; class LoginPageCubit extends Cubit with LogContext { LoginPageCubit() : super(LoginPageState.reset()); @@ -60,8 +65,8 @@ class LoginPageCubit extends Cubit with LogContext { // Check for /.well-known/immich url = await loginService.resolveEndpoint(uri); - di().put(StoreKey.serverEndpoint, url); - ServiceLocator.registerPostValidationServices(url); + di().set(StoreKey.serverEndpoint, url); + await ServiceLocator.registerPostValidationServices(url); // Fetch server features await di().getFeatures(); @@ -76,15 +81,64 @@ class LoginPageCubit extends Cubit with LogContext { required String email, required String password, }) async { - emit(state.copyWith(isValidationInProgress: true)); + try { + emit(state.copyWith(isValidationInProgress: true)); + final accessToken = + await di().passwordLogin(email, password); - final url = di().get(StoreKey.serverEndpoint); + if (accessToken == null) { + SnackbarManager.showError(t.login.error.error_login); + return; + } + + await _postLogin(accessToken); + } finally { + emit(state.copyWith(isValidationInProgress: false)); + } } Future oAuthLogin() async { - emit(state.copyWith(isValidationInProgress: true)); + try { + emit(state.copyWith(isValidationInProgress: true)); - final url = di().get(StoreKey.serverEndpoint); + final accessToken = await di().oAuthLogin(); + + if (accessToken == null) { + SnackbarManager.showError(t.login.error.error_login_oauth); + return; + } + + await _postLogin(accessToken); + } finally { + emit(state.copyWith(isValidationInProgress: false)); + } + } + + Future _postLogin(String accessToken) async { + await di().set(StoreKey.accessToken, accessToken); + + /// Set token to interceptor + final interceptor = di() + .dio + .interceptors + .firstWhereOrNull((i) => i is ImmichAuthInterceptor) + as ImmichAuthInterceptor?; + interceptor?.setAccessToken(accessToken); + + final user = await di().getMyUser(); + if (user == null) { + SnackbarManager.showError(t.login.error.error_login); + return; + } + + // Register user + ServiceLocator.registerCurrentUser(user); + await di().insertUser(user); + + emit(state.copyWith( + isValidationInProgress: false, + isServerValidated: true, + )); } void resetServerValidation() { diff --git a/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart b/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart index dd3efc6092..93094bece8 100644 --- a/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart +++ b/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart @@ -5,7 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:immich_mobile/domain/models/server-info/server_feature_config.model.dart'; import 'package:immich_mobile/i18n/strings.g.dart'; import 'package:immich_mobile/presentation/components/common/gap.widget.dart'; -import 'package:immich_mobile/presentation/components/common/loading_indaticator.widget.dart'; +import 'package:immich_mobile/presentation/components/common/loading_indicator.widget.dart'; import 'package:immich_mobile/presentation/components/input/filled_button.widget.dart'; import 'package:immich_mobile/presentation/components/input/password_form_field.widget.dart'; import 'package:immich_mobile/presentation/components/input/text_button.widget.dart'; @@ -132,6 +132,7 @@ class _CredentialsPageState extends State<_CredentialsPage> { children: [ if (state.features.hasPasswordLogin) ...[ ImTextFormField( + controller: widget.emailController, label: context.t.login.label.email, isDisabled: isValidationInProgress, textInputAction: TextInputAction.next, @@ -139,6 +140,7 @@ class _CredentialsPageState extends State<_CredentialsPage> { ), const SizedGap.mh(), ImPasswordFormField( + controller: widget.passwordController, label: context.t.login.label.password, focusNode: passwordFocusNode, isDisabled: isValidationInProgress, @@ -148,11 +150,12 @@ class _CredentialsPageState extends State<_CredentialsPage> { ImFilledButton( label: context.t.login.label.login_button, icon: Symbols.login_rounded, - onPressed: () => - context.read().passwordLogin( - email: widget.emailController.text, - password: widget.passwordController.text, - ), + onPressed: () => unawaited( + context.read().passwordLogin( + email: widget.emailController.text, + password: widget.passwordController.text, + ), + ), ), // Divider when both password and oAuth login is enabled if (state.features.hasOAuthLogin) const Divider(), diff --git a/mobile-v2/lib/service_locator.dart b/mobile-v2/lib/service_locator.dart index 2b7ffcd1b9..e70571d0e1 100644 --- a/mobile-v2/lib/service_locator.dart +++ b/mobile-v2/lib/service_locator.dart @@ -1,16 +1,25 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:dio/dio.dart'; import 'package:get_it/get_it.dart'; import 'package:immich_mobile/domain/interfaces/log.interface.dart'; import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/interfaces/user.interface.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/repositories/database.repository.dart'; import 'package:immich_mobile/domain/repositories/log.repository.dart'; import 'package:immich_mobile/domain/repositories/store.repository.dart'; +import 'package:immich_mobile/domain/repositories/user.repository.dart'; import 'package:immich_mobile/domain/services/app_setting.service.dart'; import 'package:immich_mobile/domain/services/login.service.dart'; import 'package:immich_mobile/domain/services/server_info.service.dart'; -import 'package:immich_mobile/domain/store_manager.dart'; +import 'package:immich_mobile/presentation/modules/common/states/current_user.state.dart'; import 'package:immich_mobile/presentation/modules/common/states/server_info/server_feature_config.state.dart'; import 'package:immich_mobile/presentation/modules/theme/states/app_theme.state.dart'; import 'package:immich_mobile/presentation/router/router.dart'; +import 'package:immich_mobile/utils/immich_auth_interceptor.dart'; import 'package:openapi/openapi.dart'; final di = GetIt.I; @@ -29,12 +38,12 @@ class ServiceLocator { // Init store di.registerFactory(() => StoreDriftRepository(di())); - // StoreManager populates its cache with a async gap, manually signalReady once the cache is populated. - di.registerSingleton(StoreManager(di()), signalsReady: true); // Logs di.registerFactory(() => LogDriftRepository(di())); // App Settings di.registerFactory(() => AppSettingService(di())); + // User Repo + di.registerFactory(() => UserDriftRepository(di())); // Login Service di.registerFactory(() => const LoginService()); @@ -46,17 +55,35 @@ class ServiceLocator { di.registerLazySingleton(() => AppThemeCubit(di())); } - static void registerPostValidationServices(String endpoint) { + static FutureOr registerPostValidationServices(String endpoint) async { if (di.isRegistered()) { return; } + final deviceInfo = DeviceInfoPlugin(); + final String deviceModel; + if (Platform.isIOS) { + deviceModel = (await deviceInfo.iosInfo).utsname.machine; + } else { + deviceModel = (await deviceInfo.androidInfo).model; + } + // ====== DOMAIN di.registerSingleton( Openapi( - basePathOverride: endpoint, - interceptors: [BearerAuthInterceptor()], + dio: Dio( + BaseOptions( + baseUrl: endpoint, + connectTimeout: const Duration(milliseconds: 5000), + receiveTimeout: const Duration(milliseconds: 3000), + headers: { + 'deviceModel': deviceModel, + 'deviceType': Platform.operatingSystem, + }, + ), + ), + interceptors: [ImmichAuthInterceptor()], ), ); di.registerFactory(() => ServerInfoService(di())); @@ -67,4 +94,8 @@ class ServiceLocator { () => ServerFeatureConfigCubit(di()), ); } + + static void registerCurrentUser(User user) { + di.registerSingleton(CurrentUserCubit(user)); + } } diff --git a/mobile-v2/lib/utils/constants/assets.gen.dart b/mobile-v2/lib/utils/constants/assets.gen.dart index 4899ed03ae..83cc4fdb00 100644 --- a/mobile-v2/lib/utils/constants/assets.gen.dart +++ b/mobile-v2/lib/utils/constants/assets.gen.dart @@ -36,11 +36,16 @@ class Assets { } class AssetGenImage { - const AssetGenImage(this._assetName, {this.size = null}); + const AssetGenImage( + this._assetName, { + this.size, + this.flavors = const {}, + }); final String _assetName; final Size? size; + final Set flavors; Image image({ Key? key, diff --git a/mobile-v2/lib/utils/constants/globals.dart b/mobile-v2/lib/utils/constants/globals.dart index bedd6cdd49..29799ce86f 100644 --- a/mobile-v2/lib/utils/constants/globals.dart +++ b/mobile-v2/lib/utils/constants/globals.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +/// Log messages stored in the DB +const int kLogMessageLimit = 500; + /// Global ScaffoldMessengerKey to show snackbars final GlobalKey kScafMessengerKey = GlobalKey(); diff --git a/mobile-v2/lib/utils/immich_auth_interceptor.dart b/mobile-v2/lib/utils/immich_auth_interceptor.dart new file mode 100644 index 0000000000..81d8da2959 --- /dev/null +++ b/mobile-v2/lib/utils/immich_auth_interceptor.dart @@ -0,0 +1,29 @@ +import 'package:dio/dio.dart'; +import 'package:immich_mobile/presentation/router/router.dart'; +import 'package:immich_mobile/service_locator.dart'; +import 'package:immich_mobile/utils/mixins/log_context.mixin.dart'; + +class ImmichAuthInterceptor extends Interceptor with LogContext { + String? _accessToken; + + void setAccessToken(String token) => _accessToken = token; + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + if (_accessToken != null) { + options.headers["x-immich-user-token"] = _accessToken; + } + + handler.next(options); + } + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + if (response.statusCode == 401) { + log.severe("Token expired. Logging user out"); + di().replaceAll([const LoginRoute()]); + return; + } + handler.next(response); + } +} diff --git a/mobile-v2/lib/utils/log_manager.dart b/mobile-v2/lib/utils/log_manager.dart new file mode 100644 index 0000000000..f907c85e8b --- /dev/null +++ b/mobile-v2/lib/utils/log_manager.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/domain/interfaces/log.interface.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/service_locator.dart'; +import 'package:logging/logging.dart'; + +/// [LogManager] is a custom logger that is built on top of the [logging] package. +/// The logs are written to the database and onto console, using `debugPrint` method. +/// +/// The logs are deleted when exceeding the `maxLogEntries` (default 500) property +/// in the class. +class LogManager { + LogManager._(); + static final LogManager _instance = LogManager._(); + + // ignore: match-getter-setter-field-names + static LogManager get I => _instance; + + List _msgBuffer = []; + Timer? _timer; + late StreamSubscription _subscription; + + void _onLogRecord(LogRecord record) { + // Only print in development + assert(() { + debugPrint('[${record.level.name}] [${record.time}] ${record.message}'); + if (record.error != null && record.stackTrace != null) { + debugPrint('${record.error}'); + } + return true; + }()); + + final lm = LogMessage( + logger: record.loggerName, + content: record.message, + level: record.level.toLogLevel(), + createdAt: record.time, + error: record.error?.toString(), + stack: record.stackTrace?.toString(), + ); + _msgBuffer.add(lm); + + // delayed batch writing to database: increases performance when logging + // messages in quick succession and reduces NAND wear + _timer ??= Timer(const Duration(seconds: 5), _flushBufferToDatabase); + } + + void _flushBufferToDatabase() { + _timer = null; + final buffer = _msgBuffer; + _msgBuffer = []; + di().addAll(buffer); + } + + void init() { + _subscription = Logger.root.onRecord.listen(_onLogRecord); + } + + void updateLevel(LogLevel level) { + Logger.root.level = Level.LEVELS.elementAtOrNull(level.index); + } + + void dispose() { + _subscription.cancel(); + } + + void clearLogs() { + _timer?.cancel(); + _timer = null; + _msgBuffer.clear(); + di().clear(); + } +} diff --git a/mobile-v2/lib/utils/mixins/log_context.mixin.dart b/mobile-v2/lib/utils/mixins/log_context.mixin.dart index a15ff6633a..7c3b7d8ea0 100644 --- a/mobile-v2/lib/utils/mixins/log_context.mixin.dart +++ b/mobile-v2/lib/utils/mixins/log_context.mixin.dart @@ -4,5 +4,5 @@ import 'package:logging/logging.dart'; mixin LogContext { @protected @nonVirtual - Logger get log => Logger.detached(runtimeType.toString()); + Logger get log => Logger(runtimeType.toString()); } diff --git a/mobile-v2/pubspec.lock b/mobile-v2/pubspec.lock index f3cf6ceb1b..500bbb6970 100644 --- a/mobile-v2/pubspec.lock +++ b/mobile-v2/pubspec.lock @@ -45,18 +45,18 @@ packages: dependency: "direct main" description: name: auto_route - sha256: "878186aae276296bf1cfc0a02cd2788cfb473eb622e0f5e4293f40ecdf86d80d" + sha256: a9001a90539ca3effc168f7e1029a5885c7326b9032c09ac895e303c1d137704 url: "https://pub.dev" source: hosted - version: "8.2.0" + version: "8.3.0" auto_route_generator: dependency: "direct dev" description: name: auto_route_generator - sha256: ba28133d3a3bf0a66772bcc98dade5843753cd9f1a8fb4802b842895515b67d3 + sha256: a21d7a936c917488653c972f62d884d8adcf8c5d37acc7cd24da33cf784546c0 url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "8.1.0" bloc: dependency: transitive description: @@ -241,30 +241,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: eead12d1a1ed83d8283ab4c2f3fca23ac4082f29f25f29dff0f758f57d06ec91 + url: "https://pub.dev" + source: hosted + version: "10.1.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64 + url: "https://pub.dev" + source: hosted + version: "7.0.0" dio: dependency: "direct main" description: name: dio - sha256: "11e40df547d418cc0c4900a9318b26304e665da6fa4755399a9ff9efd09034b5" + sha256: e17f6b3097b8c51b72c74c9f071a605c47bcc8893839bd66732457a5ebe73714 url: "https://pub.dev" source: hosted - version: "5.4.3+1" + version: "5.5.0+1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "36c5b2d79eb17cdae41e974b7a8284fec631651d2a6f39a8a2ff22327e90aeac" + url: "https://pub.dev" + source: hosted + version: "1.0.1" drift: dependency: "direct main" description: name: drift - sha256: "6acedc562ffeed308049f78fb1906abad3d65714580b6745441ee6d50ec564cd" + sha256: "4e0ffee40d23f0b809e6cff1ad202886f51d629649073ed42d9cd1d194ea943e" url: "https://pub.dev" source: hosted - version: "2.18.0" + version: "2.19.1+1" drift_dev: dependency: "direct dev" description: name: drift_dev - sha256: d9b020736ea85fff1568699ce18b89fabb3f0f042e8a7a05e84a3ec20d39acde + sha256: ac7647c6cedca99724ca300cff9181f6dd799428f8ed71f94159ed0528eaec26 url: "https://pub.dev" source: hosted - version: "2.18.0" + version: "2.19.1" dynamic_color: dependency: "direct main" description: @@ -314,10 +338,10 @@ packages: dependency: "direct main" description: name: flutter_adaptive_scaffold - sha256: "794791e6fc0cc23e375d07ea987e76cf7f0eb7cd753532e45dc35b7e90575e2d" + sha256: "56d4d81fe88ecffe8ae96b8d89a1ae793c0a85035bb9b74ff28f20eea0cdbdc2" url: "https://pub.dev" source: hosted - version: "0.1.11" + version: "0.1.11+1" flutter_bloc: dependency: "direct main" description: @@ -330,26 +354,26 @@ packages: dependency: transitive description: name: flutter_gen_core - sha256: b9894396b2a790cc2d6eb3ed86e5e113aaed993765b21d4b981c9da4476e0f52 + sha256: d8e828ad015a8511624491b78ad8e3f86edb7993528b1613aefbb4ad95947795 url: "https://pub.dev" source: hosted - version: "5.5.0+1" + version: "5.6.0" flutter_gen_runner: dependency: "direct dev" description: name: flutter_gen_runner - sha256: b4c4c54e4dd89022f5e405fe96f16781be2dfbeabe8a70ccdf73b7af1302c655 + sha256: "931b03f77c164df0a4815aac0efc619a6ac8ec4cada55025119fca4894dada90" url: "https://pub.dev" source: hosted - version: "5.5.0+1" + version: "5.6.0" flutter_lints: dependency: "direct dev" description: name: flutter_lints - sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "4.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -360,6 +384,22 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_auth_2: + dependency: "direct main" + description: + name: flutter_web_auth_2 + sha256: "4d3d2fd3d26bf1a26b3beafd4b4b899c0ffe10dc99af25abc58ffe24e991133c" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_web_auth_2_platform_interface: + dependency: transitive + description: + name: flutter_web_auth_2_platform_interface + sha256: e8669e262005a8354389ba2971f0fc1c36188481234ff50d013aaf993f30f739 + url: "https://pub.dev" + source: hosted + version: "3.1.0" flutter_web_plugins: dependency: transitive description: flutter @@ -393,10 +433,10 @@ packages: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" hashcodes: dependency: transitive description: @@ -409,10 +449,10 @@ packages: dependency: transitive description: name: http - sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" http_multi_server: dependency: transitive description: @@ -505,10 +545,10 @@ packages: dependency: transitive description: name: lints - sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "4.0.0" logging: dependency: "direct main" description: @@ -537,10 +577,10 @@ packages: dependency: "direct main" description: name: material_symbols_icons - sha256: a2c78726048c755f0f90fd2b7c8799cd94338e2e9b7ab6498ae56503262c14bc + sha256: "37f88057af06224cd99242bd9b5ceda8c1ebddfff67bd5e8432521910a3d4598" url: "https://pub.dev" source: hosted - version: "4.2762.0" + version: "4.2771.0" meta: dependency: transitive description: @@ -640,10 +680,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "9c96da072b421e98183f9ea7464898428e764bc0ce5567f27ec8693442e72514" + sha256: "30c5aa827a6ae95ce2853cdc5fe3971daaac00f6f081c419c013f7f57bff2f5e" url: "https://pub.dev" source: hosted - version: "2.2.5" + version: "2.2.7" path_provider_foundation: dependency: transitive description: @@ -672,10 +712,10 @@ packages: dependency: transitive description: name: path_provider_windows - sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" petitparser: dependency: transitive description: @@ -688,10 +728,10 @@ packages: dependency: "direct main" description: name: photo_manager - sha256: "68d6099d07ce5033170f8368af8128a4555cf1d590a97242f83669552de989b1" + sha256: "2f98fed8fede27eaf55021a1ce382609a715b52096a94a315f99ae33b6d2eaab" url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "3.2.2" photo_manager_image_provider: dependency: "direct main" description: @@ -789,10 +829,10 @@ packages: dependency: "direct main" description: name: slang - sha256: "0d8a8cbfd7858ed2bd9164a79bfb664ea83f1e124740b28acd0618757fc87ecc" + sha256: f68f6d6709890f85efabfb0318e9d694be2ebdd333e57fe5cb50eee449e4e3ab url: "https://pub.dev" source: hosted - version: "3.31.0" + version: "3.31.1" slang_build_runner: dependency: "direct dev" description: @@ -829,26 +869,26 @@ packages: dependency: "direct main" description: name: sqlite3 - sha256: b384f598b813b347c5a7e5ffad82cbaff1bec3d1561af267041e66f6f0899295 + sha256: "6d17989c0b06a5870b2190d391925186f944cb943e5262d0d3f778fcfca3bc6e" url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "2.4.4" sqlite3_flutter_libs: dependency: "direct main" description: name: sqlite3_flutter_libs - sha256: "9f89a7e7dc36eac2035808427eba1c3fbd79e59c3a22093d8dace6d36b1fe89e" + sha256: "62bbb4073edbcdf53f40c80775f33eea01d301b7b81417e5b3fb7395416258c1" url: "https://pub.dev" source: hosted - version: "0.5.23" + version: "0.5.24" sqlparser: dependency: transitive description: name: sqlparser - sha256: ade9a67fd70d0369329ed3373208de7ebd8662470e8c396fc8d0d60f9acdfc9f + sha256: "3be52b4968fc2f098ba735863404756d2fe3ea0729cf006a5b5612618f74ca04" url: "https://pub.dev" source: hosted - version: "0.36.0" + version: "0.37.1" stack_trace: dependency: transitive description: @@ -933,18 +973,18 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: ceb2625f0c24ade6ef6778d1de0b2e44f2db71fded235eb52295247feba8c5cf + sha256: "95d8027db36a0e52caf55680f91e33ea6aa12a3ce608c90b06f4e429a21067ac" url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.3.5" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7068716403343f6ba4969b4173cbf3b84fc768042124bc2c011e5d782b24fe89" + sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.3.1" url_launcher_linux: dependency: transitive description: @@ -981,10 +1021,10 @@ packages: dependency: transitive description: name: url_launcher_windows - sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + sha256: "49c10f879746271804767cb45551ec5592cdab00ee105c06dddde1a98f73b185" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" vector_graphics_codec: dependency: transitive description: @@ -1037,18 +1077,18 @@ packages: dependency: transitive description: name: web_socket - sha256: "24301d8c293ce6fe327ffe6f59d8fd8834735f0ec36e4fd383ec7ff8a64aa078" + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" url: "https://pub.dev" source: hosted - version: "0.1.5" + version: "0.1.6" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: a2d56211ee4d35d9b344d9d4ce60f362e4f5d1aafb988302906bd732bc731276 + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.0.1" win32: dependency: transitive description: @@ -1057,6 +1097,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.5.1" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "10589e0d7f4e053f2c61023a31c9ce01146656a70b7b7f0828c0b46d7da2a9bb" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + window_to_front: + dependency: transitive + description: + name: window_to_front + sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee" + url: "https://pub.dev" + source: hosted + version: "0.0.3" xdg_directories: dependency: transitive description: diff --git a/mobile-v2/pubspec.yaml b/mobile-v2/pubspec.yaml index 302b3e1c35..20c93cd6d0 100644 --- a/mobile-v2/pubspec.yaml +++ b/mobile-v2/pubspec.yaml @@ -48,6 +48,9 @@ dependencies: url_launcher: ^6.3.0 # plus_extensions package_info_plus: ^8.0.0 + device_info_plus: ^10.1.0 + # oauth login + flutter_web_auth_2: ^3.1.2 openapi: path: openapi @@ -57,7 +60,7 @@ dev_dependencies: sdk: flutter # Recommended lints - flutter_lints: ^3.0.0 + flutter_lints: ^4.0.0 # Code generator build_runner: ^2.4.9 # Database helper @@ -67,7 +70,7 @@ dev_dependencies: # Localization generator slang_build_runner: ^3.30.0 # Assets constant generator - flutter_gen_runner: 5.5.0+1 + flutter_gen_runner: ^5.6.0 flutter: uses-material-design: true