diff --git a/lib/config/routes/v2/routes_config/routes/noauth_routes.dart b/lib/config/routes/v2/routes_config/routes/noauth_routes.dart index 60bf60a9..5d1c29ff 100644 --- a/lib/config/routes/v2/routes_config/routes/noauth_routes.dart +++ b/lib/config/routes/v2/routes_config/routes/noauth_routes.dart @@ -8,15 +8,14 @@ final List noAuthRoutes = [ GoRoute( path: noAuthPath, builder: (context, state) { - // Try to get secret from query parameters - final secret = state.uri.queryParameters['secret']; - - if (secret == null) { - return const SwitchEndpointNoAuthView(arguments: null); - } - - // Create arguments from query parameters - final args = SwitchEndpointArgs.fromJson(state.uri.queryParameters); + // A link without a secret (e.g. the app QR from the login page) is + // still a valid host switch, so parse arguments in both cases. The + // original scanned link is passed along as the `uri` parameter. + final params = { + ...state.uri.queryParameters, + 'uri': state.uri.queryParameters['uri'] ?? state.uri.toString(), + }; + final args = SwitchEndpointArgs.fromJson(params); return SwitchEndpointNoAuthView(arguments: args); }, diff --git a/lib/core/auth/login/provider/login_provider.dart b/lib/core/auth/login/provider/login_provider.dart index f63e48ee..325a4520 100644 --- a/lib/core/auth/login/provider/login_provider.dart +++ b/lib/core/auth/login/provider/login_provider.dart @@ -23,7 +23,10 @@ part 'login_provider.g.dart'; @riverpod class Login extends _$Login { - final _tbClient = getIt().client; + // Read the live client on every access: a QR-code endpoint switch re-creates + // the client (ITbClientService.reInit), so a reference captured at build time + // would keep pointing at the old host and fail with 401 (PROD-8200). + ThingsboardClient get _tbClient => getIt().client; final _deviceInfoService = getIt(); late final StreamSubscription _listener; final _overlayService = getIt(); @@ -32,13 +35,24 @@ class Login extends _$Login { _listener = getIt().on().listen(( _, ) async { - await handleUserLoaded(); + await _safeHandleUserLoaded(); }); ref.onDispose(() => _listener.cancel()); - Future(() => handleUserLoaded()); + Future(_safeHandleUserLoaded); return const LoginState(isUserLoaded: false); } + /// handleUserLoaded runs from fire-and-forget contexts (event bus, build): + /// a failure there (e.g. the session was invalidated while loading the + /// user) must not escape as an unhandled zone error (PROD-8200). + Future _safeHandleUserLoaded() async { + try { + await handleUserLoaded(); + } catch (e) { + log('handle user loaded failed: $e'); + } + } + Future logout() async { if (getIt().apps.isNotEmpty && state.isFullyAuthenticated()) { diff --git a/lib/core/auth/login/provider/oauth_provider.dart b/lib/core/auth/login/provider/oauth_provider.dart index a2eaf020..d6254a5f 100644 --- a/lib/core/auth/login/provider/oauth_provider.dart +++ b/lib/core/auth/login/provider/oauth_provider.dart @@ -21,11 +21,19 @@ Future oauth(Ref ref) async { final tbClient = getIt().client; final deviceInfoService = getIt(); try { + // Best-effort call with a graceful fallback below: don't let the + // interceptor surface its failures as error toasts (e.g. some servers + // answer 403 for a package they don't know about) (PROD-8200). final response = await tbClient .getMobileAppControllerApi() .getLoginMobileInfo( pkgName: deviceInfoService.getApplicationId(), platform: deviceInfoService.getPlatformType().name, + extra: + InterceptorConfig( + ignoreErrors: true, + ignoreLoading: true, + ).toExtra(), ); final loginInfo = response.data; if (loginInfo != null) { diff --git a/lib/core/auth/noauth/data/model/switch_endpoint_args.dart b/lib/core/auth/noauth/data/model/switch_endpoint_args.dart index d908b1ea..309d36ca 100644 --- a/lib/core/auth/noauth/data/model/switch_endpoint_args.dart +++ b/lib/core/auth/noauth/data/model/switch_endpoint_args.dart @@ -5,22 +5,24 @@ part 'switch_endpoint_args.g.dart'; @freezed abstract class SwitchEndpointArgs with _$SwitchEndpointArgs { - const factory SwitchEndpointArgs( - {required String secret, - String? host, - String? ttl, - @JsonKey(fromJson: fromFluroData, toJson: uriToJson) - required Uri uri}) = _SwitchEndpointArgs; + const factory SwitchEndpointArgs({ + String? secret, + String? host, + String? ttl, + @JsonKey(fromJson: fromFluroData, toJson: uriToJson) required Uri uri, + }) = _SwitchEndpointArgs; factory SwitchEndpointArgs.fromJson(Map json) => _$SwitchEndpointArgsFromJson(json); } - Uri fromFluroData(dynamic data) { - if(data is Uri) { + +Uri fromFluroData(dynamic data) { + if (data is Uri) { return data; } -return Uri.parse(data.toString()); + return Uri.parse(data.toString()); } + String uriToJson(Uri uri) { return uri.toString(); -} \ No newline at end of file +} diff --git a/lib/core/auth/noauth/data/model/switch_endpoint_args.freezed.dart b/lib/core/auth/noauth/data/model/switch_endpoint_args.freezed.dart index d2e920d0..7b0e8732 100644 --- a/lib/core/auth/noauth/data/model/switch_endpoint_args.freezed.dart +++ b/lib/core/auth/noauth/data/model/switch_endpoint_args.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$SwitchEndpointArgs { - String get secret; String? get host; String? get ttl;@JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri get uri; + String? get secret; String? get host; String? get ttl;@JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri get uri; /// Create a copy of SwitchEndpointArgs /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -48,7 +48,7 @@ abstract mixin class $SwitchEndpointArgsCopyWith<$Res> { factory $SwitchEndpointArgsCopyWith(SwitchEndpointArgs value, $Res Function(SwitchEndpointArgs) _then) = _$SwitchEndpointArgsCopyWithImpl; @useResult $Res call({ - String secret, String? host, String? ttl,@JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri + String? secret, String? host, String? ttl,@JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri }); @@ -65,10 +65,10 @@ class _$SwitchEndpointArgsCopyWithImpl<$Res> /// Create a copy of SwitchEndpointArgs /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? secret = null,Object? host = freezed,Object? ttl = freezed,Object? uri = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? secret = freezed,Object? host = freezed,Object? ttl = freezed,Object? uri = null,}) { return _then(_self.copyWith( -secret: null == secret ? _self.secret : secret // ignore: cast_nullable_to_non_nullable -as String,host: freezed == host ? _self.host : host // ignore: cast_nullable_to_non_nullable +secret: freezed == secret ? _self.secret : secret // ignore: cast_nullable_to_non_nullable +as String?,host: freezed == host ? _self.host : host // ignore: cast_nullable_to_non_nullable as String?,ttl: freezed == ttl ? _self.ttl : ttl // ignore: cast_nullable_to_non_nullable as String?,uri: null == uri ? _self.uri : uri // ignore: cast_nullable_to_non_nullable as Uri, @@ -156,7 +156,7 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String secret, String? host, String? ttl, @JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String? secret, String? host, String? ttl, @JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _SwitchEndpointArgs() when $default != null: return $default(_that.secret,_that.host,_that.ttl,_that.uri);case _: @@ -177,7 +177,7 @@ return $default(_that.secret,_that.host,_that.ttl,_that.uri);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String secret, String? host, String? ttl, @JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String? secret, String? host, String? ttl, @JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri) $default,) {final _that = this; switch (_that) { case _SwitchEndpointArgs(): return $default(_that.secret,_that.host,_that.ttl,_that.uri);case _: @@ -197,7 +197,7 @@ return $default(_that.secret,_that.host,_that.ttl,_that.uri);case _: /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String secret, String? host, String? ttl, @JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String? secret, String? host, String? ttl, @JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri)? $default,) {final _that = this; switch (_that) { case _SwitchEndpointArgs() when $default != null: return $default(_that.secret,_that.host,_that.ttl,_that.uri);case _: @@ -212,10 +212,10 @@ return $default(_that.secret,_that.host,_that.ttl,_that.uri);case _: @JsonSerializable() class _SwitchEndpointArgs implements SwitchEndpointArgs { - const _SwitchEndpointArgs({required this.secret, this.host, this.ttl, @JsonKey(fromJson: fromFluroData, toJson: uriToJson) required this.uri}); + const _SwitchEndpointArgs({this.secret, this.host, this.ttl, @JsonKey(fromJson: fromFluroData, toJson: uriToJson) required this.uri}); factory _SwitchEndpointArgs.fromJson(Map json) => _$SwitchEndpointArgsFromJson(json); -@override final String secret; +@override final String? secret; @override final String? host; @override final String? ttl; @override@JsonKey(fromJson: fromFluroData, toJson: uriToJson) final Uri uri; @@ -253,7 +253,7 @@ abstract mixin class _$SwitchEndpointArgsCopyWith<$Res> implements $SwitchEndpoi factory _$SwitchEndpointArgsCopyWith(_SwitchEndpointArgs value, $Res Function(_SwitchEndpointArgs) _then) = __$SwitchEndpointArgsCopyWithImpl; @override @useResult $Res call({ - String secret, String? host, String? ttl,@JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri + String? secret, String? host, String? ttl,@JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri }); @@ -270,10 +270,10 @@ class __$SwitchEndpointArgsCopyWithImpl<$Res> /// Create a copy of SwitchEndpointArgs /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? secret = null,Object? host = freezed,Object? ttl = freezed,Object? uri = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? secret = freezed,Object? host = freezed,Object? ttl = freezed,Object? uri = null,}) { return _then(_SwitchEndpointArgs( -secret: null == secret ? _self.secret : secret // ignore: cast_nullable_to_non_nullable -as String,host: freezed == host ? _self.host : host // ignore: cast_nullable_to_non_nullable +secret: freezed == secret ? _self.secret : secret // ignore: cast_nullable_to_non_nullable +as String?,host: freezed == host ? _self.host : host // ignore: cast_nullable_to_non_nullable as String?,ttl: freezed == ttl ? _self.ttl : ttl // ignore: cast_nullable_to_non_nullable as String?,uri: null == uri ? _self.uri : uri // ignore: cast_nullable_to_non_nullable as Uri, diff --git a/lib/core/auth/noauth/data/model/switch_endpoint_args.g.dart b/lib/core/auth/noauth/data/model/switch_endpoint_args.g.dart index a7a2327d..ec4dc2fd 100644 --- a/lib/core/auth/noauth/data/model/switch_endpoint_args.g.dart +++ b/lib/core/auth/noauth/data/model/switch_endpoint_args.g.dart @@ -8,7 +8,7 @@ part of 'switch_endpoint_args.dart'; _SwitchEndpointArgs _$SwitchEndpointArgsFromJson(Map json) => _SwitchEndpointArgs( - secret: json['secret'] as String, + secret: json['secret'] as String?, host: json['host'] as String?, ttl: json['ttl'] as String?, uri: fromFluroData(json['uri']), diff --git a/lib/core/auth/noauth/presentation/view/switch_endpoint_noauth_view.dart b/lib/core/auth/noauth/presentation/view/switch_endpoint_noauth_view.dart index 246126d0..baf6c01e 100644 --- a/lib/core/auth/noauth/presentation/view/switch_endpoint_noauth_view.dart +++ b/lib/core/auth/noauth/presentation/view/switch_endpoint_noauth_view.dart @@ -3,10 +3,14 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:thingsboard_app/core/auth/login/provider/login_provider.dart'; import 'package:thingsboard_app/core/auth/noauth/data/model/switch_endpoint_args.dart'; - import 'package:thingsboard_app/core/auth/noauth/presentation/widgets/noauth_loading_widget.dart'; import 'package:thingsboard_app/core/auth/noauth/provider/noauth_provider.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/modules/main/providers/navigation_provider.dart'; +import 'package:thingsboard_app/thingsboard_client.dart'; +import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; class SwitchEndpointNoAuthView extends HookConsumerWidget { const SwitchEndpointNoAuthView({required this.arguments, super.key}); @@ -15,24 +19,63 @@ class SwitchEndpointNoAuthView extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final noAuth = ref.watch(noauthProviderProvider); useEffect(() { - ref.invalidate(noauthProviderProvider); + ref.invalidate(noauthProviderProvider); if (arguments != null) { ref .read(noauthProviderProvider.notifier) .switchEndpoint(SwitchEndpointParams(data: arguments!)); + } else { + // Nothing to switch to: never leave the user on an endless spinner. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + context.go('/login'); + } + }); } return null; }, []); ref.listen(noauthProviderProvider, (prev, next) { if (next.error != null) { Future.delayed(const Duration(seconds: 5), () { - if (context.mounted) { + if (!context.mounted) { + return; + } + if (context.canPop()) { context.pop(); + } else { + context.go('/login'); } }); } if (next.isDone) { - + // A switch with a login secret ends authenticated: HomeHandler + // navigates to the home page once the user is fully loaded. A + // host-only switch (QR without a secret) ends unauthenticated: show + // the login page of the new host. + final authenticated = + getIt().client.isAuthenticated(); + if (!context.mounted) { + return; + } + if (!authenticated) { + context.go('/login'); + } else if (ref.read(loginProvider).isFullyAuthenticated()) { + // Already fully logged in (e.g. the same QR was scanned twice): + // HomeHandler won't see a state transition, navigate ourselves. + final navigation = ref.read(navigationProvider); + if (navigation.bottomBarPages.isNotEmpty) { + context.go(navigation.bottomBarPages.first.path); + } + } else { + // HomeHandler navigates once the user finishes loading. If that + // never happens (e.g. the new session gets rejected), don't leave + // the user on the spinner forever. + Future.delayed(const Duration(seconds: 12), () { + if (context.mounted) { + context.go('/login'); + } + }); + } } }); return Scaffold( @@ -40,6 +83,13 @@ class SwitchEndpointNoAuthView extends HookConsumerWidget { child: Builder( builder: (context) { if (noAuth.error != null) { + final error = noAuth.error; + // Never render error.toString(): for ThingsboardError it + // includes the captured stacktrace (PROD-8200). + final message = + error is ThingsboardError + ? error.message ?? noAuth.message + : noAuth.message; return Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Center( @@ -49,8 +99,7 @@ class SwitchEndpointNoAuthView extends HookConsumerWidget { const Icon(Icons.error, color: Colors.red, size: 50), const SizedBox(height: 10), Text( - noAuth.error.toString(), - //S.of(context).somethingWentWrongRollback, + message, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w500, diff --git a/lib/core/auth/noauth/provider/noauth_provider.dart b/lib/core/auth/noauth/provider/noauth_provider.dart index 230a83f3..6ff56c2e 100644 --- a/lib/core/auth/noauth/provider/noauth_provider.dart +++ b/lib/core/auth/noauth/provider/noauth_provider.dart @@ -1,7 +1,6 @@ import 'package:dio/dio.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:thingsboard_app/constants/app_constants.dart'; import 'package:thingsboard_app/core/auth/login/provider/oauth_provider.dart'; import 'package:thingsboard_app/core/auth/noauth/data/model/switch_endpoint_args.dart'; @@ -29,13 +28,30 @@ class NoauthProvider extends _$NoauthProvider { } Future switchEndpoint(SwitchEndpointParams params) async { + final uri = params.data.uri; + final key = params.data.secret; + final currentEndpoint = await getIt().getEndpoint(); try { - final uri = params.data.uri; - final host = params.data.host ?? uri.origin; - final key = params.data.secret; - final currentEndpoint = await getIt().getEndpoint(); + final host = + params.data.host ?? (uri.isAbsolute ? uri.origin : currentEndpoint); final isTheSameHost = Uri.parse(host).host.compareTo(Uri.parse(currentEndpoint).host) == 0; + _logger.debug( + 'SwitchEndpointUseCase: host=$host currentEndpoint=$currentEndpoint ' + 'isTheSameHost=$isTheSameHost hasSecret=${key != null}', + ); + + if (key == null || key.isEmpty) { + // A QR link without a secret (e.g. the mobile app QR shown on the + // login page) cannot log the user in: just switch to the target host + // and let the login page of that host take over. + await _switchHostOnly( + host: host, + currentEndpoint: currentEndpoint, + isTheSameHost: isTheSameHost, + ); + return; + } state = NoAuthState( error: null, @@ -54,7 +70,22 @@ class NoauthProvider extends _$NoauthProvider { receiveTimeout: const Duration(seconds: 20), ), ); - final secretResponse = await tempDio.get('/api/noauth/qr/${key ?? ''}'); + final Response secretResponse; + try { + secretResponse = await tempDio.get('/api/noauth/qr/$key'); + } on DioException catch (e) { + // The server replies with a ThingsboardError body (e.g. an expired + // one-time secret): surface its message instead of the raw Dio text. + final body = e.response?.data; + final serverMessage = body is Map ? body['message'] as String? : null; + throw ThingsboardError( + message: + serverMessage ?? + 'Failed to obtain a login token from $host. ' + 'Please scan a new QR code.', + status: e.response?.statusCode, + ); + } final data = secretResponse.data; final tokenStr = data is Map ? data['token'] as String? : null; final refreshTokenStr = @@ -65,6 +96,28 @@ class NoauthProvider extends _$NoauthProvider { ); } + // The server can hand out an already-revoked pair: the pair is bound + // to the QR secret, so re-scanning the same code after a logout yields + // tokens issued before the logout watermark ('Token is outdated'). + // Verify the pair against the target host BEFORE switching, otherwise + // the new client would enter a login/refresh loop it can never win. + try { + await tempDio.get( + '/api/auth/user', + options: Options(headers: {'X-Authorization': 'Bearer $tokenStr'}), + ); + } on DioException catch (e) { + final body = e.response?.data; + final serverMessage = body is Map ? body['message'] as String? : null; + throw ThingsboardError( + message: + serverMessage ?? + 'The QR code session is no longer valid. ' + 'Please refresh the QR code and scan again.', + status: e.response?.statusCode, + ); + } + if (isTheSameHost) { state = NoAuthState( error: null, @@ -79,56 +132,101 @@ class NoauthProvider extends _$NoauthProvider { ); } - await getIt().client.setUserFromJwtToken( - tokenStr, - refreshTokenStr, - false, - ); + // Stage the exchanged JWT pair in storage BEFORE re-creating the + // client: reInit logs in from storage, so this guarantees the new + // client starts with exactly these tokens. Never hand them to the old + // client instance, and never let a session left in storage by a + // previous host win the race (PROD-8200). + final storage = getIt(); + await storage.setItem('jwt_token', tokenStr); + if (refreshTokenStr != null) { + await storage.setItem('refresh_token', refreshTokenStr); + } else { + await storage.deleteItem('refresh_token'); + } await getIt().setEndpoint(host); - if (!isTheSameHost) { - _logger.debug('SwitchEndpointUseCase:deleteFB App'); - if (Firebase.apps.isNotEmpty) { - getIt() - ..removeApp() - ..removeApp(name: currentEndpoint); - } - - // If we revert to the original host configured in the app_constants - final t = await getIt().isCustomEndpoint(); - _logger.debug(t); - if (!t) { - await _initDefaultFbApp(); - } + await _switchFirebaseApps(currentEndpoint); } - // A re-initialization is required if we set 'notifyUser' to true for - // 'setUserFromJwtToken'. This code will be executed twice. await getIt().reInit( endpoint: host, - onDone: () { - ref.invalidate(oauthProvider); - // await ref.read(loginProvider.notifier).handleUserLoaded(); - }, + onDone: () => ref.invalidate(oauthProvider), onAuthError: (e) { - _logger.error('SwitchEndpointUseCase:onError $e'); - throw e; + // Client-level errors are surfaced by the client service itself; + // throwing here would escape the callback as an unhandled zone + // error and leak a raw stacktrace to the UI (PROD-8200). + _logger.error('SwitchEndpointUseCase:onAuthError $e'); }, ); - state = NoAuthState(error: null, isDone: true, message: ''); - } catch (e) { - await reset(params); - if (e is ThingsboardError) { - _logger.error('SwitchEndpointUseCase:ThingsboardError $e', e); - state = NoAuthState( - error: e, - isDone: false, - message: e.message ?? e.toString(), + if (!getIt().client.isAuthenticated()) { + // The staged tokens were lost before init picked them up (e.g. a + // failing background refresh of the previous session cleared the + // shared storage in the meantime): apply the exchanged pair to the + // new client directly. + _logger.debug('SwitchEndpointUseCase: re-applying exchanged tokens'); + await getIt().client.setUserFromJwtToken( + tokenStr, + refreshTokenStr, + true, ); - return; } + _logger.debug('SwitchEndpointUseCase: switch to $host done'); + state = NoAuthState(error: null, isDone: true, message: ''); + } catch (e) { _logger.error('SwitchEndpointUseCase:catch $e', e); - state = NoAuthState(error: e, isDone: false, message: e.toString()); + await reset(previousEndpoint: currentEndpoint); + state = NoAuthState( + error: e, + isDone: false, + message: e is ThingsboardError ? e.message ?? e.toString() : '$e', + ); + } + } + + Future _switchHostOnly({ + required String host, + required String currentEndpoint, + required bool isTheSameHost, + }) async { + if (!isTheSameHost) { + state = NoAuthState( + error: null, + isDone: false, + message: 'Switching you to the new host $host', + ); + // A host switch without a login secret ends on the login page of the + // new host: the previous host's session tokens are meaningless there. + await getIt().client.setUserFromJwtToken( + null, + null, + false, + ); + await getIt().setEndpoint(host); + await _switchFirebaseApps(currentEndpoint); + await getIt().reInit( + endpoint: host, + onDone: () => ref.invalidate(oauthProvider), + onAuthError: (e) { + _logger.error('SwitchEndpointUseCase:onAuthError $e'); + }, + ); + } + state = NoAuthState(error: null, isDone: true, message: ''); + } + + Future _switchFirebaseApps(String previousEndpoint) async { + _logger.debug('SwitchEndpointUseCase:deleteFB App'); + if (Firebase.apps.isNotEmpty) { + getIt() + ..removeApp() + ..removeApp(name: previousEndpoint); + } + + // If we revert to the original host configured in the app_constants + final isCustom = await getIt().isCustomEndpoint(); + if (!isCustom) { + await _initDefaultFbApp(); } } @@ -145,35 +243,29 @@ class NoauthProvider extends _$NoauthProvider { } } - Future reset(SwitchEndpointParams params) async { + /// Rolls the app back to the endpoint that was active before the failed + /// switch. The previous session tokens are still in storage (the new ones + /// are only persisted after a successful reInit), so a logged-in user keeps + /// their session. + Future reset({required String previousEndpoint}) async { try { - await getIt().setEndpoint( - ThingsboardAppConstants.thingsBoardApiEndpoint, - ); + await getIt().setEndpoint(previousEndpoint); await getIt().clearApps(); - await _initDefaultFbApp(); - _reInitClient( - endpoint: ThingsboardAppConstants.thingsBoardApiEndpoint, - params: params, + final isCustom = await getIt().isCustomEndpoint(); + if (!isCustom) { + await _initDefaultFbApp(); + } + await getIt().reInit( + endpoint: previousEndpoint, + onDone: () => ref.invalidate(oauthProvider), + onAuthError: (e) { + _logger.error('SwitchEndpointUseCaseReset:onAuthError $e'); + }, ); } catch (e) { _logger.error('SwitchEndpointUseCaseReset:onError $e'); } } - - Future _reInitClient({ - required String endpoint, - required SwitchEndpointParams params, - }) async { - await getIt().reInit( - endpoint: endpoint, - onDone: () {}, - onAuthError: (e) { - _logger.error('SwitchEndpointUseCase:onError $e'); - throw e; - }, - ); - } } class NoAuthState { diff --git a/lib/core/auth/noauth/provider/noauth_provider.g.dart b/lib/core/auth/noauth/provider/noauth_provider.g.dart index b119f9e5..3d8d04d2 100644 --- a/lib/core/auth/noauth/provider/noauth_provider.g.dart +++ b/lib/core/auth/noauth/provider/noauth_provider.g.dart @@ -6,7 +6,7 @@ part of 'noauth_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$noauthProviderHash() => r'098288b187c0dc0d812e6674e8f86501e956ec99'; +String _$noauthProviderHash() => r'814ea03eaa086b3f1577f40c81392db4c40972fa'; /// See also [NoauthProvider]. @ProviderFor(NoauthProvider) diff --git a/lib/thingsboard_app_ce.dart b/lib/thingsboard_app_ce.dart index c76ce4a0..61b82c10 100644 --- a/lib/thingsboard_app_ce.dart +++ b/lib/thingsboard_app_ce.dart @@ -1,13 +1,18 @@ +import 'package:app_links/app_links.dart'; import 'package:country_picker/country_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localized_locales/flutter_localized_locales.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:thingsboard_app/config/routes/router.dart'; import 'package:thingsboard_app/config/routes/v2/router_2.dart'; import 'package:thingsboard_app/config/themes/dark_theme.dart'; import 'package:thingsboard_app/config/themes/tb_ce_theme.dart'; import 'package:thingsboard_app/generated/l10n.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/utils/services/local_database/i_local_database_service.dart'; import 'package:toastification/toastification.dart'; class ThingsboardApp extends HookConsumerWidget { @@ -17,8 +22,40 @@ class ThingsboardApp extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final router = ref.watch(routerProvider); + // App links (e.g. the login QR code scanned with the camera app) used to + // be handled by TbContext.init, which the v2 router no longer calls. + // Listen here and route through the same navigateByAppLink path the + // in-app QR scanner uses. The platform may deliver the same intent more + // than once, so consecutive duplicates are dropped. + useEffect(() { + String? lastLink; + DateTime? lastLinkAt; + final sub = AppLinks().uriLinkStream.listen((link) { + final now = DateTime.now(); + final isDuplicate = + link.toString() == lastLink && + lastLinkAt != null && + now.difference(lastLinkAt!) < const Duration(seconds: 2); + lastLink = link.toString(); + lastLinkAt = now; + if (!isDuplicate) { + getIt().navigateByAppLink(link.toString()); + } + }); + + // Consume the link the app was cold-started with (stored in main()). + WidgetsBinding.instance.addPostFrameCallback((_) async { + final initialLink = + await getIt().getInitialAppLink(); + if (initialLink != null && initialLink != lastLink) { + getIt().navigateByAppLink(initialLink); + } + }); + return sub.cancel; + }, const []); + return ToastificationWrapper( - child : ColoredBox( + child: ColoredBox( color: tbCeTheme.scaffoldBackgroundColor, child: MaterialApp.router( debugShowCheckedModeBanner: false, @@ -38,7 +75,7 @@ class ThingsboardApp extends HookConsumerWidget { darkTheme: tbDarkTheme, routerConfig: router, ), - ) + ), ); } } diff --git a/lib/utils/services/notification_service.dart b/lib/utils/services/notification_service.dart index 18980c5e..61072358 100644 --- a/lib/utils/services/notification_service.dart +++ b/lib/utils/services/notification_service.dart @@ -18,7 +18,16 @@ class NotificationService { static FirebaseMessaging _messaging = FirebaseMessaging.instance; late NotificationDetails _notificationDetails; final TbLogger _log = getIt(); - final ThingsboardClient _tbClient = getIt().client; + // Read the live client on every access: a QR-code endpoint switch re-creates + // the client (ITbClientService.reInit), so a reference captured at + // construction would keep pointing at the old host (PROD-8200). + ThingsboardClient get _tbClient => getIt().client; + + // Notifications are best-effort: some servers answer 403 for a mobile + // package they don't know about, and that must not surface as an error + // toast right after a successful login (PROD-8200). + static Map get _bestEffortRequestExtra => + InterceptorConfig(ignoreErrors: true, ignoreLoading: true).toExtra(); final INotificationsLocalService _localService = NotificationsLocalService(); StreamSubscription? _foregroundMessageSubscription; StreamSubscription? _onMessageOpenedAppSubscription; @@ -55,7 +64,10 @@ class NotificationService { if (_fcmToken != null) { _tbClient .getUserControllerApi() - .removeMobileSession(xMobileToken: _fcmToken!) + .removeMobileSession( + xMobileToken: _fcmToken!, + extra: _bestEffortRequestExtra, + ) .then((_) { _fcmToken = token; if (_fcmToken != null) { @@ -100,6 +112,7 @@ class NotificationService { ); _tbClient.getUserControllerApi().removeMobileSession( xMobileToken: _fcmToken!, + extra: _bestEffortRequestExtra, ); } @@ -173,7 +186,10 @@ class NotificationService { Future _resetToken(String? token) async { if (token != null) { - _tbClient.getUserControllerApi().removeMobileSession(xMobileToken: token); + _tbClient.getUserControllerApi().removeMobileSession( + xMobileToken: token, + extra: _bestEffortRequestExtra, + ); } await _messaging.deleteToken(); @@ -181,27 +197,39 @@ class NotificationService { } Future _getAndSaveToken() async { - String? fcmToken = await getToken(); + final fcmToken = await getToken(); _log.debug('FCM token: $fcmToken'); - if (fcmToken != null) { - final mobileInfo = - (await _tbClient.getUserControllerApi().getMobileSession( - xMobileToken: fcmToken, - )).data; - if (mobileInfo != null) { - final int timeAfterCreatedToken = - DateTime.now().millisecondsSinceEpoch - - (mobileInfo.fcmTokenTimestamp ?? 0); - if (timeAfterCreatedToken > const Duration(days: 30).inMilliseconds) { - fcmToken = await _resetToken(fcmToken); - if (fcmToken != null) { - await _saveToken(fcmToken); - } + try { + await _syncMobileSession(fcmToken); + } catch (e) { + // The server may reject the session for an unknown mobile package: + // push notifications simply stay off, nothing else should break. + _log.error('NotificationService: failed to sync mobile session $e'); + } + } + + Future _syncMobileSession(String? fcmToken) async { + if (fcmToken == null) { + return; + } + final mobileInfo = + (await _tbClient.getUserControllerApi().getMobileSession( + xMobileToken: fcmToken, + extra: _bestEffortRequestExtra, + )).data; + if (mobileInfo != null) { + final int timeAfterCreatedToken = + DateTime.now().millisecondsSinceEpoch - + (mobileInfo.fcmTokenTimestamp ?? 0); + if (timeAfterCreatedToken > const Duration(days: 30).inMilliseconds) { + final refreshedToken = await _resetToken(fcmToken); + if (refreshedToken != null) { + await _saveToken(refreshedToken); } - } else { - await _saveToken(fcmToken); } + } else { + await _saveToken(fcmToken); } } @@ -211,6 +239,7 @@ class NotificationService { mobileSessionInfo: MobileSessionInfo( (b) => b..fcmTokenTimestamp = DateTime.now().millisecondsSinceEpoch, ), + extra: _bestEffortRequestExtra, ); } @@ -315,7 +344,10 @@ class NotificationService { try { final resp = await _tbClient .getNotificationControllerApi() - .getUnreadNotificationsCount(deliveryMethod: 'MOBILE_APP'); + .getUnreadNotificationsCount( + deliveryMethod: 'MOBILE_APP', + extra: _bestEffortRequestExtra, + ); return resp.data ?? 0; } catch (_) { return 0; diff --git a/lib/utils/services/tb_client_service/tb_client_service.dart b/lib/utils/services/tb_client_service/tb_client_service.dart index fba12374..b4a304b7 100644 --- a/lib/utils/services/tb_client_service/tb_client_service.dart +++ b/lib/utils/services/tb_client_service/tb_client_service.dart @@ -19,6 +19,22 @@ class TbClientService implements ITbClientService { ThingsboardClient get client => _client; final IOverlayService _overlayService = getIt(); + // The client performs best-effort internal calls during init() (e.g. the + // server version check hits /api/admin/updates, which answers 403 for + // non-SYS_ADMIN users). Those must not surface as error toasts, and the + // generated client library can't be modified to ignore them (PROD-8200). + bool _suppressErrorNotifications = false; + + // The client delivers error callbacks via Future(() => cb(error)), so an + // error raised during init() reaches onClientError one event-loop turn + // AFTER init() returns. Keep suppressing for a grace period instead of + // lifting the flag synchronously. + void _scheduleErrorNotificationsRestore() { + Future.delayed(const Duration(seconds: 2), () { + _suppressErrorNotifications = false; + }); + } + ThingsboardClient _createClient( String endpoint, { required ErrorCallback onError, @@ -42,10 +58,13 @@ class TbClientService implements ITbClientService { _client = _createClient(endpoint, onError: onClientError); try { + _suppressErrorNotifications = true; await _client.init(); } catch (e) { log('Failed to init tbClient: $e'); onInitError(e); + } finally { + _scheduleErrorNotificationsRestore(); } } @@ -76,6 +95,9 @@ class TbClientService implements ITbClientService { void onClientError(ThingsboardError e) { log('client on error: $e'); + if (_suppressErrorNotifications) { + return; + } WidgetsBinding.instance.addPostFrameCallback((_) { if (Utils.isConnectionError(e)) { _overlayService.showAlertDialog( @@ -108,7 +130,7 @@ class TbClientService implements ITbClientService { required VoidCallback onDone, required ErrorCallback onAuthError, }) async { - log('TbClient:reinit()'); + log('TbClient:reinit() endpoint: $endpoint'); _client = _createClient( endpoint, onError: (e) { @@ -116,7 +138,12 @@ class TbClientService implements ITbClientService { onClientError(e); }, ); - await _client.init(); + try { + _suppressErrorNotifications = true; + await _client.init(); + } finally { + _scheduleErrorNotificationsRestore(); + } onDone(); } } diff --git a/lib/utils/ui/qr_code_scanner/qr_code_scanner.dart b/lib/utils/ui/qr_code_scanner/qr_code_scanner.dart index 1c80f6af..9dc21829 100644 --- a/lib/utils/ui/qr_code_scanner/qr_code_scanner.dart +++ b/lib/utils/ui/qr_code_scanner/qr_code_scanner.dart @@ -36,6 +36,9 @@ class QrCodeScannerPage extends HookWidget { final isBackCameraActive = useState(true); final controller = useMemoized(() => MobileScannerController()); + // The scanner keeps detecting the same code on every frame: pop with the + // first result only, or GoRouter throws "There is nothing to pop". + final detectionHandled = useRef(false); // Check camera permission initially useEffect(() { @@ -91,13 +94,12 @@ class QrCodeScannerPage extends HookWidget { errorBuilder: (p0, p1) => const ScannerErrorWidget(), controller: controller, onDetect: (barcodes) { - if (barcodes.barcodes.isNotEmpty) { - if (context.mounted) { - context.pop( - barcodes.barcodes.first, - - ); - } + if (barcodes.barcodes.isNotEmpty && + !detectionHandled.value && + context.mounted && + context.canPop()) { + detectionHandled.value = true; + context.pop(barcodes.barcodes.first); } }, ),