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: force update #470

Merged
merged 8 commits into from
Dec 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
ice-hector marked this conversation as resolved.
Show resolved Hide resolved

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) {
ice-hector marked this conversation as resolved.
Show resolved Hide resolved
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();
ice-hector marked this conversation as resolved.
Show resolved Hide resolved
}
},
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) {
ice-kreios marked this conversation as resolved.
Show resolved Hide resolved
_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