Skip to content
Open
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
1 change: 1 addition & 0 deletions lib/config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ abstract class Config {
// Wallet-app specific endpoints
static const dapps = "/wallet-app/dapps/dapps.json";
static dappLocale(String locale) => "/wallet-app/dapps/locales/$locale.json";
static const appVersionCheck = "/wallet-app/version-check.json";

// General/ecosystem endpoints (shared across Qubic ecosystem)
static const smartContracts = "/general/data/smart_contracts.json";
Expand Down
2 changes: 2 additions & 0 deletions lib/di.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'package:qubic_wallet/resources/qubic_cmd.dart';
import 'package:qubic_wallet/resources/secure_storage.dart';
import 'package:qubic_wallet/services/biometric_service.dart';
import 'package:qubic_wallet/services/wallet_connect_service.dart';
import 'package:qubic_wallet/stores/app_update_store.dart';
import 'package:qubic_wallet/stores/application_store.dart';
import 'package:qubic_wallet/stores/network_store.dart';
import 'package:qubic_wallet/stores/root_jailbreak_flag_store.dart';
Expand Down Expand Up @@ -43,6 +44,7 @@ Future<void> setupDI() async {
await getIt<SecureStorage>().initialize();
getIt.registerSingleton<HiveStorage>(HiveStorage());
await getIt<HiveStorage>().initialize();
getIt.registerSingleton<AppUpdateStore>(AppUpdateStore());

//Providers
getIt.registerSingleton<GlobalSnackBar>(GlobalSnackBar());
Expand Down
12 changes: 11 additions & 1 deletion lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1047,5 +1047,15 @@
"androidDeprecationDialogMessageBefore": "Future updates will require Android 7.0 or higher. To continue receiving updates and ensure long-term wallet access, please ",
"androidDeprecationDialogMessageHighlight": "back up your private seeds or export your wallet vault",
"androidDeprecationDialogMessageAfter": " from Settings.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@ahmed-tarek-salem 10 new keys added only to app_en.arb. Missing from app_de/es/fr/ru/tr/vi/zh.arb. Non-English users get fallback English on a forced upgrade gate.

"androidDeprecationDialogButton": "Got it"
"androidDeprecationDialogButton": "Got it",
"updateRequiredTitle": "Update Required",
"updateRequiredMessage": "A new version of Qubic Wallet is required to continue. Please update to the latest version.",
"updateAvailableTitle": "Update Available",
"updateAvailableMessage": "A new version of Qubic Wallet is available with improvements and bug fixes.",
"updateButton": "Update Now",
"laterButton": "Remind Me Later",
"ignoreVersionButton": "Skip This Version",
"updateScreenCurrentVersion": "Current Version",
"updateScreenNewVersion": "New Version",
"updateScreenWhatsNew": "What's New"
}
2 changes: 2 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import 'package:qubic_wallet/stores/application_store.dart';
import 'package:qubic_wallet/stores/wallet_content_store.dart';
import 'package:qubic_wallet/stores/root_jailbreak_flag_store.dart';
import 'package:qubic_wallet/stores/settings_store.dart';
import 'package:qubic_wallet/stores/app_update_store.dart';
import 'package:qubic_wallet/styles/button_styles.dart';
import 'package:universal_platform/universal_platform.dart';

Expand All @@ -44,6 +45,7 @@ Future<void> main() async {
getIt.get<SettingsStore>().setBuildNumber(packageInfo.buildNumber);

getIt.get<ApplicationStore>().checkWalletIsInitialized();
await getIt.get<AppUpdateStore>().checkForUpdate();
} catch (e) {
appLogger.e(e.toString());
}
Expand Down
80 changes: 80 additions & 0 deletions lib/models/app_version_check_model.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import 'package:universal_platform/universal_platform.dart';

enum UpdateType {
force,
flexible,
none,
}

class AppVersionCheckModel {
final String version;
final String? releaseNotes;
final bool showLaterButton;
final bool showIgnoreButton;
final Map<String, String> updateUrls;
final List<String> platforms;

static const List<String> _defaultPlatforms = [
'android',
'ios',
];

static const Map<String, String> _defaultUpdateUrls = {
'ios': 'https://apps.apple.com/app/qubic-wallet/id6502265811',
'android': 'https://play.google.com/store/apps/details?id=org.qubic.wallet',
};

AppVersionCheckModel({
required this.version,
this.releaseNotes,
this.showLaterButton = false,
this.showIgnoreButton = false,
this.updateUrls = _defaultUpdateUrls,
this.platforms = _defaultPlatforms,
});

/// Derive update type from button visibility:
/// - If both buttons are hidden -> force update
/// - If any button is visible -> flexible update
UpdateType get updateType {
if (!showLaterButton && !showIgnoreButton) {
return UpdateType.force;
}
return UpdateType.flexible;
}

static AppVersionCheckModel? fromJson(Map<String, dynamic>? json) {
if (json == null || json.isEmpty) {
return null;
}

final updateUrls = json['update_urls'] != null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@ahmed-tarek-salem The App Store and Play Store URLs are constant — they're fully determined by our fixed bundle ID / package name (org.qubic.wallet), so we can build them in the app instead of reading them from the remote JSON. Letting the remote payload supply the URL means anyone who can write to (or MITM) static.qubic.org can serve a forced update (show_later_button: false + show_ignore_button: false) that routes every user — on a screen they can't dismiss — to an attacker-controlled APK or seed-entry phishing page.

? Map<String, String>.from(json['update_urls'])
: _defaultUpdateUrls;

final platforms = json['platforms'] != null
? List<String>.from(json['platforms'])
: _defaultPlatforms;

return AppVersionCheckModel(
version: json['version'],
releaseNotes: json['release_notes'],
showLaterButton: json['show_later_button'] ?? false,
showIgnoreButton: json['show_ignore_button'] ?? false,
updateUrls: updateUrls,
platforms: platforms,
);
}

bool isApplicableForCurrentPlatform() {
if (UniversalPlatform.isIOS) return platforms.contains('ios');
if (UniversalPlatform.isAndroid) return platforms.contains('android');
return false;
}

String? getUpdateUrlForPlatform() {
if (UniversalPlatform.isIOS) return updateUrls['ios'];
if (UniversalPlatform.isAndroid) return updateUrls['android'];
return null;
}
}
99 changes: 99 additions & 0 deletions lib/pages/update/app_update_screen.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:go_router/go_router.dart';
import 'package:qubic_wallet/di.dart';
import 'package:qubic_wallet/flutter_flow/theme_paddings.dart';
import 'package:qubic_wallet/helpers/app_logger.dart';
import 'package:qubic_wallet/l10n/l10n.dart';
import 'package:qubic_wallet/models/app_version_check_model.dart';
import 'package:qubic_wallet/stores/app_update_store.dart';
import 'package:qubic_wallet/stores/settings_store.dart';
import 'package:qubic_wallet/styles/text_styles.dart';
import 'package:qubic_wallet/styles/themed_controls.dart';
import 'package:url_launcher/url_launcher_string.dart';

part 'components/app_update_logo.dart';
part 'components/app_update_header.dart';
part 'components/app_update_info_card.dart';
part 'components/app_update_buttons.dart';

class AppUpdateScreen extends StatelessWidget {
const AppUpdateScreen({super.key});

@override
Widget build(BuildContext context) {
final appUpdateStore = getIt<AppUpdateStore>();
final settingsStore = getIt<SettingsStore>();

return Observer(
builder: (context) {
final versionInfo = appUpdateStore.currentVersionInfo;

if (versionInfo == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
context.go('/');
}
});
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}

final isForceUpdate = versionInfo.updateType == UpdateType.force;

return Scaffold(
backgroundColor: LightThemeColors.background,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(ThemePaddings.hugePadding),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Spacer(),
const _AppUpdateLogo(),
_AppUpdateHeader(isForceUpdate: isForceUpdate),
ThemedControls.spacerVerticalBig(),
_AppUpdateInfoCard(
versionInfo: versionInfo,
currentVersion: settingsStore.versionInfo,
onUpdatePressed: () => _launchUpdateUrl(versionInfo),
),
const Spacer(),
_AppUpdateButtons(
versionInfo: versionInfo,
onUpdatePressed: () => _launchUpdateUrl(versionInfo),
onLaterPressed: () {
appUpdateStore.handleLaterAction();
context.go('/');
},
onIgnorePressed: () {
appUpdateStore.handleIgnoreAction(versionInfo.version);
context.go('/');
},
),
],
),
),
),
);
},
);
}

