diff --git a/lib/app/components/app_update_handler/hooks/use_app_update.dart b/lib/app/components/app_update_handler/hooks/use_app_update.dart new file mode 100644 index 000000000..5895f3454 --- /dev/null +++ b/lib/app/components/app_update_handler/hooks/use_app_update.dart @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/features/config/providers/force_update_provider.c.dart'; +import 'package:ion/app/features/core/model/app_update_type.dart'; +import 'package:ion/app/features/core/views/pages/app_update_modal.dart'; +import 'package:ion/app/router/app_routes.c.dart'; +import 'package:ion/app/router/utils/show_simple_bottom_sheet.dart'; + +void useAppUpdate(WidgetRef ref) { + final isUpdateModalVisible = useState(false); + final forceUpdateState = ref.watch(forceUpdateProvider); + + useEffect( + () { + if (forceUpdateState.shouldShowUpdateModal && !isUpdateModalVisible.value) { + isUpdateModalVisible.value = true; + + showSimpleBottomSheet( + isDismissible: false, + context: rootNavigatorKey.currentContext!, + child: const AppUpdateModal( + appUpdateType: AppUpdateType.updateRequired, + ), + ); + } else if (!forceUpdateState.shouldShowUpdateModal && isUpdateModalVisible.value) { + Navigator.of(rootNavigatorKey.currentContext!).pop(); + isUpdateModalVisible.value = false; + } + return null; + }, + [forceUpdateState], + ); +} diff --git a/lib/app/exceptions/exceptions.dart b/lib/app/exceptions/exceptions.dart index 124f8ff4c..f875b1826 100644 --- a/lib/app/exceptions/exceptions.dart +++ b/lib/app/exceptions/exceptions.dart @@ -212,3 +212,16 @@ class ConversationNotFoundException extends IONException { class ParticipantNotFoundException extends IONException { ParticipantNotFoundException() : super(10044, 'Failed to find conversation participant'); } + +class ConfigPlatformNotSupportException extends IONException { + ConfigPlatformNotSupportException() : super(10045, 'Platform not supported'); +} + +class ForceUpdateCouldntLaunchUrlException extends IONException { + ForceUpdateCouldntLaunchUrlException({required String url}) + : super(10046, 'Could not launch $url'); +} + +class ForceUpdateFetchConfigException extends IONException { + ForceUpdateFetchConfigException() : super(10047, 'Failed to get version config'); +} diff --git a/lib/app/features/config/providers/config_provider.c.dart b/lib/app/features/config/providers/config_provider.c.dart new file mode 100644 index 000000000..6c9d613d5 --- /dev/null +++ b/lib/app/features/config/providers/config_provider.c.dart @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'dart:io'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/exceptions/exceptions.dart'; +import 'package:ion/app/features/core/providers/dio_provider.c.dart'; +import 'package:ion/app/features/core/providers/env_provider.c.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'config_provider.c.g.dart'; + +enum ConfigName { + requiredAndroidAppVersion, + requiredIosAppVersion, + requiredMacosAppVersion, + requiredWindowsAppVersion, + requiredLinuxAppVersion; + + @override + String toString() => switch (this) { + ConfigName.requiredAndroidAppVersion => 'required_android_app_version', + ConfigName.requiredIosAppVersion => 'required_ios_app_version', + ConfigName.requiredMacosAppVersion => 'required_macos_app_version', + ConfigName.requiredWindowsAppVersion => 'required_windows_app_version', + ConfigName.requiredLinuxAppVersion => 'required_linux_app_version', + }; +} + +@riverpod +Future configForPlatform(Ref ref) async { + final dio = ref.watch(dioProvider); + + ConfigName getPlatformConfigName() { + if (Platform.isAndroid) { + return ConfigName.requiredAndroidAppVersion; + } else if (Platform.isIOS) { + return ConfigName.requiredIosAppVersion; + } else if (Platform.isMacOS) { + return ConfigName.requiredMacosAppVersion; + } else if (Platform.isWindows) { + return ConfigName.requiredWindowsAppVersion; + } else if (Platform.isLinux) { + return ConfigName.requiredLinuxAppVersion; + } else { + throw ConfigPlatformNotSupportException(); + } + } + + final baseUrl = ref.watch(envProvider.notifier).get(EnvVariable.ION_ORIGIN); + final configName = getPlatformConfigName(); + + final path = '$baseUrl/v1/config/$configName'; + + try { + final response = await dio.get(path); + if (response.data == null) { + throw ForceUpdateFetchConfigException(); + } + return response.data!; + } catch (e) { + throw ForceUpdateFetchConfigException(); + } +} diff --git a/lib/app/features/config/providers/force_update_last_sync_date_provider.c.dart b/lib/app/features/config/providers/force_update_last_sync_date_provider.c.dart new file mode 100644 index 000000000..d087ed781 --- /dev/null +++ b/lib/app/features/config/providers/force_update_last_sync_date_provider.c.dart @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:ion/app/services/storage/local_storage.c.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'force_update_last_sync_date_provider.c.g.dart'; + +@Riverpod(keepAlive: true) +class ForceUpdateLastSyncDateNotifier extends _$ForceUpdateLastSyncDateNotifier { + static const String forceUpdateLastSyncDateKey = 'forceUpdateLastSyncDateKey'; + + @override + DateTime? build() { + final storedDate = ref.watch(localStorageProvider).getString(forceUpdateLastSyncDateKey); + if (storedDate != null) { + return DateTime.parse(storedDate); + } + return null; + } + + void updateLastSyncDate(DateTime date) { + state = date; + ref.read(localStorageProvider).setString(forceUpdateLastSyncDateKey, date.toIso8601String()); + } +} diff --git a/lib/app/features/config/providers/force_update_provider.c.dart b/lib/app/features/config/providers/force_update_provider.c.dart new file mode 100644 index 000000000..ee476bcbf --- /dev/null +++ b/lib/app/features/config/providers/force_update_provider.c.dart @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:ion/app/features/config/providers/config_provider.c.dart'; +import 'package:ion/app/features/config/providers/force_update_last_sync_date_provider.c.dart'; +import 'package:ion/app/features/config/providers/force_update_util_provider.c.dart'; +import 'package:ion/app/features/core/providers/app_lifecycle_provider.c.dart'; +import 'package:ion/app/features/core/providers/env_provider.c.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'force_update_provider.c.freezed.dart'; +part 'force_update_provider.c.g.dart'; + +@freezed +class ForceUpdateState with _$ForceUpdateState { + const factory ForceUpdateState({ + @Default(false) bool shouldShowUpdateModal, + }) = _ForceUpdateState; + + const ForceUpdateState._(); +} + +@Riverpod(keepAlive: true) +class ForceUpdate extends _$ForceUpdate { + @override + ForceUpdateState build() { + ref.listen( + appLifecycleProvider, + (previous, next) { + if (next == AppLifecycleState.resumed) { + _checkAndUpdateConfig(); + } + }, + fireImmediately: true, + ); + + return const ForceUpdateState(); + } + + Future _checkAndUpdateConfig() async { + await ref.read(envProvider.future); + + final refetchIntervalInMilliseconds = + ref.read(envProvider.notifier).get(EnvVariable.VERSIONS_CONFIG_REFETCH_INTERVAL); + + final lastSyncDate = ref.read(forceUpdateLastSyncDateNotifierProvider); + + if (lastSyncDate == null || + DateTime.now().difference(lastSyncDate).inMilliseconds >= refetchIntervalInMilliseconds) { + final remoteVersion = await ref.read(configForPlatformProvider.future); + + final packageInfo = await PackageInfo.fromPlatform(); + final localVersion = packageInfo.version; + + if (localVersion == remoteVersion) { + _updateState(showUpdateModal: false); + + ref + .read(forceUpdateLastSyncDateNotifierProvider.notifier) + .updateLastSyncDate(DateTime.now()); + } else if (ref + .read(forceUpdateServiceProvider) + .isVersionOutdated(localVersion, remoteVersion)) { + _updateState(showUpdateModal: true); + } + } + } + + void _updateState({required bool showUpdateModal}) { + state = state.copyWith(shouldShowUpdateModal: showUpdateModal); + } +} diff --git a/lib/app/features/config/providers/force_update_util_provider.c.dart b/lib/app/features/config/providers/force_update_util_provider.c.dart new file mode 100644 index 000000000..db44e01b9 --- /dev/null +++ b/lib/app/features/config/providers/force_update_util_provider.c.dart @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'dart:io'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/exceptions/exceptions.dart'; +import 'package:ion/app/features/core/providers/env_provider.c.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:url_launcher/url_launcher.dart'; + +part 'force_update_util_provider.c.g.dart'; + +class ForceUpdateUtil { + const ForceUpdateUtil(this.env); + + final Env env; + + Future handleForceUpdateRedirect() async { + if (Platform.isAndroid) { + final androidAppId = env.get(EnvVariable.ION_ANDROID_APP_ID); + final androidUrl = 'https://play.google.com/store/apps/details?id=$androidAppId'; + await _openUrl(androidUrl); + } else if (Platform.isIOS) { + final iosAppId = env.get(EnvVariable.ION_IOS_APP_ID); + final iosUrl = 'https://apps.apple.com/app/id$iosAppId'; + await _openUrl(iosUrl); + } else if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + const fallbackWebsite = 'https://example.com'; //TODO: Replace with the actual website + await _openUrl(fallbackWebsite); + } + } + + Future _openUrl(String url) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + throw ForceUpdateCouldntLaunchUrlException(url: url); + } + } + + bool isVersionOutdated(String localVersion, String remoteVersion) { + final localParts = localVersion.split('.').map(int.parse).toList(); + final remoteParts = remoteVersion.split('.').map(int.parse).toList(); + + for (var i = 0; i < remoteParts.length; i++) { + if (localParts.length <= i || localParts[i] < remoteParts[i]) { + return true; + } else if (localParts[i] > remoteParts[i]) { + return false; + } + } + return false; + } +} + +@riverpod +ForceUpdateUtil forceUpdateService(Ref ref) { + return ForceUpdateUtil(ref.watch(envProvider.notifier)); +} diff --git a/lib/app/features/core/providers/env_provider.c.dart b/lib/app/features/core/providers/env_provider.c.dart index eda45842c..f29249bbf 100644 --- a/lib/app/features/core/providers/env_provider.c.dart +++ b/lib/app/features/core/providers/env_provider.c.dart @@ -15,6 +15,7 @@ enum EnvVariable { SHOW_DEBUG_INFO, BANUBA_TOKEN, STORY_EXPIRATION_HOURS, + VERSIONS_CONFIG_REFETCH_INTERVAL, } @Riverpod(keepAlive: true) diff --git a/lib/app/features/core/views/pages/app_update_modal.dart b/lib/app/features/core/views/pages/app_update_modal.dart index 988a773ad..118aaed29 100644 --- a/lib/app/features/core/views/pages/app_update_modal.dart +++ b/lib/app/features/core/views/pages/app_update_modal.dart @@ -1,15 +1,16 @@ // SPDX-License-Identifier: ice License 1.0 import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:ion/app/components/button/button.dart'; import 'package:ion/app/components/card/info_card.dart'; import 'package:ion/app/components/screen_offset/screen_bottom_offset.dart'; import 'package:ion/app/components/screen_offset/screen_side_offset.dart'; import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/config/providers/force_update_util_provider.c.dart'; import 'package:ion/app/features/core/model/app_update_type.dart'; -import 'package:ion/app/router/utils/show_simple_bottom_sheet.dart'; -class AppUpdateModal extends StatelessWidget { +class AppUpdateModal extends ConsumerWidget { const AppUpdateModal({ required this.appUpdateType, super.key, @@ -18,7 +19,7 @@ class AppUpdateModal extends StatelessWidget { final AppUpdateType appUpdateType; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -36,15 +37,10 @@ class AppUpdateModal extends StatelessWidget { leadingIcon: appUpdateType.buttonIconAsset.icon( color: context.theme.appColors.onPrimaryAccent, ), - onPressed: () { - Navigator.of(context, rootNavigator: true).pop(); + onPressed: () async { if (appUpdateType == AppUpdateType.updateRequired) { - showSimpleBottomSheet( - context: context, - child: const AppUpdateModal( - appUpdateType: AppUpdateType.upToDate, - ), - ); + final forceUpdateService = ref.read(forceUpdateServiceProvider); + await forceUpdateService.handleForceUpdateRedirect(); } }, label: Text(appUpdateType.getActionTitle(context)), diff --git a/lib/app/features/feed/views/pages/feed_page/feed_page.dart b/lib/app/features/feed/views/pages/feed_page/feed_page.dart index f06c075af..caf524c65 100644 --- a/lib/app/features/feed/views/pages/feed_page/feed_page.dart +++ b/lib/app/features/feed/views/pages/feed_page/feed_page.dart @@ -1,7 +1,5 @@ // SPDX-License-Identifier: ice License 1.0 -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -9,8 +7,6 @@ import 'package:ion/app/components/screen_offset/screen_top_offset.dart'; import 'package:ion/app/components/scroll_view/load_more_builder.dart'; import 'package:ion/app/components/scroll_view/pull_to_refresh_builder.dart'; import 'package:ion/app/extensions/extensions.dart'; -import 'package:ion/app/features/core/model/app_update_type.dart'; -import 'package:ion/app/features/core/views/pages/app_update_modal.dart'; import 'package:ion/app/features/feed/data/models/feed_category.dart'; import 'package:ion/app/features/feed/providers/feed_current_filter_provider.c.dart'; import 'package:ion/app/features/feed/providers/feed_posts_data_source_provider.c.dart'; @@ -22,10 +18,8 @@ import 'package:ion/app/features/feed/views/pages/feed_page/components/feed_post import 'package:ion/app/features/feed/views/pages/feed_page/components/stories/stories.dart'; import 'package:ion/app/features/feed/views/pages/feed_page/components/trending_videos/trending_videos.dart'; import 'package:ion/app/features/nostr/providers/entities_paged_data_provider.c.dart'; -import 'package:ion/app/hooks/use_on_init.dart'; import 'package:ion/app/hooks/use_scroll_top_on_tab_press.dart'; import 'package:ion/app/router/components/navigation_app_bar/collapsing_app_bar.dart'; -import 'package:ion/app/router/utils/show_simple_bottom_sheet.dart'; class FeedPage extends HookConsumerWidget { const FeedPage({super.key}); @@ -39,21 +33,6 @@ class FeedPage extends HookConsumerWidget { .select((state) => (state?.hasMore).falseOrValue), ); - useOnInit( - () { - if (Random().nextInt(10) == 0) { - showSimpleBottomSheet( - isDismissible: false, - context: context, - child: const AppUpdateModal( - appUpdateType: AppUpdateType.updateRequired, - ), - ); - } - }, - [], - ); - useScrollTopOnTabPress(context, scrollController: scrollController); final slivers = [ diff --git a/lib/app/router/components/app_router_builder.dart b/lib/app/router/components/app_router_builder.dart index 471a1a6a2..b45103b66 100644 --- a/lib/app/router/components/app_router_builder.dart +++ b/lib/app/router/components/app_router_builder.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/components/app_update_handler/hooks/use_app_update.dart'; import 'package:ion/app/components/global_notification_bar/global_notification_bar.dart'; import 'package:ion/app/components/global_notification_bar/providers/global_notification_provider.c.dart'; import 'package:ion/app/extensions/extensions.dart'; @@ -17,6 +18,7 @@ class AppRouterBuilder extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final notification = ref.watch(globalNotificationProvider); final isShowSafeArea = useState(false); + useAppUpdate(ref); if (notification.isShow) { isShowSafeArea.value = true; diff --git a/melos.yaml b/melos.yaml index 51e6a1bf6..f9255359c 100644 --- a/melos.yaml +++ b/melos.yaml @@ -5,7 +5,7 @@ packages: - packages/** ignore: - - 'packages/**/example' + - "packages/**/example" command: bootstrap: diff --git a/packages/ion_identity_client/lib/src/signer/dtos/user_registration_challenge.c.g.dart b/packages/ion_identity_client/lib/src/signer/dtos/user_registration_challenge.c.g.dart index 65685a747..6e2fe87e1 100644 --- a/packages/ion_identity_client/lib/src/signer/dtos/user_registration_challenge.c.g.dart +++ b/packages/ion_identity_client/lib/src/signer/dtos/user_registration_challenge.c.g.dart @@ -6,8 +6,7 @@ part of 'user_registration_challenge.c.dart'; // JsonSerializableGenerator // ************************************************************************** -UserRegistrationChallenge _$UserRegistrationChallengeFromJson( - Map json) => +UserRegistrationChallenge _$UserRegistrationChallengeFromJson(Map json) => UserRegistrationChallenge( json['temporaryAuthenticationToken'] as String?, RelyingParty.fromJson(json['rp'] as Map), @@ -24,21 +23,17 @@ UserRegistrationChallenge _$UserRegistrationChallengeFromJson( json['authenticatorSelection'] as Map), json['attestation'] as String, (json['pubKeyCredParams'] as List) - .map((e) => - PublicKeyCredentialParameters.fromJson(e as Map)) + .map((e) => PublicKeyCredentialParameters.fromJson(e as Map)) .toList(), (json['excludeCredentials'] as List) - .map((e) => - PublicKeyCredentialDescriptor.fromJson(e as Map)) + .map((e) => PublicKeyCredentialDescriptor.fromJson(e as Map)) .toList(), (json['allowedRecoveryCredentials'] as List?) - ?.map((e) => - AllowedRecoveryCredential.fromJson(e as Map)) + ?.map((e) => AllowedRecoveryCredential.fromJson(e as Map)) .toList(), ); -Map _$UserRegistrationChallengeToJson( - UserRegistrationChallenge instance) => +Map _$UserRegistrationChallengeToJson(UserRegistrationChallenge instance) => { 'temporaryAuthenticationToken': instance.temporaryAuthenticationToken, 'rp': instance.rp.toJson(), @@ -48,10 +43,8 @@ Map _$UserRegistrationChallengeToJson( 'challenge': instance.challenge, 'authenticatorSelection': instance.authenticatorSelection?.toJson(), 'attestation': instance.attestation, - 'pubKeyCredParams': - instance.pubKeyCredParams.map((e) => e.toJson()).toList(), - 'excludeCredentials': - instance.excludeCredentials.map((e) => e.toJson()).toList(), + 'pubKeyCredParams': instance.pubKeyCredParams.map((e) => e.toJson()).toList(), + 'excludeCredentials': instance.excludeCredentials.map((e) => e.toJson()).toList(), 'allowedRecoveryCredentials': instance.allowedRecoveryCredentials?.map((e) => e.toJson()).toList(), };