From fc0043ce953608acdc814070240162d4e2d16d89 Mon Sep 17 00:00:00 2001 From: Jigar-f Date: Thu, 7 May 2026 13:54:25 +0530 Subject: [PATCH 1/7] Started working on restore purchase flow --- assets/locales/en.po | 9 ++++++ lib/core/services/app_purchase.dart | 41 ++++++++++++++++++++++++- lib/features/auth/sign_in_email.dart | 2 +- lib/features/plans/plans.dart | 37 ++++++++++++++++++----- lib/features/setting/setting.dart | 45 ++++++++++++++++++++++++---- 5 files changed, 119 insertions(+), 15 deletions(-) diff --git a/assets/locales/en.po b/assets/locales/en.po index edacdb9175..8a4379f2a1 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -1014,6 +1014,15 @@ msgstr "Enter an Activation Code" msgid "restore_purchase" msgstr "Restore Purchase" +msgid "already_purchased" +msgstr "Already purchased?" + +msgid "restore_purchase_success" +msgstr "Purchase restored successfully." + +msgid "restore_purchase_no_purchases" +msgstr "No purchases were found to restore." + msgid "do_private_server_setup" msgstr "DO Private Server Setup" diff --git a/lib/core/services/app_purchase.dart b/lib/core/services/app_purchase.dart index 69ca1e4178..c5331857c0 100644 --- a/lib/core/services/app_purchase.dart +++ b/lib/core/services/app_purchase.dart @@ -28,6 +28,15 @@ class AppPurchase { // Track what plan the user selected String? _pendingPlanId; + // True while a restore flow is in progress; restored receipts should be + // acknowledged so the backend can reassociate the user, even when the + // device has no active subscription cached locally. + bool _isRestoreFlow = false; + + // Set when a restore flow processes at least one purchase, so we can + // report "no purchases found" if the platform yields no receipts. + bool _restoreProcessedAny = false; + void init() { if (PlatformUtils.isDesktop || _subscription != null) { return; @@ -173,6 +182,32 @@ class AppPurchase { } } + /// Restores prior purchases from the platform store. + /// + /// On iOS this triggers StoreKit's restore flow; on Android it surfaces + /// active Google Play Billing purchases through the same purchase stream. + /// Restored receipts are acknowledged with the backend so the current user + /// (or the user matching the receipt, if unauthenticated) is associated + /// with the subscription. + Future restorePurchases({ + required PaymentSuccessCallback onSuccess, + required PaymentErrorCallback onError, + }) async { + _onSuccess = onSuccess; + _onError = onError; + _isRestoreFlow = true; + _pendingPlanId = null; + + try { + appLogger.info('[AppPurchase] Initiating restore purchases'); + await _inAppPurchase.restorePurchases(); + } catch (e, st) { + appLogger.error('[AppPurchase] Error restoring purchases', e, st); + _isRestoreFlow = false; + _onError?.call('Error restoring purchases: $e'); + } + } + Future _onPurchaseUpdates(List purchases) async { appLogger.info( '[AppPurchase] Received purchase updates: ${purchases.length}', @@ -214,7 +249,9 @@ class AppPurchase { status == PurchaseStatus.restored) { /// Apple sends purchase updates for previously purchased items when the app starts. /// This check prevents processing the same subscription multiple times. - if (await _checkIfAlreadyPurchased()) { + /// Skip the guard during an explicit restore flow — the whole point is + /// to re-acknowledge so the backend can reassociate the user. + if (!_isRestoreFlow && await _checkIfAlreadyPurchased()) { appLogger.info( '[AppPurchase] User has already purchased the subscription. Finalizing purchase without processing.', ); @@ -272,6 +309,7 @@ class AppPurchase { appLogger.error('[AppPurchase] Error finalizing purchase: $e', e); } finally { _pendingPlanId = null; + _isRestoreFlow = false; } } @@ -362,5 +400,6 @@ class AppPurchase { _onSuccess = null; _onError = null; _pendingPlanId = null; + _isRestoreFlow = false; } } diff --git a/lib/features/auth/sign_in_email.dart b/lib/features/auth/sign_in_email.dart index 394cf0d287..9fe19a16cc 100644 --- a/lib/features/auth/sign_in_email.dart +++ b/lib/features/auth/sign_in_email.dart @@ -90,7 +90,7 @@ class SignInEmail extends HookConsumerWidget { boldOnPressed: () { appRouter.push(Plans()); }, - ) + ), ], ), ), diff --git a/lib/features/plans/plans.dart b/lib/features/plans/plans.dart index 7ba6df6f29..682c6d3be5 100644 --- a/lib/features/plans/plans.dart +++ b/lib/features/plans/plans.dart @@ -10,6 +10,7 @@ import 'package:lantern/core/services/app_purchase.dart'; import 'package:lantern/core/services/injection_container.dart'; import 'package:lantern/core/utils/formatter.dart'; import 'package:lantern/core/utils/screen_utils.dart'; +import 'package:lantern/core/widgets/app_rich_text.dart'; import 'package:lantern/core/widgets/loading_indicator.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; @@ -126,6 +127,17 @@ class _PlansState extends ConsumerState { onPressed: onGetLanternProTap, ), ), + if (isStoreVersion()) ...[ + SizedBox(height: defaultSize), + Center( + child: AppRichText( + texts: '${'already_purchased'.i18n} ', + boldTexts: 'restore_purchase'.i18n, + boldUnderline: true, + boldOnPressed: _restorePurchaseFlow, + ), + ), + ], if (PlatformUtils.isIOS) ...{ SizedBox(height: defaultSize), Padding( @@ -207,14 +219,6 @@ class _PlansState extends ConsumerState { ); }, ), - DividerSpace(), - AppTile( - icon: AppImagePaths.restorePurchase, - label: 'restore_purchase'.i18n, - onPressed: () { - appRouter.popAndPush(SignInEmail()); - }, - ), ], ); }, @@ -464,4 +468,21 @@ class _PlansState extends ConsumerState { ); } } + + Future _restorePurchaseFlow() async { + try { + final appPurchase = sl(); + await appPurchase.restorePurchases( + onSuccess: (_) async {}, + onError: (error) {}, + ); + } catch (e, st) { + appLogger.error('Error initiating restore purchase flow: $e', st); + AppDialog.errorDialog( + context: context, + title: 'error'.i18n, + content: e.localizedDescription, + ); + } + } } diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index 8db7a17a7b..dadf12665a 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -12,6 +12,7 @@ import 'package:lantern/features/home/provider/home_notifier.dart'; import 'package:lantern/features/setting/appearance.dart' show appearanceModeLabel, showAppearanceBottomSheet; +import '../../core/services/app_purchase.dart'; import '../../core/services/injection_container.dart'; enum _SettingType { @@ -24,6 +25,7 @@ enum _SettingType { getPro, checkForUpdates, browserUnbounded, + restorePurchase, } @RoutePage(name: 'Setting') @@ -44,7 +46,8 @@ class _SettingState extends ConsumerState { final appSetting = ref.watch(appSettingProvider); - final hasProSession = (user?.legacyUserData.isPro ?? false) && + final hasProSession = + (user?.legacyUserData.isPro ?? false) && (user?.legacyUserData.unpassRegistered ?? false); final isAuthenticated = appSetting.userLoggedIn || hasProSession; @@ -85,9 +88,10 @@ class _SettingState extends ConsumerState { Text('account'.i18n), if (isUserPro || isExpired) SubscriptionTags( - type: isUserPro - ? SubscriptionTagType.pro - : SubscriptionTagType.expired) + type: isUserPro + ? SubscriptionTagType.pro + : SubscriptionTagType.expired, + ), ], ), icon: AppImagePaths.accountSetting, @@ -174,6 +178,15 @@ class _SettingState extends ConsumerState { icon: AppImagePaths.star, onPressed: () => settingMenuTap(_SettingType.getPro), ), + if (isStoreVersion() && !isUserPro) ...[ + DividerSpace(), + AppTile( + label: 'restore_purchase'.i18n, + icon: AppImagePaths.restorePurchase, + onPressed: () => + settingMenuTap(_SettingType.restorePurchase), + ), + ], ], ), ), @@ -263,7 +276,9 @@ class _SettingState extends ConsumerState { final isPro = user.legacyUserData.isPro; if (isPro && !userSignedIn) { await showProAccountFlowDialog( - context: context, hasEmail: email.isNotEmpty); + context: context, + hasEmail: email.isNotEmpty, + ); return; } @@ -275,6 +290,9 @@ class _SettingState extends ConsumerState { case _SettingType.browserUnbounded: // TODO: Handle this case. throw UnimplementedError(); + case _SettingType.restorePurchase: + _restorePurchaseFlow(); + break; } } @@ -290,4 +308,21 @@ class _SettingState extends ConsumerState { ); } } + + Future _restorePurchaseFlow() async { + try { + final appPurchase = sl(); + await appPurchase.restorePurchases( + onSuccess: (_) async {}, + onError: (error) {}, + ); + } catch (e, st) { + appLogger.error('Error initiating restore purchase flow: $e', st); + AppDialog.errorDialog( + context: context, + title: 'error'.i18n, + content: e.localizedDescription, + ); + } + } } From e249ffd5c69301fb1369af44fccb95616b12e1e7 Mon Sep 17 00:00:00 2001 From: Jigar-f Date: Fri, 8 May 2026 16:38:31 +0530 Subject: [PATCH 2/7] Added restore purchase endpoint --- .../lantern/handler/MethodHandler.kt | 21 ++++++ go.mod | 2 +- go.sum | 2 - ios/Runner/Handlers/MethodHandler.swift | 30 +++++++++ lantern-core/core.go | 32 +++++++++ lantern-core/mobile/mobile.go | 41 ++++++++++++ lib/core/services/app_purchase.dart | 29 +++++--- .../plans/provider/payment_notifier.dart | 8 +++ lib/features/setting/setting.dart | 67 +++++++++++++++++-- lib/lantern/lantern_core_service.dart | 7 ++ lib/lantern/lantern_ffi_service.dart | 7 ++ lib/lantern/lantern_platform_service.dart | 16 +++++ lib/lantern/lantern_service.dart | 10 +++ 13 files changed, 254 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt b/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt index 42c271ec81..c2544e5c1f 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt @@ -45,6 +45,7 @@ enum class Methods(val method: String) { StripeBillingPortal("stripeBillingPortal"), Plans("plans"), AcknowledgeInAppPurchase("acknowledgeInAppPurchase"), + RestoreInAppPurchase("restoreInAppPurchase"), PaymentRedirect("paymentRedirect"), ReportIssue("reportIssue"), @@ -465,6 +466,26 @@ class MethodHandler : FlutterPlugin, } } + Methods.RestoreInAppPurchase.method -> { + scope.launch { + result.runCatching { + val map = call.arguments as Map<*, *> + val restoreData = Mobile.restoreGooglePlayPurchase( + map["purchaseToken"] as String, + ) + withContext(Dispatchers.Main) { + success(restoreData.toByteArray(Charsets.UTF_8)) + } + }.onFailure { e -> + result.error( + "restore_in_app_purchase", + e.localizedMessage ?: "Please try again", + e + ) + } + } + } + Methods.PaymentRedirect.method -> { scope.launch { result.runCatching { diff --git a/go.mod b/go.mod index 75388a1811..83b221b3bc 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/getlantern/lantern go 1.26.2 -// replace github.com/getlantern/radiance => ../radiance +replace github.com/getlantern/radiance => ../radiance // replace github.com/getlantern/lantern-server-provisioner => ../lantern-server-provisioner diff --git a/go.sum b/go.sum index d2f2cdc3ff..f83a850441 100644 --- a/go.sum +++ b/go.sum @@ -261,8 +261,6 @@ github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 h1:rtDmW8YL github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535/go.mod h1:WKJEdjMOD4IuTRYwjQHjT4bmqDl5J82RShMLxPAvi0Q= github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b h1:gMYJzEhLrmIqQ+JnjiYNm+UyUDalK3WUmVyecFwmV5g= github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b/go.mod h1:NpfXdK4ldEKkjQ4P1R+DBF4ua5VFOlxmgHROTnYrApg= -github.com/getlantern/radiance v0.0.0-20260507232251-71c8a8b5b1c2 h1:O6B+kPVuHOPFayuiRZhBz0EqPPnXSf/NlbftgMaVIPY= -github.com/getlantern/radiance v0.0.0-20260507232251-71c8a8b5b1c2/go.mod h1:oBXKRBE6qxdBmxnjV9NI3CSOSy4zDlm1f7haUVWpwBQ= github.com/getlantern/samizdat v0.0.3-0.20260327203406-ef7323341974 h1:k+/qNo5YNO+8M8LVUp6G5Evm1OQdEs3Z4ye8top4AhI= github.com/getlantern/samizdat v0.0.3-0.20260327203406-ef7323341974/go.mod h1:uEeykQSW2/6rTjfPlj3MTTo59poSHXfAHTGgzYDkbr0= github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb h1:c5YM7b3a4r2J8Eh89KkI6M/iTFe6Bi+b8AJlfkKdFq4= diff --git a/ios/Runner/Handlers/MethodHandler.swift b/ios/Runner/Handlers/MethodHandler.swift index 93d5b8b2d6..48c41b95af 100644 --- a/ios/Runner/Handlers/MethodHandler.swift +++ b/ios/Runner/Handlers/MethodHandler.swift @@ -92,6 +92,22 @@ class MethodHandler { } self.acknowledgeInAppPurchase(token: token, planId: planId, result: result) + case "restoreInAppPurchase": + guard + let map = call.arguments as? [String: Any], + let token = map["purchaseToken"] as? String + else { + result( + FlutterError( + code: "INVALID_ARGUMENTS", + message: "Missing or invalid purchaseToken", + details: nil + ) + ) + return + } + self.restoreInAppPurchase(token: token, result: result) + // user management case "startRecoveryByEmail": let map = (call.arguments as? [String: Any]) ?? [:] @@ -553,6 +569,20 @@ class MethodHandler { } } + func restoreInAppPurchase(token: String, result: @escaping FlutterResult) { + Task { + var error: NSError? + let json = MobileRestoreApplePurchase(token, &error) + if let error { + await self.handleFlutterError(error, result: result, code: "RESTORE_FAILED") + return + } + await MainActor.run { + result(json.data(using: .utf8)) + } + } + } + // MARK: - User management func startRecoveryByEmail(result: @escaping FlutterResult, email: String) { diff --git a/lantern-core/core.go b/lantern-core/core.go index f63b9019db..747196e3b9 100644 --- a/lantern-core/core.go +++ b/lantern-core/core.go @@ -122,6 +122,8 @@ type Payment interface { StripeBillingPortalUrl() (string, error) AcknowledgeGooglePurchase(purchaseToken, planId string) (string, error) AcknowledgeApplePurchase(receipt, planII string) (string, error) + RestoreGooglePlayPurchase(purchaseToken string) (string, error) + RestoreApplePurchase(receipt string) (string, error) PaymentRedirect(provider, planID, email string) (string, error) ActivationCode(email, resellerCode string) error SubscriptionPaymentRedirectURL(redirectBody account.PaymentRedirectData) (string, error) @@ -871,6 +873,36 @@ func (lc *LanternCore) AcknowledgeApplePurchase(receipt, planII string) (string, return lc.client.VerifySubscription(lc.ctx, account.AppleService, params) } +func (lc *LanternCore) RestoreGooglePlayPurchase(purchaseToken string) (string, error) { + params := map[string]string{ + "purchaseToken": purchaseToken, + } + resp, err := lc.client.RestoreSubscription(lc.ctx, account.GoogleService, params) + if err != nil { + return "", err + } + data, err := json.Marshal(resp) + if err != nil { + return "", fmt.Errorf("error marshalling restore google play purchase response: %w", err) + } + return string(data), nil +} + +func (lc *LanternCore) RestoreApplePurchase(receipt string) (string, error) { + params := map[string]string{ + "receipt": receipt, + } + resp, err := lc.client.RestoreSubscription(lc.ctx, account.AppleService, params) + if err != nil { + return "", err + } + data, err := json.Marshal(resp) + if err != nil { + return "", fmt.Errorf("error marshalling restore apple purchase response: %w", err) + } + return string(data), nil +} + func (lc *LanternCore) SubscriptionPaymentRedirectURL(redirectBody account.PaymentRedirectData) (string, error) { return lc.client.SubscriptionPaymentRedirectURL(lc.ctx, redirectBody) } diff --git a/lantern-core/mobile/mobile.go b/lantern-core/mobile/mobile.go index e54e1d882b..bcb42b7a50 100644 --- a/lantern-core/mobile/mobile.go +++ b/lantern-core/mobile/mobile.go @@ -565,6 +565,47 @@ func AcknowledgeApplePurchase(receipt, planII string) (string, error) { }) } +func RestoreGooglePlayPurchase(purchaseToken string) (string, error) { + return withCoreR(func(c lanterncore.Core) (string, error) { + return restoreSubscription(c, c.RestoreGooglePlayPurchase, purchaseToken) + }) +} + +func RestoreApplePurchase(receipt string) (string, error) { + return withCoreR(func(c lanterncore.Core) (string, error) { + return restoreSubscription(c, c.RestoreApplePurchase, receipt) + }) +} + +func restoreSubscription(c lanterncore.Core, fn func(string) (string, error), token string) (string, error) { + data, err := fn(token) + if err != nil { + return "", err + } + var resp account.RestoreSubscriptionResponse + if err := json.Unmarshal([]byte(data), &resp); err != nil { + return "", fmt.Errorf("error unmarshalling restore subscription response: %v", err) + } + if resp.ActualUserID != 0 && resp.ActualUserToken != "" { + /// Restore was made on a different account; switch to it + slog.Info("Restore made on a different account, switching accounts", "actualUserId", resp.ActualUserID) + if err := c.PatchSettings(settings.Settings{ + settings.UserIDKey: fmt.Sprintf("%d", resp.ActualUserID), + settings.TokenKey: resp.ActualUserToken, + }); err != nil { + return "", fmt.Errorf("error updating settings after account switch: %v", err) + } + userData, err := FetchUserData() + if err != nil { + return "", err + } + slog.Debug("fetched user data after account switch", "userdata", userData) + return userData, nil + } + /// Restore was made on the same account, just return "" to indicate success + return "", nil +} + func PaymentRedirect(provider, planId, email string) (string, error) { return withCoreR(func(c lanterncore.Core) (string, error) { return c.PaymentRedirect(provider, planId, email) }) diff --git a/lib/core/services/app_purchase.dart b/lib/core/services/app_purchase.dart index c5331857c0..868109d526 100644 --- a/lib/core/services/app_purchase.dart +++ b/lib/core/services/app_purchase.dart @@ -241,17 +241,29 @@ class AppPurchase { /// Purchase is pending (e.g. deferred payment method on Android). /// Dismiss loading and inform the user — the purchase will complete /// asynchronously when the payment is confirmed. - appLogger.info('[AppPurchase] Purchase is pending: ${purchaseDetails.productID}'); - _onError?.call("Purchase is pending. You will be notified when it completes."); + appLogger.info( + '[AppPurchase] Purchase is pending: ${purchaseDetails.productID}', + ); + _onError?.call( + "Purchase is pending. You will be notified when it completes.", + ); return; } if (status == PurchaseStatus.purchased || status == PurchaseStatus.restored) { + /// During an explicit restore flow, skip the backend acknowledge call + if (_isRestoreFlow) { + appLogger.info( + '[AppPurchase] Restore flow: delegating backend call to onSuccess', + ); + await _finalize(purchaseDetails); + _onSuccess?.call(purchaseDetails); + return; + } + /// Apple sends purchase updates for previously purchased items when the app starts. /// This check prevents processing the same subscription multiple times. - /// Skip the guard during an explicit restore flow — the whole point is - /// to re-acknowledge so the backend can reassociate the user. - if (!_isRestoreFlow && await _checkIfAlreadyPurchased()) { + if (await _checkIfAlreadyPurchased()) { appLogger.info( '[AppPurchase] User has already purchased the subscription. Finalizing purchase without processing.', ); @@ -352,7 +364,8 @@ class AppPurchase { return null; }, (user) => user); - final user = fetchedUser ?? + final user = + fetchedUser ?? (await lanternService.getUserData()).fold((failure) { appLogger.warning( '[AppPurchase] Failed to load cached user data for purchase check: ${failure.error}', @@ -365,8 +378,8 @@ class AppPurchase { } final userLevel = user.legacyUserData.userLevel.toLowerCase(); - final subscriptionStatus = - user.legacyUserData.subscriptionData.status.toLowerCase(); + final subscriptionStatus = user.legacyUserData.subscriptionData.status + .toLowerCase(); return userLevel == 'pro' || subscriptionStatus == 'active'; } diff --git a/lib/features/plans/provider/payment_notifier.dart b/lib/features/plans/provider/payment_notifier.dart index bec5dc5100..ed606217ec 100644 --- a/lib/features/plans/provider/payment_notifier.dart +++ b/lib/features/plans/provider/payment_notifier.dart @@ -49,6 +49,14 @@ class PaymentNotifier extends _$PaymentNotifier { .acknowledgeInAppPurchase(purchaseToken: purchaseToken, planId: planId); } + Future> restoreInAppPurchase({ + required String purchaseToken, + }) async { + return ref + .read(lanternServiceProvider) + .restoreInAppPurchase(purchaseToken: purchaseToken); + } + Future> stripeSubscriptionLink( BillingType type, String planId, diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index dadf12665a..f1e2e7a010 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -1,6 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/extensions/user_data.dart'; import 'package:lantern/core/localization/localization_constants.dart'; @@ -9,6 +10,7 @@ import 'package:lantern/core/utils/pro_utils.dart'; import 'package:lantern/core/widgets/subscription_tags.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; +import 'package:lantern/features/plans/provider/payment_notifier.dart'; import 'package:lantern/features/setting/appearance.dart' show appearanceModeLabel, showAppearanceBottomSheet; @@ -311,18 +313,69 @@ class _SettingState extends ConsumerState { Future _restorePurchaseFlow() async { try { - final appPurchase = sl(); - await appPurchase.restorePurchases( - onSuccess: (_) async {}, - onError: (error) {}, + await sl().restorePurchases( + onSuccess: _onRestoredPurchase, + onError: _showRestoreError, ); } catch (e, st) { appLogger.error('Error initiating restore purchase flow: $e', st); - AppDialog.errorDialog( + _showRestoreError(e.localizedDescription); + } + } + + Future _onRestoredPurchase(PurchaseDetails purchaseDetails) async { + final purchaseToken = + purchaseDetails.verificationData.serverVerificationData; + if (purchaseToken.isEmpty) { + appLogger.info( + '[Restore] Skipping: empty token for ${purchaseDetails.productID}', + ); + return; + } + if (!mounted) return; + + appLogger.info( + '[Restore] Calling restoreInAppPurchase for ${purchaseDetails.productID}', + ); + context.showLoadingDialog(); + final result = await ref + .read(paymentProvider.notifier) + .restoreInAppPurchase(purchaseToken: purchaseToken); + if (!mounted) return; + + if (result.isLeft()) { + final failure = result.swap().getOrElse((_) => throw StateError('left')); + appLogger.error('[Restore] Failed: ${failure.error}'); + context.hideLoadingDialog(); + _showRestoreError(failure.localizedErrorMessage); + return; + } + + appLogger.info('[Restore] Succeeded, verifying Pro status'); + final isPro = await checkUserAccountStatus(ref, context); + if (!mounted) return; + context.hideLoadingDialog(); + if (isPro) { + AppDialog.showLanternProDialog( context: context, - title: 'error'.i18n, - content: e.localizedDescription, + onPressed: () => appRouter.popUntilRoot(), + ); + } else { + AppDialog.dialog( + context: context, + title: 'restore_purchase'.i18n, + content: 'it_looks_like_something_went_wrong'.i18n, ); } } + + void _showRestoreError(String message) { + appLogger.error('[Restore] $message'); + if (!mounted) return; + AppDialog.errorDialog( + context: context, + title: 'error'.i18n, + content: message, + ); + } } diff --git a/lib/lantern/lantern_core_service.dart b/lib/lantern/lantern_core_service.dart index c78b7e55d6..2c07b4bb1b 100644 --- a/lib/lantern/lantern_core_service.dart +++ b/lib/lantern/lantern_core_service.dart @@ -119,6 +119,13 @@ abstract class LanternCoreService { required String planId, }); + /// Restores a previously purchased subscription. Mobile-only. + /// `purchaseToken` is the Google Play purchase token on Android, or the + /// StoreKit receipt (server verification data) on iOS. + Future> restoreInAppPurchase({ + required String purchaseToken, + }); + Future> showManageSubscriptions(); /// Spilt tunnel methods diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index 3b6549819b..fff2d6b05e 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -892,6 +892,13 @@ class LanternFFIService implements LanternCoreService { throw Exception("This not supported on desktop, this is only for mobile"); } + @override + Future> restoreInAppPurchase({ + required String purchaseToken, + }) { + throw Exception("This not supported on desktop, this is only for mobile"); + } + @override Future> paymentRedirect({ required String provider, diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index 84f48a1d01..b0436b99f7 100644 --- a/lib/lantern/lantern_platform_service.dart +++ b/lib/lantern/lantern_platform_service.dart @@ -813,6 +813,22 @@ class LanternPlatformService implements LanternCoreService { } } + @override + Future> restoreInAppPurchase({ + required String purchaseToken, + }) async { + try { + final bytes = await _methodChannel.invokeMethod( + 'restoreInAppPurchase', + {'purchaseToken': purchaseToken}, + ); + return Right(bytes != null ? utf8.decode(bytes) : ''); + } catch (e, stackTrace) { + appLogger.error('Error restoring in-app purchase', e, stackTrace); + return Left(e.toFailure()); + } + } + @override Future> getOAuthLoginUrl(String provider) async { try { diff --git a/lib/lantern/lantern_service.dart b/lib/lantern/lantern_service.dart index 35492f711d..83268fa4c0 100644 --- a/lib/lantern/lantern_service.dart +++ b/lib/lantern/lantern_service.dart @@ -345,6 +345,16 @@ class LanternService implements LanternCoreService { ); } + @override + Future> restoreInAppPurchase({ + required String purchaseToken, + }) { + if (PlatformUtils.isFFISupported) { + return _ffiService.restoreInAppPurchase(purchaseToken: purchaseToken); + } + return _platformService.restoreInAppPurchase(purchaseToken: purchaseToken); + } + @override Future> logout(String email) { if (PlatformUtils.isFFISupported) { From 2cbfb8b2a57f956b99767555ae0510fa0cfe3fce Mon Sep 17 00:00:00 2001 From: Jigar-f Date: Mon, 11 May 2026 18:19:07 +0530 Subject: [PATCH 3/7] Added restore purchase and device flow check --- ios/Podfile.lock | 6 ++ lantern-core/core.go | 6 +- lantern-core/mobile/mobile.go | 10 +-- .../models/restore_subscription_response.dart | 28 ++++++++ .../plans/provider/payment_notifier.dart | 3 +- lib/features/setting/setting.dart | 65 ++++++++++++------- lib/lantern/lantern_core_service.dart | 3 +- lib/lantern/lantern_ffi_service.dart | 3 +- lib/lantern/lantern_platform_service.dart | 8 ++- lib/lantern/lantern_service.dart | 3 +- 10 files changed, 95 insertions(+), 40 deletions(-) create mode 100644 lib/core/models/restore_subscription_response.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ac34d190fb..4976cae2f5 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3,6 +3,8 @@ PODS: - Flutter - device_info_plus (0.0.1): - Flutter + - file_selector_ios (0.0.1): + - Flutter - Flutter (1.0.0) - flutter_inappwebview_ios (0.0.1): - Flutter @@ -99,6 +101,7 @@ PODS: DEPENDENCIES: - app_links (from `.symlinks/plugins/app_links/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - Flutter (from `Flutter`) - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) @@ -131,6 +134,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/app_links/ios" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" + file_selector_ios: + :path: ".symlinks/plugins/file_selector_ios/ios" Flutter: :path: Flutter flutter_inappwebview_ios: @@ -161,6 +166,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_links: 6d01271b3907b0ee7325c5297c75d697c4226c4d device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 + file_selector_ios: 80c12e90ad3f2045ed6819d03742f1a4c5ec3f93 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4 flutter_local_notifications: eda81afddbf18f8589f6ec069ce593c4c6378769 diff --git a/lantern-core/core.go b/lantern-core/core.go index 747196e3b9..1d83704c8a 100644 --- a/lantern-core/core.go +++ b/lantern-core/core.go @@ -875,7 +875,8 @@ func (lc *LanternCore) AcknowledgeApplePurchase(receipt, planII string) (string, func (lc *LanternCore) RestoreGooglePlayPurchase(purchaseToken string) (string, error) { params := map[string]string{ - "purchaseToken": purchaseToken, + "purchaseToken": purchaseToken, + "idempotencyKey": strconv.FormatInt(time.Now().UnixNano(), 10), } resp, err := lc.client.RestoreSubscription(lc.ctx, account.GoogleService, params) if err != nil { @@ -890,7 +891,8 @@ func (lc *LanternCore) RestoreGooglePlayPurchase(purchaseToken string) (string, func (lc *LanternCore) RestoreApplePurchase(receipt string) (string, error) { params := map[string]string{ - "receipt": receipt, + "receipt": receipt, + "idempotencyKey": strconv.FormatInt(time.Now().UnixNano(), 10), } resp, err := lc.client.RestoreSubscription(lc.ctx, account.AppleService, params) if err != nil { diff --git a/lantern-core/mobile/mobile.go b/lantern-core/mobile/mobile.go index bcb42b7a50..43c23ef6c3 100644 --- a/lantern-core/mobile/mobile.go +++ b/lantern-core/mobile/mobile.go @@ -587,7 +587,6 @@ func restoreSubscription(c lanterncore.Core, fn func(string) (string, error), to return "", fmt.Errorf("error unmarshalling restore subscription response: %v", err) } if resp.ActualUserID != 0 && resp.ActualUserToken != "" { - /// Restore was made on a different account; switch to it slog.Info("Restore made on a different account, switching accounts", "actualUserId", resp.ActualUserID) if err := c.PatchSettings(settings.Settings{ settings.UserIDKey: fmt.Sprintf("%d", resp.ActualUserID), @@ -595,15 +594,8 @@ func restoreSubscription(c lanterncore.Core, fn func(string) (string, error), to }); err != nil { return "", fmt.Errorf("error updating settings after account switch: %v", err) } - userData, err := FetchUserData() - if err != nil { - return "", err - } - slog.Debug("fetched user data after account switch", "userdata", userData) - return userData, nil } - /// Restore was made on the same account, just return "" to indicate success - return "", nil + return data, nil } func PaymentRedirect(provider, planId, email string) (string, error) { diff --git a/lib/core/models/restore_subscription_response.dart b/lib/core/models/restore_subscription_response.dart new file mode 100644 index 0000000000..9ca04a8d69 --- /dev/null +++ b/lib/core/models/restore_subscription_response.dart @@ -0,0 +1,28 @@ +import 'package:lantern/core/models/user.dart'; + +class RestoreSubscriptionResponse { + final String status; + final int actualUserId; + final String actualUserToken; + final List devices; + + RestoreSubscriptionResponse({ + required this.status, + required this.actualUserId, + required this.actualUserToken, + required this.devices, + }); + + factory RestoreSubscriptionResponse.fromJson(Map json) => + RestoreSubscriptionResponse( + status: (json['status'] as String?) ?? '', + actualUserId: (json['actualUserId'] as num?)?.toInt() ?? 0, + actualUserToken: (json['actualUserToken'] as String?) ?? '', + devices: ((json['devices'] as List?) ?? const []) + .whereType() + .map((d) => DeviceModel.fromJson(Map.from(d))) + .toList(), + ); + + bool get isAccountSwitch => actualUserId != 0 && actualUserToken.isNotEmpty; +} diff --git a/lib/features/plans/provider/payment_notifier.dart b/lib/features/plans/provider/payment_notifier.dart index ed606217ec..b8b65143c7 100644 --- a/lib/features/plans/provider/payment_notifier.dart +++ b/lib/features/plans/provider/payment_notifier.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:fpdart/fpdart.dart'; import 'package:lantern/core/common/common.dart'; +import 'package:lantern/core/models/restore_subscription_response.dart'; import 'package:lantern/core/services/app_purchase.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -49,7 +50,7 @@ class PaymentNotifier extends _$PaymentNotifier { .acknowledgeInAppPurchase(purchaseToken: purchaseToken, planId: planId); } - Future> restoreInAppPurchase({ + Future> restoreInAppPurchase({ required String purchaseToken, }) async { return ref diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index f1e2e7a010..e351189fa8 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -14,6 +14,7 @@ import 'package:lantern/features/plans/provider/payment_notifier.dart'; import 'package:lantern/features/setting/appearance.dart' show appearanceModeLabel, showAppearanceBottomSheet; +import '../../core/models/user.dart'; import '../../core/services/app_purchase.dart'; import '../../core/services/injection_container.dart'; @@ -343,30 +344,48 @@ class _SettingState extends ConsumerState { .restoreInAppPurchase(purchaseToken: purchaseToken); if (!mounted) return; - if (result.isLeft()) { - final failure = result.swap().getOrElse((_) => throw StateError('left')); - appLogger.error('[Restore] Failed: ${failure.error}'); - context.hideLoadingDialog(); - _showRestoreError(failure.localizedErrorMessage); - return; - } + result.fold( + (failure) { + appLogger.error( + '[Restore] restoreInAppPurchase failed for token $purchaseToken: ${failure.error}', + ); + context.hideLoadingDialog(); + _showRestoreError(failure.localizedErrorMessage); + }, + (restorePurchase) { + context.hideLoadingDialog(); - appLogger.info('[Restore] Succeeded, verifying Pro status'); - final isPro = await checkUserAccountStatus(ref, context); - if (!mounted) return; - context.hideLoadingDialog(); - if (isPro) { - AppDialog.showLanternProDialog( - context: context, - onPressed: () => appRouter.popUntilRoot(), - ); - } else { - AppDialog.dialog( - context: context, - title: 'restore_purchase'.i18n, - content: 'it_looks_like_something_went_wrong'.i18n, - ); - } + if (restorePurchase.status == 'ok' && + restorePurchase.devices.isNotEmpty) { + appLogger.info( + '[Restore] Account restored with ${restorePurchase.devices.length} linked device(s); showing device list', + ); + _showRestoredDevicesDialog(restorePurchase.devices); + return; + } + appLogger.info('[Restore] Account restored; showing success dialog'); + AppDialog.showLanternProDialog( + context: context, + onPressed: () => appRouter.popUntilRoot(), + ); + }, + ); + } + + void _showRestoredDevicesDialog(List devices) { + appRouter.push(DeviceLimitReached(devices: devices)).then((value) async { + if (value != null && value is bool) { + // Give the backend time to propagate the device removal before + // retrying sign-in, otherwise the request may still hit the + // device limit. + await Future.delayed(const Duration(seconds: 1)); + if (!mounted) return; + AppDialog.showLanternProDialog( + context: context, + onPressed: () => appRouter.popUntilRoot(), + ); + } + }); } void _showRestoreError(String message) { diff --git a/lib/lantern/lantern_core_service.dart b/lib/lantern/lantern_core_service.dart index 2c07b4bb1b..63a81d46d9 100644 --- a/lib/lantern/lantern_core_service.dart +++ b/lib/lantern/lantern_core_service.dart @@ -10,6 +10,7 @@ import 'package:lantern/core/models/datacap_info.dart'; import 'package:lantern/core/models/lantern_status.dart'; import 'package:lantern/core/models/macos_extension_state.dart'; import 'package:lantern/core/models/plan_data.dart'; +import 'package:lantern/core/models/restore_subscription_response.dart'; import 'package:lantern/core/models/private_server_status.dart'; import 'package:lantern/features/report_issue/models/report_issue_attachment.dart'; import 'package:lantern/core/models/user.dart'; @@ -122,7 +123,7 @@ abstract class LanternCoreService { /// Restores a previously purchased subscription. Mobile-only. /// `purchaseToken` is the Google Play purchase token on Android, or the /// StoreKit receipt (server verification data) on iOS. - Future> restoreInAppPurchase({ + Future> restoreInAppPurchase({ required String purchaseToken, }); diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index fff2d6b05e..4420181a6a 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -15,6 +15,7 @@ import 'package:lantern/core/models/datacap_info.dart'; import 'package:lantern/core/models/lantern_status.dart'; import 'package:lantern/core/models/private_server_status.dart'; import 'package:lantern/core/services/app_purchase.dart'; +import 'package:lantern/core/models/restore_subscription_response.dart'; import 'package:lantern/core/utils/app_data_utils.dart'; import 'package:lantern/core/utils/storage_utils.dart'; import 'package:lantern/features/report_issue/models/report_issue_attachment.dart'; @@ -893,7 +894,7 @@ class LanternFFIService implements LanternCoreService { } @override - Future> restoreInAppPurchase({ + Future> restoreInAppPurchase({ required String purchaseToken, }) { throw Exception("This not supported on desktop, this is only for mobile"); diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index b0436b99f7..641d788367 100644 --- a/lib/lantern/lantern_platform_service.dart +++ b/lib/lantern/lantern_platform_service.dart @@ -12,6 +12,7 @@ import 'package:lantern/core/models/available_servers.dart'; import 'package:lantern/core/models/datacap_info.dart'; import 'package:lantern/core/models/macos_extension_state.dart'; import 'package:lantern/core/models/plan_data.dart'; +import 'package:lantern/core/models/restore_subscription_response.dart'; import 'package:lantern/core/models/private_server_status.dart'; import 'package:lantern/core/models/server_location.dart'; import 'package:lantern/core/models/user.dart'; @@ -814,7 +815,7 @@ class LanternPlatformService implements LanternCoreService { } @override - Future> restoreInAppPurchase({ + Future> restoreInAppPurchase({ required String purchaseToken, }) async { try { @@ -822,7 +823,10 @@ class LanternPlatformService implements LanternCoreService { 'restoreInAppPurchase', {'purchaseToken': purchaseToken}, ); - return Right(bytes != null ? utf8.decode(bytes) : ''); + final jsonStr = bytes != null ? utf8.decode(bytes) : '{}'; + return Right( + RestoreSubscriptionResponse.fromJson(jsonDecode(jsonStr)), + ); } catch (e, stackTrace) { appLogger.error('Error restoring in-app purchase', e, stackTrace); return Left(e.toFailure()); diff --git a/lib/lantern/lantern_service.dart b/lib/lantern/lantern_service.dart index 83268fa4c0..ca94527a45 100644 --- a/lib/lantern/lantern_service.dart +++ b/lib/lantern/lantern_service.dart @@ -8,6 +8,7 @@ import 'package:lantern/core/models/lantern_status.dart'; import 'package:lantern/core/models/server_location.dart'; import 'package:lantern/core/models/macos_extension_state.dart'; import 'package:lantern/core/models/plan_data.dart'; +import 'package:lantern/core/models/restore_subscription_response.dart'; import 'package:lantern/core/models/private_server_status.dart'; import 'package:lantern/core/services/app_purchase.dart'; import 'package:lantern/lantern/lantern_core_service.dart'; @@ -346,7 +347,7 @@ class LanternService implements LanternCoreService { } @override - Future> restoreInAppPurchase({ + Future> restoreInAppPurchase({ required String purchaseToken, }) { if (PlatformUtils.isFFISupported) { From 189a72f795483d0cdd1e4628bc9256936b06a280 Mon Sep 17 00:00:00 2001 From: Jigar-f Date: Tue, 12 May 2026 15:56:44 +0530 Subject: [PATCH 4/7] Restore purchase changes --- lib/core/models/user.dart | 11 ++++++- lib/core/services/app_purchase.dart | 14 +++++---- lib/core/widgets/user_devices.dart | 45 ++++++++++++++++++----------- lib/features/account/account.dart | 15 ++++------ lib/features/setting/setting.dart | 29 ++++++++++++------- 5 files changed, 70 insertions(+), 44 deletions(-) diff --git a/lib/core/models/user.dart b/lib/core/models/user.dart index c7e11270c7..aa45bef581 100644 --- a/lib/core/models/user.dart +++ b/lib/core/models/user.dart @@ -58,7 +58,7 @@ class DeviceModel { }); factory DeviceModel.fromJson(Map json) => DeviceModel( - deviceId: (json['id'] ?? '').toString(), + deviceId: DeviceModel._firstNonEmpty([json['id'], json['deviceId']]), name: (json['name'] ?? '').toString(), created: (json['created'] as num?)?.toInt() ?? 0, ); @@ -68,6 +68,15 @@ class DeviceModel { 'name': name, 'created': created, }; + + static String _firstNonEmpty(List values) { + for (final v in values) { + if (v == null) continue; + final s = v.toString(); + if (s.isNotEmpty) return s; + } + return ''; + } } class UserDataModel { diff --git a/lib/core/services/app_purchase.dart b/lib/core/services/app_purchase.dart index 868109d526..b2e46867cd 100644 --- a/lib/core/services/app_purchase.dart +++ b/lib/core/services/app_purchase.dart @@ -33,10 +33,6 @@ class AppPurchase { // device has no active subscription cached locally. bool _isRestoreFlow = false; - // Set when a restore flow processes at least one purchase, so we can - // report "no purchases found" if the platform yields no receipts. - bool _restoreProcessedAny = false; - void init() { if (PlatformUtils.isDesktop || _subscription != null) { return; @@ -212,6 +208,14 @@ class AppPurchase { appLogger.info( '[AppPurchase] Received purchase updates: ${purchases.length}', ); + if (_isRestoreFlow && purchases.isEmpty) { + appLogger.info( + '[AppPurchase] Restore flow: purchase stream emitted empty list', + ); + _isRestoreFlow = false; + _onError?.call('No previous purchases found to restore.'); + return; + } for (final purchase in purchases) { await _handlePurchase(purchase); } @@ -254,7 +258,7 @@ class AppPurchase { /// During an explicit restore flow, skip the backend acknowledge call if (_isRestoreFlow) { appLogger.info( - '[AppPurchase] Restore flow: delegating backend call to onSuccess', + '[AppPurchase] Found restore purchase calling success', ); await _finalize(purchaseDetails); _onSuccess?.call(purchaseDetails); diff --git a/lib/core/widgets/user_devices.dart b/lib/core/widgets/user_devices.dart index db66667414..126a3eebd7 100644 --- a/lib/core/widgets/user_devices.dart +++ b/lib/core/widgets/user_devices.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/models/user.dart'; import 'package:lantern/features/auth/provider/auth_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; -import 'package:lantern/core/models/user.dart'; import '../common/common.dart'; @@ -10,9 +10,7 @@ class UserDevices extends HookConsumerWidget { // final List userDevices; // final String myDeviceId; - const UserDevices({ - super.key, - }); + const UserDevices({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -39,10 +37,14 @@ class UserDevices extends HookConsumerWidget { ); } - Widget _buildRow(DeviceModel e, WidgetRef ref, BuildContext context, - bool isMyDevice) { + Widget _buildRow( + DeviceModel e, + WidgetRef ref, + BuildContext context, + bool isMyDevice, + ) { return AppTile( - label: e.name, + label: e.name.isEmpty ? e.deviceId : e.name, contentPadding: EdgeInsets.only(left: 16), trailing: isMyDevice ? AppTextButton( @@ -54,17 +56,26 @@ class UserDevices extends HookConsumerWidget { } Future _removeDevice( - DeviceModel device, WidgetRef ref, BuildContext context) async { + DeviceModel device, + WidgetRef ref, + BuildContext context, + ) async { context.showLoadingDialog(); - final result = - await ref.read(authProvider.notifier).deviceRemove(device.deviceId); + final result = await ref + .read(authProvider.notifier) + .deviceRemove(device.deviceId); - result.fold((failure) { - context.showSnackBar(failure.localizedErrorMessage); - }, (success) async { - context.showSnackBar('device_removed'.i18n); - final innerResult = await ref.read(homeProvider.notifier).fetchUserData(); - context.hideLoadingDialog(); - }); + result.fold( + (failure) { + context.showSnackBar(failure.localizedErrorMessage); + }, + (success) async { + context.showSnackBar('device_removed'.i18n); + final innerResult = await ref + .read(homeProvider.notifier) + .fetchUserData(); + context.hideLoadingDialog(); + }, + ); } } diff --git a/lib/features/account/account.dart b/lib/features/account/account.dart index 458d3a2e7d..4dd05d2d8c 100644 --- a/lib/features/account/account.dart +++ b/lib/features/account/account.dart @@ -5,15 +5,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/extensions/plan.dart'; import 'package:lantern/core/extensions/user_data.dart'; +import 'package:lantern/core/keys/app_keys.dart'; +import 'package:lantern/core/models/user.dart'; import 'package:lantern/core/widgets/info_row.dart'; import 'package:lantern/core/widgets/user_devices.dart'; import 'package:lantern/features/account/provider/account_notifier.dart'; -import 'package:lantern/core/keys/app_keys.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; import 'package:lantern/lantern/lantern_service.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; -import 'package:lantern/core/models/user.dart'; @RoutePage(name: 'Account') class Account extends HookConsumerWidget { @@ -102,10 +102,7 @@ class Account extends HookConsumerWidget { label: 'change_email'.i18n, onPressed: () { appRouter.push( - SignInPassword( - email: email, - fromChangeEmail: true, - ), + SignInPassword(email: email, fromChangeEmail: true), ); }, ), @@ -430,9 +427,7 @@ class Account extends HookConsumerWidget { } if (!context.mounted) return; context.showLoadingDialog(); - final result = await ref - .read(lanternServiceProvider) - .logout(email); + final result = await ref.read(lanternServiceProvider).logout(email); if (!context.mounted) return; result.fold( (l) { @@ -445,7 +440,7 @@ class Account extends HookConsumerWidget { ref.read(homeProvider.notifier).clearLogoutData(); ref.read(homeProvider.notifier).updateUserData(user); appRouter.popUntilRoot(); - appLogger.info('Logout success: $user'); + appLogger.info('Logout success: got user data ${user.toJson()}'); }, ); } diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index e351189fa8..e7486d5b5a 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -313,13 +313,19 @@ class _SettingState extends ConsumerState { } Future _restorePurchaseFlow() async { + context.showLoadingDialog(); try { await sl().restorePurchases( onSuccess: _onRestoredPurchase, - onError: _showRestoreError, + onError: (error) { + if (!mounted) return; + context.hideLoadingDialog(); + _showRestoreError(error); + }, ); } catch (e, st) { appLogger.error('Error initiating restore purchase flow: $e', st); + if (mounted) context.hideLoadingDialog(); _showRestoreError(e.localizedDescription); } } @@ -328,38 +334,39 @@ class _SettingState extends ConsumerState { final purchaseToken = purchaseDetails.verificationData.serverVerificationData; if (purchaseToken.isEmpty) { - appLogger.info( - '[Restore] Skipping: empty token for ${purchaseDetails.productID}', - ); + appLogger.info('Skipping: empty token for ${purchaseDetails.productID}'); return; } if (!mounted) return; - appLogger.info( - '[Restore] Calling restoreInAppPurchase for ${purchaseDetails.productID}', - ); - context.showLoadingDialog(); + appLogger.info('Found the restore purchase calling restore subscription'); final result = await ref .read(paymentProvider.notifier) .restoreInAppPurchase(purchaseToken: purchaseToken); if (!mounted) return; - result.fold( - (failure) { + await result.fold( + (failure) async { appLogger.error( '[Restore] restoreInAppPurchase failed for token $purchaseToken: ${failure.error}', ); context.hideLoadingDialog(); _showRestoreError(failure.localizedErrorMessage); }, - (restorePurchase) { + (restorePurchase) async { + if (!mounted) return; context.hideLoadingDialog(); + /// Once the purchase is successfully restored, we need to fetch the + /// latest user data to get the updated subscription status and linked devices. + await ref.read(homeProvider.notifier).fetchUserData(); + if (restorePurchase.status == 'ok' && restorePurchase.devices.isNotEmpty) { appLogger.info( '[Restore] Account restored with ${restorePurchase.devices.length} linked device(s); showing device list', ); + _showRestoredDevicesDialog(restorePurchase.devices); return; } From 7afb7ed22872ac60f9c31b5450648fbeaecac6bc Mon Sep 17 00:00:00 2001 From: Jigar-f Date: Tue, 12 May 2026 19:19:38 +0530 Subject: [PATCH 5/7] More restore purchase changes --- assets/locales/en.po | 6 ++ lib/core/common/app_dialog.dart | 119 +++++++++++++++++++--------- lib/core/services/app_purchase.dart | 19 +++++ lib/features/plans/plans.dart | 6 +- lib/features/setting/setting.dart | 9 +-- macos/Podfile.lock | 12 +++ 6 files changed, 126 insertions(+), 45 deletions(-) diff --git a/assets/locales/en.po b/assets/locales/en.po index 09fdde67cf..b9177c142d 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -482,6 +482,12 @@ msgstr "Welcome to Lantern!" msgid "lantern_pro_description" msgstr "Your Pro features are unlocked—enjoy unlimited data, faster speeds, and premium servers." +msgid "purchase_restored_title" +msgstr "Purchase Restored" + +msgid "purchase_restored_description" +msgstr "Your Lantern Pro subscription is now active on this device." + msgid "plans_fetch_error" msgstr "Error fetching plans. Please try again later." diff --git a/lib/core/common/app_dialog.dart b/lib/core/common/app_dialog.dart index 014c73a0a8..ce2dd0860c 100644 --- a/lib/core/common/app_dialog.dart +++ b/lib/core/common/app_dialog.dart @@ -41,25 +41,68 @@ class AppDialog { SizedBox(height: defaultSize), Text( 'lantern_pro_description'.i18n, - style: textTheme.bodyMedium?.copyWith( - height: 23 / 16, - ), + style: textTheme.bodyMedium?.copyWith(height: 23 / 16), ), ], ), actions: [ AppTextButton( label: label ?? 'continue'.i18n, + onPressed: () { + appRouter.maybePop(); + Future.delayed(const Duration(milliseconds: 400), () { + onPressed?.call(); + }); + }, + ), + ], + ); + }, + ); + } + + static void purchaseRestoredDialog({ + required BuildContext context, + OnPressed? onPressed, + }) { + final textTheme = Theme.of(context).textTheme; + showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return AlertDialog( + contentPadding: EdgeInsets.symmetric(horizontal: defaultSize), + actionsPadding: EdgeInsets.all(24), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 24), + Center(child: AppImage(path: AppImagePaths.roundCorrect)), + SizedBox(height: 24), + Text( + 'purchase_restored_title'.i18n, + style: textTheme.headlineMedium, + ), + SizedBox(height: defaultSize), + Text( + 'purchase_restored_description'.i18n, + style: textTheme.bodyMedium?.copyWith(height: 23 / 16), + textAlign: TextAlign.left, + ), + ], + ), + actions: [ + AppTextButton( + label: 'continue'.i18n, onPressed: () { appRouter.maybePop(); Future.delayed( const Duration(milliseconds: 400), - () { - onPressed?.call(); - }, + () => onPressed?.call(), ); }, - ) + ), ], ); }, @@ -81,12 +124,14 @@ class AppDialog { actionsOverflowAlignment: OverflowBarAlignment.end, // backgroundColor and shape come from dialogTheme in app_theme.dart contentPadding: EdgeInsets.symmetric(horizontal: size24), - actionsPadding: actionPadding ?? + actionsPadding: + actionPadding ?? EdgeInsets.only( - top: defaultSize, - bottom: defaultSize, - left: defaultSize, - right: defaultSize), + top: defaultSize, + bottom: defaultSize, + left: defaultSize, + right: defaultSize, + ), content: content, actions: action, ); @@ -108,20 +153,18 @@ class AppDialog { // backgroundColor and shape come from dialogTheme in app_theme.dart contentPadding: EdgeInsets.symmetric(horizontal: defaultSize), actionsPadding: EdgeInsets.only( - top: defaultSize, - bottom: defaultSize, - left: defaultSize, - right: defaultSize), + top: defaultSize, + bottom: defaultSize, + left: defaultSize, + right: defaultSize, + ), content: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox(height: 24), Text(title, style: Theme.of(context).textTheme.headlineMedium), SizedBox(height: defaultSize), - Text( - content, - style: Theme.of(context).textTheme.bodyMedium, - ), + Text(content, style: Theme.of(context).textTheme.bodyMedium), ], ), actions: [ @@ -130,7 +173,7 @@ class AppDialog { onPressed: () { appRouter.maybePop(); }, - ) + ), ], ); }, @@ -148,16 +191,19 @@ class AppDialog { return AlertDialog( contentPadding: EdgeInsets.symmetric(horizontal: defaultSize), actionsPadding: EdgeInsets.only( - top: defaultSize, - bottom: defaultSize, - left: defaultSize, - right: defaultSize), + top: defaultSize, + bottom: defaultSize, + left: defaultSize, + right: defaultSize, + ), content: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox(height: 24), - Text('vpn_conflict_title'.i18n, - style: Theme.of(context).textTheme.headlineMedium), + Text( + 'vpn_conflict_title'.i18n, + style: Theme.of(context).textTheme.headlineMedium, + ), SizedBox(height: defaultSize), Text( 'vpn_conflict_body'.i18n, @@ -197,30 +243,29 @@ class AppDialog { // backgroundColor and shape come from dialogTheme in app_theme.dart contentPadding: EdgeInsets.symmetric(horizontal: defaultSize), actionsPadding: EdgeInsets.only( - top: defaultSize, - bottom: defaultSize, - left: defaultSize, - right: defaultSize), + top: defaultSize, + bottom: defaultSize, + left: defaultSize, + right: defaultSize, + ), content: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox(height: 24), Text(title, style: Theme.of(context).textTheme.headlineMedium), SizedBox(height: defaultSize), - Text( - content, - style: Theme.of(context).textTheme.bodyMedium, - ), + Text(content, style: Theme.of(context).textTheme.bodyMedium), ], ), actions: [ AppTextButton( label: action ?? 'ok'.i18n, - onPressed: onPressed ?? + onPressed: + onPressed ?? () { appRouter.maybePop(); }, - ) + ), ], ); }, diff --git a/lib/core/services/app_purchase.dart b/lib/core/services/app_purchase.dart index b2e46867cd..d37b3700b9 100644 --- a/lib/core/services/app_purchase.dart +++ b/lib/core/services/app_purchase.dart @@ -33,6 +33,11 @@ class AppPurchase { // device has no active subscription cached locally. bool _isRestoreFlow = false; + // Set true when a restored receipt is delivered through the stream during + // a restore flow. Used on iOS to detect "no purchases" (StoreKit doesn't + // emit an empty stream event the way Play Billing does). + bool _restoreReceivedAny = false; + void init() { if (PlatformUtils.isDesktop || _subscription != null) { return; @@ -192,11 +197,24 @@ class AppPurchase { _onSuccess = onSuccess; _onError = onError; _isRestoreFlow = true; + _restoreReceivedAny = false; _pendingPlanId = null; try { appLogger.info('[AppPurchase] Initiating restore purchases'); await _inAppPurchase.restorePurchases(); + if (Platform.isIOS) { + // StoreKit doesn't emit an empty stream event when there's nothing to + // restore. Give it a brief window for real receipts to arrive, then + // surface "no purchases" if none did. + Future.delayed(const Duration(seconds: 5), () { + if (_isRestoreFlow && !_restoreReceivedAny) { + appLogger.info('[AppPurchase] iOS restore: no purchases delivered'); + _isRestoreFlow = false; + _onError?.call('No previous purchases found to restore.'); + } + }); + } } catch (e, st) { appLogger.error('[AppPurchase] Error restoring purchases', e, st); _isRestoreFlow = false; @@ -260,6 +278,7 @@ class AppPurchase { appLogger.info( '[AppPurchase] Found restore purchase calling success', ); + _restoreReceivedAny = true; await _finalize(purchaseDetails); _onSuccess?.call(purchaseDetails); return; diff --git a/lib/features/plans/plans.dart b/lib/features/plans/plans.dart index 682c6d3be5..d2af1f9677 100644 --- a/lib/features/plans/plans.dart +++ b/lib/features/plans/plans.dart @@ -70,7 +70,7 @@ class _PlansState extends ConsumerState { child: SizedBox( height: context.isSmallDevice ? size.height * 0.4 - : size.height * 0.39, + : size.height * 0.37, child: SingleChildScrollView(child: FeatureList()), ), ), @@ -128,7 +128,7 @@ class _PlansState extends ConsumerState { ), ), if (isStoreVersion()) ...[ - SizedBox(height: defaultSize), + SizedBox(height: 8), Center( child: AppRichText( texts: '${'already_purchased'.i18n} ', @@ -139,7 +139,7 @@ class _PlansState extends ConsumerState { ), ], if (PlatformUtils.isIOS) ...{ - SizedBox(height: defaultSize), + SizedBox(height: 8), Padding( padding: const EdgeInsets.only(left: 8.0), child: Text( diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index e7486d5b5a..6cd024d7d5 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -313,6 +313,7 @@ class _SettingState extends ConsumerState { } Future _restorePurchaseFlow() async { + if (!mounted) return; context.showLoadingDialog(); try { await sl().restorePurchases( @@ -343,7 +344,6 @@ class _SettingState extends ConsumerState { final result = await ref .read(paymentProvider.notifier) .restoreInAppPurchase(purchaseToken: purchaseToken); - if (!mounted) return; await result.fold( (failure) async { @@ -355,12 +355,11 @@ class _SettingState extends ConsumerState { }, (restorePurchase) async { if (!mounted) return; - context.hideLoadingDialog(); /// Once the purchase is successfully restored, we need to fetch the /// latest user data to get the updated subscription status and linked devices. await ref.read(homeProvider.notifier).fetchUserData(); - + context.hideLoadingDialog(); if (restorePurchase.status == 'ok' && restorePurchase.devices.isNotEmpty) { appLogger.info( @@ -371,7 +370,7 @@ class _SettingState extends ConsumerState { return; } appLogger.info('[Restore] Account restored; showing success dialog'); - AppDialog.showLanternProDialog( + AppDialog.purchaseRestoredDialog( context: context, onPressed: () => appRouter.popUntilRoot(), ); @@ -387,7 +386,7 @@ class _SettingState extends ConsumerState { // device limit. await Future.delayed(const Duration(seconds: 1)); if (!mounted) return; - AppDialog.showLanternProDialog( + AppDialog.purchaseRestoredDialog( context: context, onPressed: () => appRouter.popUntilRoot(), ); diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 60114a91f6..16442fcc56 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -4,10 +4,14 @@ PODS: - auto_updater_macos (0.0.1): - FlutterMacOS - Sparkle + - desktop_drop (0.0.1): + - FlutterMacOS - desktop_webview_window (0.0.1): - FlutterMacOS - device_info_plus (0.0.1): - FlutterMacOS + - file_selector_macos (0.0.1): + - FlutterMacOS - flutter_inappwebview_macos (0.0.1): - FlutterMacOS - OrderedSet (~> 6.0.3) @@ -45,8 +49,10 @@ PODS: DEPENDENCIES: - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) - auto_updater_macos (from `Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos`) + - desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`) - desktop_webview_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_webview_window/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) + - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - flutter_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos`) @@ -73,10 +79,14 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos auto_updater_macos: :path: Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos + desktop_drop: + :path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos desktop_webview_window: :path: Flutter/ephemeral/.symlinks/plugins/desktop_webview_window/macos device_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos + file_selector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos flutter_inappwebview_macos: :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos flutter_local_notifications: @@ -109,8 +119,10 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_links: c3185399a5cabc2e610ee5ad52fb7269b84ff869 auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d + desktop_drop: 1eeeb9484299585770b6461e4f3d60950e99efa2 desktop_webview_window: d4365e71bcd4e1aa0c14cf0377aa24db0c16a7e2 device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215 + file_selector_macos: 3e56eaea051180007b900eacb006686fd54da150 flutter_inappwebview_macos: bdf207b8f4ebd58e86ae06cd96b147de99a67c9b flutter_local_notifications: 14e285ca39907db50704f7f46c9ab7a526bd7ead flutter_timezone: b3bc0c587d8780d395651284a1ff46eb1e5753ac From f595e0aafd43c3d5f0357c56b48b06e6bafc782c Mon Sep 17 00:00:00 2001 From: Jigar-f Date: Tue, 12 May 2026 20:07:33 +0530 Subject: [PATCH 6/7] use remote radiance --- go.mod | 4 ++-- go.sum | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 83b221b3bc..c6735a818b 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/getlantern/lantern go 1.26.2 -replace github.com/getlantern/radiance => ../radiance +// replace github.com/getlantern/radiance => ../radiance // replace github.com/getlantern/lantern-server-provisioner => ../lantern-server-provisioner @@ -25,7 +25,7 @@ replace github.com/quic-go/qpack => github.com/quic-go/qpack v0.5.1 require ( github.com/alecthomas/assert/v2 v2.3.0 github.com/getlantern/lantern-server-provisioner v0.0.0-20251031121934-8ea031fccfa9 - github.com/getlantern/radiance v0.0.0-20260507232251-71c8a8b5b1c2 + github.com/getlantern/radiance v0.0.0-20260511115046-794feeec3c10 github.com/sagernet/sing-box v1.12.22 golang.org/x/mobile v0.0.0-20250711185624-d5bb5ecc55c0 golang.org/x/sys v0.41.0 diff --git a/go.sum b/go.sum index f83a850441..07d90f1b92 100644 --- a/go.sum +++ b/go.sum @@ -261,6 +261,8 @@ github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 h1:rtDmW8YL github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535/go.mod h1:WKJEdjMOD4IuTRYwjQHjT4bmqDl5J82RShMLxPAvi0Q= github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b h1:gMYJzEhLrmIqQ+JnjiYNm+UyUDalK3WUmVyecFwmV5g= github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b/go.mod h1:NpfXdK4ldEKkjQ4P1R+DBF4ua5VFOlxmgHROTnYrApg= +github.com/getlantern/radiance v0.0.0-20260511115046-794feeec3c10 h1:Oky7XUspmTblCzGTS/kRmEFGYLEkvr2tRS4l0UN1IV8= +github.com/getlantern/radiance v0.0.0-20260511115046-794feeec3c10/go.mod h1:oBXKRBE6qxdBmxnjV9NI3CSOSy4zDlm1f7haUVWpwBQ= github.com/getlantern/samizdat v0.0.3-0.20260327203406-ef7323341974 h1:k+/qNo5YNO+8M8LVUp6G5Evm1OQdEs3Z4ye8top4AhI= github.com/getlantern/samizdat v0.0.3-0.20260327203406-ef7323341974/go.mod h1:uEeykQSW2/6rTjfPlj3MTTo59poSHXfAHTGgzYDkbr0= github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb h1:c5YM7b3a4r2J8Eh89KkI6M/iTFe6Bi+b8AJlfkKdFq4= From 1132a9b5aecf87ab18b16fcd5bfdf1075b7591e5 Mon Sep 17 00:00:00 2001 From: Jigar-f Date: Tue, 12 May 2026 22:16:11 +0530 Subject: [PATCH 7/7] Update No Purchase Dialogue. --- assets/locales/en.po | 9 +++++ lib/core/common/app_dialog.dart | 57 +++++++++++++++++++++++++++++++ lib/features/setting/setting.dart | 4 +++ 3 files changed, 70 insertions(+) diff --git a/assets/locales/en.po b/assets/locales/en.po index b9177c142d..cbdb6ba579 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -488,6 +488,15 @@ msgstr "Purchase Restored" msgid "purchase_restored_description" msgstr "Your Lantern Pro subscription is now active on this device." +msgid "no_purchase_found_title" +msgstr "No purchase found" + +msgid "no_purchase_found_body_ios" +msgstr "No active subscription is tied to this Apple ID." + +msgid "no_purchase_found_body_android" +msgstr "No active subscription is tied to this Google account." + msgid "plans_fetch_error" msgstr "Error fetching plans. Please try again later." diff --git a/lib/core/common/app_dialog.dart b/lib/core/common/app_dialog.dart index ce2dd0860c..4c4cf8e551 100644 --- a/lib/core/common/app_dialog.dart +++ b/lib/core/common/app_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:lantern/core/common/common.dart'; @@ -109,6 +111,61 @@ class AppDialog { ); } + static void noPurchaseFoundDialog({ + required BuildContext context, + OnPressed? onPressed, + }) { + final textTheme = Theme.of(context).textTheme; + final body = Platform.isIOS + ? 'no_purchase_found_body_ios'.i18n + : 'no_purchase_found_body_android'.i18n; + showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return AlertDialog( + contentPadding: EdgeInsets.symmetric(horizontal: defaultSize), + actionsPadding: EdgeInsets.all(24), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 24), + Icon( + Icons.info_outline, + color: Colors.orange, + size: 48, + ), + SizedBox(height: 24), + Text( + 'no_purchase_found_title'.i18n, + style: textTheme.headlineMedium, + ), + SizedBox(height: defaultSize), + Text( + body, + style: textTheme.bodyMedium?.copyWith(height: 23 / 16), + textAlign: TextAlign.left, + ), + ], + ), + actions: [ + AppTextButton( + label: 'ok'.i18n, + onPressed: () { + appRouter.maybePop(); + Future.delayed( + const Duration(milliseconds: 400), + () => onPressed?.call(), + ); + }, + ), + ], + ); + }, + ); + } + static void customDialog({ required BuildContext context, required Widget content, diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index 6cd024d7d5..5489423443 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -321,6 +321,10 @@ class _SettingState extends ConsumerState { onError: (error) { if (!mounted) return; context.hideLoadingDialog(); + if (error.contains('No previous purchases found')) { + AppDialog.noPurchaseFoundDialog(context: context); + return; + } _showRestoreError(error); }, );