Future<void> _launchUpdateUrl(AppVersionCheckModel versionInfo) async {
final url = versionInfo.getUpdateUrlForPlatform();
if (url == null) {
appLogger.e('[AppUpdateScreen] No update URL available for platform');
return;
}

try {
await launchUrlString(url, mode: LaunchMode.externalApplication);
} catch (e) {
appLogger.e('[AppUpdateScreen] Failed to launch update URL: $e');
return;
}
}
}
82 changes: 82 additions & 0 deletions lib/pages/update/components/app_update_buttons.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
part of '../app_update_screen.dart';

class _AppUpdateButtons extends StatelessWidget {
final AppVersionCheckModel versionInfo;
final VoidCallback onUpdatePressed;
final VoidCallback onLaterPressed;
final VoidCallback onIgnorePressed;

const _AppUpdateButtons({
required this.versionInfo,
required this.onUpdatePressed,
required this.onLaterPressed,
required this.onIgnorePressed,
});

bool get _hasAllOptions =>
versionInfo.showLaterButton && versionInfo.showIgnoreButton;

@override
Widget build(BuildContext context) {
final l10n = l10nOf(context);

// Scenario 1: Force update - only Update button at bottom
if (versionInfo.updateType == UpdateType.force) {
return ThemedControls.primaryButtonBig(
onPressed: onUpdatePressed,
text: l10n.updateButton,
);
}

final showLater = versionInfo.showLaterButton;
final showIgnore = versionInfo.showIgnoreButton;

// Scenario 3: All three options - Update in card, Later & Skip in row
if (_hasAllOptions) {
return Row(
children: [
Expanded(
child: ThemedControls.transparentButtonNormal(
onPressed: onLaterPressed,
text: l10n.laterButton,
),
),
ThemedControls.spacerHorizontalNormal(),
Expanded(
child: ThemedControls.dangerButtonBigWithClild(
onPressed: onIgnorePressed,
child: Text(
l10n.ignoreVersionButton,
style: TextStyles.destructiveButtonText,
),
),
),
],
);
}

// Scenario 2: Two options - Update first, then the other option
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ThemedControls.primaryButtonBig(
onPressed: onUpdatePressed,
text: l10n.updateButton,
),
if (showLater)
ThemedControls.transparentButtonNormal(
onPressed: onLaterPressed,
text: l10n.laterButton,
),
if (showIgnore)
ThemedControls.dangerButtonBigWithClild(
onPressed: onIgnorePressed,
child: Text(
l10n.ignoreVersionButton,
style: TextStyles.destructiveButtonText,
),
),
],
);
}
}
35 changes: 35 additions & 0 deletions lib/pages/update/components/app_update_header.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
part of '../app_update_screen.dart';

class _AppUpdateHeader extends StatelessWidget {
final bool isForceUpdate;

const _AppUpdateHeader({required this.isForceUpdate});

@override
Widget build(BuildContext context) {
final l10n = l10nOf(context);

return Column(
children: [
Text(
isForceUpdate ? l10n.updateRequiredTitle : l10n.updateAvailableTitle,
style: TextStyles.textEnormous.copyWith(
fontWeight: FontWeight.bold,
color: LightThemeColors.primary,
),
textAlign: TextAlign.center,
),
ThemedControls.spacerVerticalBig(),
Text(
isForceUpdate
? l10n.updateRequiredMessage
: l10n.updateAvailableMessage,
style: TextStyles.textNormal.copyWith(
color: LightThemeColors.textColorSecondary,
),
textAlign: TextAlign.center,
),
],
);
}
}
Loading