Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(mobile) - Add better offline support #3279

Merged
merged 14 commits into from
Jul 28, 2023
76 changes: 50 additions & 26 deletions mobile/lib/modules/login/providers/authentication.provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -136,38 +136,62 @@ 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);
String? deviceId;

if (offlineLogin) {
ddshd marked this conversation as resolved.
Show resolved Hide resolved
deviceId = Store.tryGet(StoreKey.deviceId);
User? offlineUser = Store.tryGet(StoreKey.currentUser);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding error handling for the case when the user is offline and the stored user data is null. This could prevent potential crashes or undefined behavior. [important]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed


if (deviceId != null && offlineUser != null) {
state = state.copyWith(
isAuthenticated: true,
userId: offlineUser.id,
userEmail: offlineUser.email,
firstName: offlineUser.firstName,
lastName: offlineUser.lastName,
profileImagePath: offlineUser.profileImagePath,
isAdmin: offlineUser.isAdmin,
shouldChangePassword: false,
deviceId: deviceId,
);
return false;
}
} else {
try {
userResponseDto = await _apiService.userApi.getMyUserInfo();
} on ApiException catch (e) {
if (e.innerException is SocketException) {
state = state.copyWith(isAuthenticated: true);
}
}
}

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: userResponseDto.id,
userEmail: userResponseDto.email,
firstName: userResponseDto.firstName,
lastName: userResponseDto.lastName,
profileImagePath: userResponseDto.profileImagePath,
isAdmin: userResponseDto.isAdmin,
shouldChangePassword: userResponseDto.shouldChangePassword,
deviceId: deviceId,
);
if (userResponseDto != null) {
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: 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 true;
return false;
}
}

Expand Down
19 changes: 15 additions & 4 deletions mobile/lib/routing/auth_guard.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
AuthGuard(this._apiService);
@override
void onNavigation(NavigationResolver resolver, StackRouter router) async {
var log = Logger("AuthGuard");
ddshd marked this conversation as resolved.
Show resolved Hide resolved

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.info("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.info(
ddshd marked this conversation as resolved.
Show resolved Hide resolved
"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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is no return statement here, what would happen?

}
} catch (e) {
debugPrint("Error [onNavigation] ${e.toString()}");
router.replaceAll([const LoginRoute()]);
return;
}
}
Expand Down
5 changes: 5 additions & 0 deletions mobile/lib/shared/models/user.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class User {
required this.isAdmin,
this.isPartnerSharedBy = false,
this.isPartnerSharedWith = false,
this.profileImagePath = '',
});

Id get isarId => fastHash(id);
Expand All @@ -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)
Expand All @@ -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')
Expand All @@ -54,6 +57,7 @@ class User {
lastName == other.lastName &&
isPartnerSharedBy == other.isPartnerSharedBy &&
isPartnerSharedWith == other.isPartnerSharedWith &&
profileImagePath == other.profileImagePath &&
isAdmin == other.isAdmin;
}

Expand All @@ -67,5 +71,6 @@ class User {
lastName.hashCode ^
isPartnerSharedBy.hashCode ^
isPartnerSharedWith.hashCode ^
profileImagePath.hashCode ^
isAdmin.hashCode;
}
27 changes: 27 additions & 0 deletions mobile/lib/shared/services/api.service.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -62,6 +64,8 @@ class ApiService {
Future<String> _resolveEndpoint(String serverUrl) async {
final url = sanitizeUrl(serverUrl);

await _isEndpointAvailable(serverUrl);

// Check for /.well-known/immich
final wellKnownEndpoint = await _getWellKnownEndpoint(url);
if (wellKnownEndpoint.isNotEmpty) return wellKnownEndpoint;
Expand All @@ -70,6 +74,29 @@ class ApiService {
return url;
}

Future<void> _isEndpointAvailable(String serverUrl) async {
fyfrey marked this conversation as resolved.
Show resolved Hide resolved
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));
return;
} on TimeoutException catch (_) {
rethrow;
} on SocketException catch (_) {
rethrow;
}
}

Future<String> _getWellKnownEndpoint(String baseUrl) async {
final Client client = Client();

Expand Down
7 changes: 6 additions & 1 deletion mobile/lib/shared/views/splash_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,26 @@ class SplashScreenPage extends HookConsumerWidget {

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) {
// okay, try to continue anyway if offline
deviceIsOffline = true;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a fallback or error message for the user in the case that the device is offline and login is unsuccessful. This would improve user experience by providing clear feedback. [medium]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the device is offline and the login is unsuccessful (i.e. there is no user information stored locally) then it will try to do an online login. If that also fails then it will redirect user back to the login page

}

isSuccess =
await ref.read(authenticationProvider.notifier).setSuccessLoginInfo(
accessToken: accessToken,
serverUrl: serverUrl,
offlineLogin: deviceIsOffline,
);
}
if (isSuccess) {
if (deviceIsOffline) {
AutoRouter.of(context).replace(const TabControllerRoute());
} else if (isSuccess) {
final hasPermission =
await ref.read(galleryPermissionNotifier.notifier).hasPermission;
if (hasPermission) {
Expand Down