Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ enum class Methods(val method: String) {
StripeBillingPortal("stripeBillingPortal"),
Plans("plans"),
AcknowledgeInAppPurchase("acknowledgeInAppPurchase"),
RestoreInAppPurchase("restoreInAppPurchase"),
PaymentRedirect("paymentRedirect"),
ReportIssue("reportIssue"),

Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions assets/locales/en.po
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,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"
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
30 changes: 30 additions & 0 deletions ios/Runner/Handlers/MethodHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]) ?? [:]
Expand Down Expand Up @@ -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) {
Expand Down
32 changes: 32 additions & 0 deletions lantern-core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
41 changes: 41 additions & 0 deletions lantern-core/mobile/mobile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) })

Expand Down
62 changes: 57 additions & 5 deletions lib/core/services/app_purchase.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> 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<void> _onPurchaseUpdates(List<PurchaseDetails> purchases) async {
appLogger.info(
'[AppPurchase] Received purchase updates: ${purchases.length}',
Expand Down Expand Up @@ -206,12 +241,26 @@ 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.
if (await _checkIfAlreadyPurchased()) {
Expand Down Expand Up @@ -272,6 +321,7 @@ class AppPurchase {
appLogger.error('[AppPurchase] Error finalizing purchase: $e', e);
} finally {
_pendingPlanId = null;
_isRestoreFlow = false;
}
}

Expand Down Expand Up @@ -314,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}',
Expand All @@ -327,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';
}
Expand Down Expand Up @@ -362,5 +413,6 @@ class AppPurchase {
_onSuccess = null;
_onError = null;
_pendingPlanId = null;
_isRestoreFlow = false;
}
}
2 changes: 1 addition & 1 deletion lib/features/auth/sign_in_email.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ class SignInEmail extends HookConsumerWidget {
boldOnPressed: () {
appRouter.push(Plans());
},
)
),
],
),
),
Expand Down
37 changes: 29 additions & 8 deletions lib/features/plans/plans.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -126,6 +127,17 @@ class _PlansState extends ConsumerState<Plans> {
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(
Expand Down Expand Up @@ -207,14 +219,6 @@ class _PlansState extends ConsumerState<Plans> {
);
},
),
DividerSpace(),
AppTile(
icon: AppImagePaths.restorePurchase,
label: 'restore_purchase'.i18n,
onPressed: () {
appRouter.popAndPush(SignInEmail());
},
),
],
);
},
Expand Down Expand Up @@ -464,4 +468,21 @@ class _PlansState extends ConsumerState<Plans> {
);
}
}

Future<void> _restorePurchaseFlow() async {
try {
final appPurchase = sl<AppPurchase>();
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,
);
}
}
}
Loading
Loading