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 7b066670a9..54a998e98c 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/assets/locales/en.po b/assets/locales/en.po index f2810db1d6..cbdb6ba579 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -482,6 +482,21 @@ 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 "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." @@ -1038,6 +1053,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/go.mod b/go.mod index 74737b4af4..1245efeb2c 100644 --- a/go.mod +++ b/go.mod @@ -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-20260512232534-fdf5b21caa02 + github.com/getlantern/radiance v0.0.0-20260513120333-8fd1b0335917 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 649a148ce9..eb17be02c3 100644 --- a/go.sum +++ b/go.sum @@ -261,8 +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-20260512232534-fdf5b21caa02 h1:9DfkAkip/ZrwKacgNsXjmqCeK8b+llUHbT+Bb87UzRA= -github.com/getlantern/radiance v0.0.0-20260512232534-fdf5b21caa02/go.mod h1:OWxfQRKKT/W13sKURvdLrgU/fYgUfWbmkgrREMzAEtk= +github.com/getlantern/radiance v0.0.0-20260513120333-8fd1b0335917 h1:7rJL4TXNfaIqxBigae5T4hHiVLCz4EEP2PkyDrLCtmw= +github.com/getlantern/radiance v0.0.0-20260513120333-8fd1b0335917/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/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/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 e08dce43dd..7f93befec2 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, idempotencyKey string) (string, error) ActivationCode(email, resellerCode string) error SubscriptionPaymentRedirectURL(redirectBody account.PaymentRedirectData) (string, error) @@ -871,6 +873,38 @@ 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, + "idempotencyKey": strconv.FormatInt(time.Now().UnixNano(), 10), + } + 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, + "idempotencyKey": strconv.FormatInt(time.Now().UnixNano(), 10), + } + 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 dedde5c043..75c4fae397 100644 --- a/lantern-core/mobile/mobile.go +++ b/lantern-core/mobile/mobile.go @@ -565,6 +565,39 @@ 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 != "" { + 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) + } + } + return data, nil +} + func PaymentRedirect(provider, planId, email, idempotencyKey string) (string, error) { return withCoreR(func(c lanterncore.Core) (string, error) { return c.PaymentRedirect(provider, planId, email, idempotencyKey) diff --git a/lib/core/common/app_dialog.dart b/lib/core/common/app_dialog.dart index 014c73a0a8..33cd5cdc8b 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'; @@ -41,25 +43,129 @@ 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.greenCheck, + height: 50, + useThemeColor: false, + ), + ), + SizedBox(height: defaultSize), + Center( + child: Text( + 'purchase_restored_title'.i18n, + style: textTheme.headlineMedium, + ), + ), + SizedBox(height: 8), + Text( + 'purchase_restored_description'.i18n, + style: textTheme.bodyMedium, + textAlign: TextAlign.left, + ), + ], + ), + actions: [ + AppTextButton( + label: 'continue'.i18n, onPressed: () { appRouter.maybePop(); Future.delayed( const Duration(milliseconds: 400), - () { - onPressed?.call(); - }, + () => onPressed?.call(), + ); + }, + ), + ], + ); + }, + ); + } + + 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), + Center(child: AppImage(path: AppImagePaths.info, height: 40)), + SizedBox(height: 8), + Center( + child: Text( + 'no_purchase_found_title'.i18n, + style: textTheme.headlineMedium, + ), + ), + SizedBox(height: 8), + Text( + body, + style: textTheme.bodyMedium, + textAlign: TextAlign.left, + ), + ], + ), + actions: [ + AppTextButton( + label: 'ok'.i18n, + onPressed: () { + appRouter.maybePop(); + Future.delayed( + const Duration(milliseconds: 400), + () => onPressed?.call(), ); }, - ) + ), ], ); }, @@ -81,12 +187,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 +216,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 +236,7 @@ class AppDialog { onPressed: () { appRouter.maybePop(); }, - ) + ), ], ); }, @@ -148,16 +254,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 +306,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/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/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 69ca1e4178..92fab324dc 100644 --- a/lib/core/services/app_purchase.dart +++ b/lib/core/services/app_purchase.dart @@ -28,6 +28,16 @@ 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 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; @@ -39,6 +49,11 @@ class AppPurchase { onDone: _updateStreamOnDone, onError: _updateStreamOnError, ); + unawaited( + fetchSubscriptions().catchError((Object e, StackTrace st) { + appLogger.error('[AppPurchase] init: fetchSubscriptions failed', e, st); + }), + ); } Future fetchSubscriptions({int maxAttempts = 3}) async { @@ -173,15 +188,125 @@ 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; + _restoreReceivedAny = false; + _pendingPlanId = null; + + try { + appLogger.info('[AppPurchase] Initiating restore purchases'); + await _inAppPurchase.restorePurchases(); + if (Platform.isIOS) { + // StoreKit doesn't emit anything via the stream when there are no + // purchases to restore, so the only signal "nothing to restore" is + // the absence of a stream event within a reasonable window. + Future.delayed(const Duration(seconds: 10), () { + if (_isRestoreFlow && !_restoreReceivedAny) { + appLogger.info('[AppPurchase] iOS restore: no purchases delivered'); + _isRestoreFlow = false; + final onError = _onError; + clearCallbacks(); + onError?.call('No previous purchases found to restore.'); + } + }); + } + } catch (e, st) { + appLogger.error('[AppPurchase] Error restoring purchases', e, st); + _isRestoreFlow = false; + final onError = _onError; + clearCallbacks(); + onError?.call('Error restoring purchases: $e'); + } + } + Future _onPurchaseUpdates(List purchases) async { appLogger.info( '[AppPurchase] Received purchase updates: ${purchases.length}', ); + if (_isRestoreFlow && purchases.isEmpty) { + appLogger.info( + '[AppPurchase] Restore flow: purchase stream emitted empty list', + ); + _isRestoreFlow = false; + final onError = _onError; + clearCallbacks(); + onError?.call('No previous purchases found to restore.'); + return; + } + + /// During restore, if more than one restored receipt comes back, batch + /// them: pick one to surface and finalize the rest. Otherwise _finalize + /// would clear _isRestoreFlow after the first item and the second + /// iteration would fall into the regular acknowledge path. + if (_isRestoreFlow && purchases.length > 1) { + final restored = purchases + .where( + (p) => + p.status == PurchaseStatus.purchased || + p.status == PurchaseStatus.restored, + ) + .toList(); + if (restored.length > 1) { + await _handleRestoreBatch(restored); + return; + } + } + for (final purchase in purchases) { await _handlePurchase(purchase); } } + /// Picks the latest restored receipt, finalizes every restored receipt + /// with the store, and fires `_onSuccess` once. + Future _handleRestoreBatch(List restored) async { + ///Pick the latest purchase + restored.sort((a, b) { + final aDate = int.tryParse(a.transactionDate ?? '') ?? 0; + final bDate = int.tryParse(b.transactionDate ?? '') ?? 0; + return bDate.compareTo(aDate); + }); + + final chosen = restored.first; + appLogger.info( + '[AppPurchase] Restore batch: ${restored.length} purchases, choosing ${chosen.productID}', + ); + + _restoreReceivedAny = true; + + // Complete each receipt inline (don't call _finalize — its `finally` + // clears _isRestoreFlow, which would mis-route any re-entrant stream + // emissions through the regular acknowledge path mid-batch and fire + // a stray error before our success callback. + for (final purchase in restored) { + try { + if (purchase.pendingCompletePurchase) { + await _inAppPurchase.completePurchase(purchase); + } + } catch (e) { + appLogger.error('[AppPurchase] Error completing restored purchase: $e'); + } + } + + final onSuccess = _onSuccess; + _isRestoreFlow = false; + _pendingPlanId = null; + clearCallbacks(); + onSuccess?.call(chosen); + } + Future _handlePurchase(PurchaseDetails purchaseDetails) async { appLogger.info( '[AppPurchase] Handling purchase: ${purchaseDetails.productID} with status: ${purchaseDetails.status}', @@ -206,12 +331,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] Found restore purchase calling success', + ); + _restoreReceivedAny = true; + await _finalize(purchaseDetails); + final onSuccess = _onSuccess; + clearCallbacks(); + 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. if (await _checkIfAlreadyPurchased()) { @@ -272,6 +414,7 @@ class AppPurchase { appLogger.error('[AppPurchase] Error finalizing purchase: $e', e); } finally { _pendingPlanId = null; + _isRestoreFlow = false; } } @@ -314,7 +457,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}', @@ -327,8 +471,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'; } @@ -362,5 +506,6 @@ class AppPurchase { _onSuccess = null; _onError = null; _pendingPlanId = null; + _isRestoreFlow = false; } } diff --git a/lib/core/widgets/user_devices.dart b/lib/core/widgets/user_devices.dart index db66667414..8e8e9bcf93 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,27 @@ 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) { + if (!context.mounted) return; + context.hideLoadingDialog(); + context.showSnackBar(failure.localizedErrorMessage); + }, + (success) async { + context.showSnackBar('device_removed'.i18n); + await ref.read(homeProvider.notifier).fetchUserData(); + if (!context.mounted) return; + context.hideLoadingDialog(); + }, + ); } } diff --git a/lib/features/account/account.dart b/lib/features/account/account.dart index 458d3a2e7d..ac50f017a2 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 userId=${user.toJson()}'); }, ); } diff --git a/lib/features/auth/device_limit_reached.dart b/lib/features/auth/device_limit_reached.dart index f4bd7caca9..a3bee41cc2 100644 --- a/lib/features/auth/device_limit_reached.dart +++ b/lib/features/auth/device_limit_reached.dart @@ -11,10 +11,7 @@ import 'package:lantern/core/models/user.dart'; class DeviceLimitReached extends HookConsumerWidget { final List devices; - const DeviceLimitReached({ - super.key, - required this.devices, - }); + const DeviceLimitReached({super.key, required this.devices}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -36,44 +33,50 @@ class DeviceLimitReached extends HookConsumerWidget { SizedBox(height: 24.0), Padding( padding: const EdgeInsets.only(left: 16.0), - child: Text("lantern_pro_devices".i18n, - style: textTheme.labelLarge!.copyWith( - color: context.textSecondary, - )), + child: Text( + "lantern_pro_devices".i18n, + style: textTheme.labelLarge!.copyWith( + color: context.textSecondary, + ), + ), ), AppCard( - child: ListView( - shrinkWrap: true, - padding: const EdgeInsets.all(0), - children: devices.map((device) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppTile( - contentPadding: EdgeInsets.zero, - label: device.name, - trailing: AppRadioButton( - value: device, - groupValue: selectedDevice.value, - onChanged: (value) { - selectedDevice.value = value; - }, + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.all(0), + children: devices.map((device) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppTile( + contentPadding: EdgeInsets.zero, + label: device.name.isEmpty + ? device.deviceId + : device.name, + trailing: AppRadioButton( + value: device, + groupValue: selectedDevice.value, + onChanged: (value) { + selectedDevice.value = value; + }, + ), ), - ), - DividerSpace( - padding: EdgeInsetsGeometry.zero, - ), - ], - ); - }).toList(), - )), + DividerSpace(padding: EdgeInsetsGeometry.zero), + ], + ); + }).toList(), + ), + ), SizedBox(height: 32.0), PrimaryButton( label: 'remove_device_and_sign_in'.i18n, isTaller: true, enabled: selectedDevice.value != null, - onPressed: () => - removeDeviceAndLogin(ref, selectedDevice.value!.deviceId, context), + onPressed: () => removeDeviceAndLogin( + ref, + selectedDevice.value!.deviceId, + context, + ), ), SizedBox(height: 30.0), Center( @@ -84,14 +87,17 @@ class DeviceLimitReached extends HookConsumerWidget { appRouter.popUntilRoot(); }, ), - ) + ), ], ), ); } Future removeDeviceAndLogin( - WidgetRef ref, String deviceId, BuildContext context) async { + WidgetRef ref, + String deviceId, + BuildContext context, + ) async { context.showLoadingDialog(); final result = await ref.read(authProvider.notifier).deviceRemove(deviceId); result.fold( diff --git a/lib/features/auth/sign_in_email.dart b/lib/features/auth/sign_in_email.dart index 394cf0d287..c6f81a9b16 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()); }, - ) + ), ], ), ), @@ -98,10 +98,7 @@ class SignInEmail extends HookConsumerWidget { ); } - void signInWithEmail( - String email, - BuildContext context, - ) { + void signInWithEmail(String email, BuildContext context) { if (!email.isValidEmail()) { context.showSnackBarError('invalid_email'.i18n); return; @@ -109,14 +106,19 @@ class SignInEmail extends HookConsumerWidget { appRouter.push(SignInPassword(email: email)); } - Future onOAuthResult(Map result, BuildContext context, - WidgetRef ref, SignUpMethodType type) async { - final token = result['token']; + Future onOAuthResult( + Map oauthResult, + BuildContext context, + WidgetRef ref, + SignUpMethodType type, + ) async { + final token = oauthResult['token']; if (token != null) { context.showLoadingDialog(); - final result = - await ref.read(authProvider.notifier).oAuthLoginCallback(token); - result.fold( + final loginResult = await ref + .read(authProvider.notifier) + .oAuthLoginCallback(token); + loginResult.fold( (failure) { context.hideLoadingDialog(); context.showSnackBar(failure.localizedErrorMessage); @@ -126,7 +128,8 @@ class SignInEmail extends HookConsumerWidget { ref.read(homeProvider.notifier).updateUserData(response); appLogger.info( - 'OAuth login successful, updating app settings with token and user data provider: ${type.name}'); + 'OAuth login successful, updating app settings with token and user data provider: ${type.name}', + ); ref.read(appSettingProvider.notifier).setUserLoggedIn(true); appRouter.popUntilRoot(); }, diff --git a/lib/features/plans/plans.dart b/lib/features/plans/plans.dart index 7ba6df6f29..54505cf8a8 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'; @@ -18,6 +19,7 @@ import 'package:lantern/features/plans/plans_list.dart'; import 'package:lantern/features/plans/provider/payment_notifier.dart'; import 'package:lantern/features/plans/provider/plans_notifier.dart'; import 'package:lantern/features/plans/provider/referral_notifier.dart'; +import 'package:lantern/features/plans/restore_purchase_mixin.dart'; import '../../core/models/plan_data.dart'; @@ -29,7 +31,8 @@ class Plans extends StatefulHookConsumerWidget { ConsumerState createState() => _PlansState(); } -class _PlansState extends ConsumerState { +class _PlansState extends ConsumerState + with RestorePurchaseMixin { late TextTheme textTheme; @override @@ -69,7 +72,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()), ), ), @@ -126,8 +129,19 @@ class _PlansState extends ConsumerState { onPressed: onGetLanternProTap, ), ), + if (isStoreVersion()) ...[ + SizedBox(height: 8), + Center( + child: AppRichText( + texts: '${'already_purchased'.i18n} ', + boldTexts: 'restore_purchase'.i18n, + boldUnderline: true, + boldOnPressed: _restorePurchaseFlow, + ), + ), + ], if (PlatformUtils.isIOS) ...{ - SizedBox(height: defaultSize), + SizedBox(height: 8), Padding( padding: const EdgeInsets.only(left: 8.0), child: Text( @@ -207,14 +221,6 @@ class _PlansState extends ConsumerState { ); }, ), - DividerSpace(), - AppTile( - icon: AppImagePaths.restorePurchase, - label: 'restore_purchase'.i18n, - onPressed: () { - appRouter.popAndPush(SignInEmail()); - }, - ), ], ); }, @@ -464,4 +470,6 @@ class _PlansState extends ConsumerState { ); } } + + Future _restorePurchaseFlow() => restorePurchaseFlow(); } diff --git a/lib/features/plans/provider/payment_notifier.dart b/lib/features/plans/provider/payment_notifier.dart index 8dc931d016..6dfde625b0 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,6 +50,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/plans/restore_purchase_mixin.dart b/lib/features/plans/restore_purchase_mixin.dart new file mode 100644 index 0000000000..dd40c51b8f --- /dev/null +++ b/lib/features/plans/restore_purchase_mixin.dart @@ -0,0 +1,115 @@ +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/models/user.dart'; +import 'package:lantern/core/services/app_purchase.dart'; +import 'package:lantern/core/services/injection_container.dart'; +import 'package:lantern/features/home/provider/home_notifier.dart'; +import 'package:lantern/features/plans/provider/payment_notifier.dart'; + +/// Shared restore-purchase flow used by Settings and Plans. Drives the +/// platform restore, talks to the backend, and surfaces success / device-limit +/// / error dialogs in a single place. +mixin RestorePurchaseMixin + on ConsumerState { + Future restorePurchaseFlow() async { + if (!mounted) return; + context.showLoadingDialog(); + try { + await sl().restorePurchases( + onSuccess: _onRestoredPurchase, + onError: (error) { + if (!mounted) return; + context.hideLoadingDialog(); + sl().clearCallbacks(); + if (error.contains('No previous purchases found')) { + AppDialog.noPurchaseFoundDialog(context: context); + return; + } + _showRestoreError(error); + }, + ); + } catch (e, st) { + appLogger.error('Error initiating restore purchase flow: $e', st); + if (mounted) context.hideLoadingDialog(); + sl().clearCallbacks(); + _showRestoreError(e.localizedDescription); + } + } + + Future _onRestoredPurchase(PurchaseDetails purchaseDetails) async { + sl().clearCallbacks(); + final purchaseToken = + purchaseDetails.verificationData.serverVerificationData; + if (purchaseToken.isEmpty) { + appLogger.error( + '[Restore] Empty server verification token for ${purchaseDetails.productID}', + ); + if (mounted) context.hideLoadingDialog(); + _showRestoreError('Unable to restore purchase: missing receipt.'); + return; + } + if (!mounted) return; + + appLogger.info('Found the restore purchase calling restore subscription'); + final result = await ref + .read(paymentProvider.notifier) + .restoreInAppPurchase(purchaseToken: purchaseToken); + + await result.fold( + (failure) async { + appLogger.error( + '[Restore] restoreInAppPurchase failed: ${failure.error}', + ); + if (mounted) context.hideLoadingDialog(); + _showRestoreError(failure.localizedErrorMessage); + }, + (restorePurchase) async { + if (!mounted) return; + + /// 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 (!mounted) return; + context.hideLoadingDialog(); + 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.purchaseRestoredDialog( + context: context, + onPressed: () => appRouter.popUntilRoot(), + ); + }, + ); + } + + void _showRestoredDevicesDialog(List devices) { + appRouter.push(DeviceLimitReached(devices: devices)).then((value) async { + if (value == true) { + await Future.delayed(const Duration(seconds: 1)); + if (!mounted) return; + AppDialog.purchaseRestoredDialog( + context: context, + onPressed: () => appRouter.popUntilRoot(), + ); + } + }); + } + + void _showRestoreError(String message) { + appLogger.error('[Restore] $message'); + if (!mounted) return; + AppDialog.errorDialog( + context: context, + title: 'error'.i18n, + content: message, + ); + } +} diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index 8db7a17a7b..ac038a3013 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -9,6 +9,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/restore_purchase_mixin.dart'; import 'package:lantern/features/setting/appearance.dart' show appearanceModeLabel, showAppearanceBottomSheet; @@ -24,6 +25,7 @@ enum _SettingType { getPro, checkForUpdates, browserUnbounded, + restorePurchase, } @RoutePage(name: 'Setting') @@ -34,7 +36,8 @@ class Setting extends StatefulHookConsumerWidget { ConsumerState createState() => _SettingState(); } -class _SettingState extends ConsumerState { +class _SettingState extends ConsumerState + with RestorePurchaseMixin { @override Widget build(BuildContext context) { final isExpired = ref.watch(isUserExpiredProvider); @@ -44,7 +47,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 +89,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 +179,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 +277,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 +291,9 @@ class _SettingState extends ConsumerState { case _SettingType.browserUnbounded: // TODO: Handle this case. throw UnimplementedError(); + case _SettingType.restorePurchase: + restorePurchaseFlow(); + break; } } diff --git a/lib/lantern/lantern_core_service.dart b/lib/lantern/lantern_core_service.dart index a0df6af006..7dde6b538b 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'; @@ -121,6 +122,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 b53d5b0bc3..0a30edae4d 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'; @@ -525,10 +526,7 @@ class LanternFFIService implements LanternCoreService { } appLogger.error('$action split tunnel error: $errMsg'); return left( - Failure( - error: errMsg, - localizedErrorMessage: localizeRawError(errMsg), - ), + Failure(error: errMsg, localizedErrorMessage: localizeRawError(errMsg)), ); } catch (e) { return left( @@ -867,7 +865,9 @@ class LanternFFIService implements LanternCoreService { @override Future> showManageSubscriptions() { - throw Exception("This not supported on desktop, this is only for mobile"); + throw UnimplementedError( + "This is not supported on desktop; this is only for mobile", + ); } @override @@ -891,7 +891,18 @@ class LanternFFIService implements LanternCoreService { required String purchaseToken, required String planId, }) { - throw Exception("This not supported on desktop, this is only for mobile"); + throw UnimplementedError( + "This is not supported on desktop; this is only for mobile", + ); + } + + @override + Future> restoreInAppPurchase({ + required String purchaseToken, + }) { + throw UnimplementedError( + "This is not supported on desktop; this is only for mobile", + ); } @override diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index b718596d0f..734c827544 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'; @@ -663,16 +664,21 @@ class LanternPlatformService implements LanternCoreService { ); } try { - final redirectUrl = await _methodChannel.invokeMethod( - 'stripeSubscriptionPaymentRedirect', - { - "type": type.name, - "planId": planId, - "email": email, - "idempotencyKey": idempotencyKey, - }, - ); - return Right(redirectUrl!); + final redirectUrl = await _methodChannel + .invokeMethod('stripeSubscriptionPaymentRedirect', { + "type": type.name, + "planId": planId, + "email": email, + "idempotencyKey": idempotencyKey, + }); + if (redirectUrl == null || redirectUrl.isEmpty) { + return Left( + Exception( + 'No subscription payment redirect URL returned', + ).toFailure(), + ); + } + return Right(redirectUrl); } catch (e) { return Left( Failure( @@ -771,16 +777,17 @@ class LanternPlatformService implements LanternCoreService { throw UnimplementedError("This not supported on IOS"); } try { - final redirectUrl = await _methodChannel.invokeMethod( - 'paymentRedirect', - { - 'provider': provider, - 'planId': planId, - 'email': email, - 'idempotencyKey': idempotencyKey, - }, - ); - return Right(redirectUrl!); + final redirectUrl = await _methodChannel + .invokeMethod('paymentRedirect', { + 'provider': provider, + 'planId': planId, + 'email': email, + 'idempotencyKey': idempotencyKey, + }); + if (redirectUrl == null || redirectUrl.isEmpty) { + return Left(Exception('No payment redirect URL returned').toFailure()); + } + return Right(redirectUrl); } catch (e, stackTrace) { appLogger.error('Error getting payment redirect URL', e, stackTrace); return Left( @@ -825,6 +832,32 @@ class LanternPlatformService implements LanternCoreService { } } + @override + Future> restoreInAppPurchase({ + required String purchaseToken, + }) async { + try { + final bytes = await _methodChannel.invokeMethod( + 'restoreInAppPurchase', + {'purchaseToken': purchaseToken}, + ); + if (bytes == null) { + appLogger.error( + 'restoreInAppPurchase returned null bytes from native side', + ); + return Left( + Exception('Empty response from restore purchase').toFailure(), + ); + } + return Right( + RestoreSubscriptionResponse.fromJson(jsonDecode(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 e8d48a6485..2f3ae8fa48 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'; @@ -348,6 +349,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) { 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