Skip to content

Commit

Permalink
feat: force update (#470)
Browse files Browse the repository at this point in the history
## Description
This PR introduces force update flow on version mismatch

## Additional Notes
Versions config re-fetch every 8 hours and on app launch
Showing of Update App modal on versions mismatch
Support of all platforms
Re-fetch config and recheck versions/hide popup after app update

## Type of Change
- [ ] Bug fix
- [x] New feature
- [ ] Breaking change
- [x] Refactoring
- [ ] Documentation
- [ ] Chore

## Screenshots (if applicable)
<img width="180" alt="Screenshot 2024-12-24 at 09 58 10"
src="https://github.com/user-attachments/assets/5c2d7e28-d98b-43ad-8e9e-6e1a3eb20e32"
/>
  • Loading branch information
ice-hector authored Dec 31, 2024
1 parent fcabfc7 commit 642161a
Show file tree
Hide file tree
Showing 12 changed files with 292 additions and 47 deletions.
36 changes: 36 additions & 0 deletions lib/app/components/app_update_handler/hooks/use_app_update.dart
Original file line number Diff line number Diff line change
@@ -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<void>(
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],
);
}
13 changes: 13 additions & 0 deletions lib/app/exceptions/exceptions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
64 changes: 64 additions & 0 deletions lib/app/features/config/providers/config_provider.c.dart
Original file line number Diff line number Diff line change
@@ -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<String> 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<String>(EnvVariable.ION_ORIGIN);
final configName = getPlatformConfigName();

final path = '$baseUrl/v1/config/$configName';

try {
final response = await dio.get<String>(path);
if (response.data == null) {
throw ForceUpdateFetchConfigException();
}
return response.data!;
} catch (e) {
throw ForceUpdateFetchConfigException();
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
76 changes: 76 additions & 0 deletions lib/app/features/config/providers/force_update_provider.c.dart
Original file line number Diff line number Diff line change
@@ -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<AppLifecycleState>(
appLifecycleProvider,
(previous, next) {
if (next == AppLifecycleState.resumed) {
_checkAndUpdateConfig();
}
},
fireImmediately: true,
);

return const ForceUpdateState();
}

Future<void> _checkAndUpdateConfig() async {
await ref.read(envProvider.future);

final refetchIntervalInMilliseconds =
ref.read(envProvider.notifier).get<int>(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);
}
}
Original file line number Diff line number Diff line change
@@ -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<void> handleForceUpdateRedirect() async {
if (Platform.isAndroid) {
final androidAppId = env.get<String>(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<String>(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<void> _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));
}
1 change: 1 addition & 0 deletions lib/app/features/core/providers/env_provider.c.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ enum EnvVariable {
SHOW_DEBUG_INFO,
BANUBA_TOKEN,
STORY_EXPIRATION_HOURS,
VERSIONS_CONFIG_REFETCH_INTERVAL,
}

@Riverpod(keepAlive: true)
Expand Down
18 changes: 7 additions & 11 deletions lib/app/features/core/views/pages/app_update_modal.dart
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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: [
Expand All @@ -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<void>(
context: context,
child: const AppUpdateModal(
appUpdateType: AppUpdateType.upToDate,
),
);
final forceUpdateService = ref.read(forceUpdateServiceProvider);
await forceUpdateService.handleForceUpdateRedirect();
}
},
label: Text(appUpdateType.getActionTitle(context)),
Expand Down
21 changes: 0 additions & 21 deletions lib/app/features/feed/views/pages/feed_page/feed_page.dart
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
// 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';
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';
Expand All @@ -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});
Expand All @@ -39,21 +33,6 @@ class FeedPage extends HookConsumerWidget {
.select((state) => (state?.hasMore).falseOrValue),
);

useOnInit(
() {
if (Random().nextInt(10) == 0) {
showSimpleBottomSheet<void>(
isDismissible: false,
context: context,
child: const AppUpdateModal(
appUpdateType: AppUpdateType.updateRequired,
),
);
}
},
<Object>[],
);

useScrollTopOnTabPress(context, scrollController: scrollController);

final slivers = [
Expand Down
Loading

0 comments on commit 642161a

Please sign in to comment.