1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(mobile) - Add better offline support (#3279)

* WIP: Adding init support for offline-loading

* WIP: found bug and fixed with offline browing adv setting

* WIP: big some bugs with first login

* WIP: static analysis fixes

* PR: Removed setting for offline browing

* PR: static analysis - remove imports

* PR: Refactored user login state

* PR: changed logger log level as it happens a lot

* PR: change log var to _log

* PR: addressing comments

* WIP: bug fixes

* WIP: static analysis on the logger variable
This commit is contained in:
Dhrumil Shah 2023-07-27 20:05:27 -07:00 committed by GitHub
parent afb0d0f54d
commit fe9ef1a3ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 126 additions and 32 deletions

View File

@ -37,6 +37,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
final ApiService _apiService;
final Isar _db;
final _log = Logger("AuthenticationNotifier");
Future<bool> login(
String email,
@ -145,38 +146,66 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Future<bool> setSuccessLoginInfo({
required String accessToken,
required String serverUrl,
bool offlineLogin = false,
}) async {
_apiService.setAccessToken(accessToken);
UserResponseDto? userResponseDto;
try {
userResponseDto = await _apiService.userApi.getMyUserInfo();
} on ApiException catch (e) {
if (e.innerException is SocketException) {
state = state.copyWith(isAuthenticated: true);
// Get the deviceid from the store if it exists, otherwise generate a new one
String deviceId =
Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
bool shouldChangePassword = false;
User? user;
bool retResult = false;
User? offlineUser = Store.tryGet(StoreKey.currentUser);
// If the user is offline and there is a user saved on the device,
// if not try an online login
if (offlineLogin && offlineUser != null) {
user = offlineUser;
retResult = false;
} else {
UserResponseDto? userResponseDto;
try {
userResponseDto = await _apiService.userApi.getMyUserInfo();
} on ApiException catch (e) {
if (e.innerException is SocketException) {
state = state.copyWith(isAuthenticated: true);
}
}
if (userResponseDto != null) {
Store.put(StoreKey.deviceId, deviceId);
Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
Store.put(StoreKey.serverUrl, serverUrl);
Store.put(StoreKey.accessToken, accessToken);
shouldChangePassword = userResponseDto.shouldChangePassword;
user = User.fromDto(userResponseDto);
retResult = true;
}
else {
_log.severe("Unable to get user information from the server.");
return false;
}
}
if (userResponseDto != null) {
final deviceId = await FlutterUdid.consistentUdid;
Store.put(StoreKey.deviceId, deviceId);
Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
Store.put(StoreKey.serverUrl, serverUrl);
Store.put(StoreKey.accessToken, accessToken);
state = state.copyWith(
isAuthenticated: true,
userId: user.id,
userEmail: user.email,
firstName: user.firstName,
lastName: user.lastName,
profileImagePath: user.profileImagePath,
isAdmin: user.isAdmin,
shouldChangePassword: shouldChangePassword,
deviceId: deviceId,
);
state = state.copyWith(
isAuthenticated: true,
userId: userResponseDto.id,
userEmail: userResponseDto.email,
firstName: userResponseDto.firstName,
lastName: userResponseDto.lastName,
profileImagePath: userResponseDto.profileImagePath,
isAdmin: userResponseDto.isAdmin,
shouldChangePassword: userResponseDto.shouldChangePassword,
deviceId: deviceId,
);
}
return true;
return retResult;
}
}

View File

@ -4,29 +4,40 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
class AuthGuard extends AutoRouteGuard {
final ApiService _apiService;
final _log = Logger("AuthGuard");
AuthGuard(this._apiService);
@override
void onNavigation(NavigationResolver resolver, StackRouter router) async {
resolver.next(true);
try {
var res = await _apiService.authenticationApi.validateAccessToken();
if (res != null && res.authStatus) {
resolver.next(true);
} else {
if (res == null || res.authStatus != true) {
// If the access token is invalid, take user back to login
_log.fine("User token is invalid. Redirecting to login");
router.replaceAll([const LoginRoute()]);
}
} on ApiException catch (e) {
if (e.code == HttpStatus.badRequest &&
e.innerException is SocketException) {
// offline?
resolver.next(true);
_log.fine(
"Unable to validate user token. User may be offline and offline browsing is allowed.",
);
} else {
debugPrint("Error [onNavigation] ${e.toString()}");
router.replaceAll([const LoginRoute()]);
return;
}
} catch (e) {
debugPrint("Error [onNavigation] ${e.toString()}");
router.replaceAll([const LoginRoute()]);
return;
}
}

View File

@ -16,6 +16,7 @@ class User {
required this.isAdmin,
this.isPartnerSharedBy = false,
this.isPartnerSharedWith = false,
this.profileImagePath = '',
});
Id get isarId => fastHash(id);
@ -28,6 +29,7 @@ class User {
lastName = dto.lastName,
isPartnerSharedBy = false,
isPartnerSharedWith = false,
profileImagePath = dto.profileImagePath,
isAdmin = dto.isAdmin;
@Index(unique: true, replace: false, type: IndexType.hash)
@ -39,6 +41,7 @@ class User {
bool isPartnerSharedBy;
bool isPartnerSharedWith;
bool isAdmin;
String profileImagePath;
@Backlink(to: 'owner')
final IsarLinks<Album> albums = IsarLinks<Album>();
@Backlink(to: 'sharedUsers')
@ -54,6 +57,7 @@ class User {
lastName == other.lastName &&
isPartnerSharedBy == other.isPartnerSharedBy &&
isPartnerSharedWith == other.isPartnerSharedWith &&
profileImagePath == other.profileImagePath &&
isAdmin == other.isAdmin;
}
@ -67,5 +71,6 @@ class User {
lastName.hashCode ^
isPartnerSharedBy.hashCode ^
isPartnerSharedWith.hashCode ^
profileImagePath.hashCode ^
isAdmin.hashCode;
}

View File

@ -1,4 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/models/store.dart';
@ -62,6 +64,10 @@ class ApiService {
Future<String> _resolveEndpoint(String serverUrl) async {
final url = sanitizeUrl(serverUrl);
if (!await _isEndpointAvailable(serverUrl)) {
throw ApiException(503, "Server is not reachable");
}
// Check for /.well-known/immich
final wellKnownEndpoint = await _getWellKnownEndpoint(url);
if (wellKnownEndpoint.isNotEmpty) return wellKnownEndpoint;
@ -70,6 +76,29 @@ class ApiService {
return url;
}
Future<bool> _isEndpointAvailable(String serverUrl) async {
final Client client = Client();
if (!serverUrl.endsWith('/api')) {
serverUrl += '/api';
}
// Throw Socket or Timeout exceptions,
// we do not care if the endpoints hits an HTTP error
try {
await client
.get(
Uri.parse(serverUrl),
)
.timeout(const Duration(seconds: 5));
} on TimeoutException catch (_) {
return false;
} on SocketException catch (_) {
return false;
}
return true;
}
Future<String> _getWellKnownEndpoint(String baseUrl) async {
final Client client = Client();

View File

@ -8,6 +8,8 @@ import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.pr
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
class SplashScreenPage extends HookConsumerWidget {
const SplashScreenPage({Key? key}) : super(key: key);
@ -17,24 +19,41 @@ class SplashScreenPage extends HookConsumerWidget {
final apiService = ref.watch(apiServiceProvider);
final serverUrl = Store.tryGet(StoreKey.serverUrl);
final accessToken = Store.tryGet(StoreKey.accessToken);
final log = Logger("SplashScreenPage");
void performLoggingIn() async {
bool isSuccess = false;
bool deviceIsOffline = false;
if (accessToken != null && serverUrl != null) {
try {
// Resolve API server endpoint from user provided serverUrl
await apiService.resolveAndSetEndpoint(serverUrl);
} catch (e) {
} on ApiException catch (e) {
// okay, try to continue anyway if offline
if (e.code == 503) {
deviceIsOffline = true;
log.fine("Device seems to be offline upon launch");
} else {
log.severe(e);
}
} catch (e) {
log.severe(e);
}
isSuccess =
await ref.read(authenticationProvider.notifier).setSuccessLoginInfo(
accessToken: accessToken,
serverUrl: serverUrl,
offlineLogin: deviceIsOffline,
);
}
if (isSuccess) {
// If the device is offline and there is a currentUser stored locallly
// Proceed into the app
if (deviceIsOffline && Store.tryGet(StoreKey.currentUser) != null) {
AutoRouter.of(context).replace(const TabControllerRoute());
} else if (isSuccess) {
// If device was able to login through the internet successfully
final hasPermission =
await ref.read(galleryPermissionNotifier.notifier).hasPermission;
if (hasPermission) {
@ -43,6 +62,7 @@ class SplashScreenPage extends HookConsumerWidget {
}
AutoRouter.of(context).replace(const TabControllerRoute());
} else {
// User was unable to login through either offline or online methods
AutoRouter.of(context).replace(const LoginRoute());
}
}