[Android] Implement Origin subscription linking after purchase (#34719)

Add support for linking Brave Origin subscriptions after purchase on
Android, following the same link-order flow used by Leo (AI Chat).

- Add OriginIAPSubscription Mojo interface and C++ implementation to
  read purchase token/order ID from prefs and persist link status
- Handle product=origin in SubscriptionRenderFrameObserver: inject
  braveOrigin.receipt and braveOrigin.orderId into localStorage on
  the initial landing page, and linkResult.setStatus() callback on
  the /order-link/ result page
- Register OriginIAPSubscription Mojo binding in
  BraveContentBrowserClient
- Update LinkSubscriptionUtils to use link-order intent for Origin
- Hide Purchase section in Origin preferences when already linked
- Move subscription_render_frame_observer.h include out of
  ENABLE_BRAVE_VPN guard so it is available for all Android
  subscription products
- Generalize Leo-specific constants (kIntentParamValueLeo,
  kResultLandingPagePathLeo) since they are now shared with Origin
- Add Origin test cases to browser tests

Resolves: https://github.com/brave/brave-browser/issues/53613
This commit is contained in:
Serg
2026-03-24 12:59:55 -04:00
committed by GitHub
parent 9486920856
commit a537c15708
14 changed files with 241 additions and 18 deletions
@@ -20,7 +20,7 @@ public class LinkSubscriptionUtils {
String braveAccountUrl =
isLinkSubscriptionOnStaging() ? BRAVE_ACCOUNT_URL_STAGING : BRAVE_ACCOUNT_URL;
String linkType =
(SubscriptionProduct.LEO == subscriptionProduct) ? "link-order" : "connect-receipt";
(SubscriptionProduct.VPN == subscriptionProduct) ? "connect-receipt" : "link-order";
String baseUrl = "https://%s?intent=%s&product=%s&mtm_campaign=linking-android";
return String.format(
baseUrl,
@@ -63,6 +63,7 @@ public class BraveOriginPreferences extends BravePreferenceFragment
private static final String PREF_WEB_DISCOVERY_PROJECT_SWITCH = "web_discovery_project_switch";
private static final String PREF_RESET_TO_DEFAULTS = "reset_to_defaults";
private static final String PREF_LINK_PURCHASE = "link_purchase";
private static final String PREF_PURCHASE_SECTION = "origin_purchase_section";
private static final String PREF_BRAVE_ORIGIN = "brave_origin";
private final SettableMonotonicObservableSupplier<String> mPageTitle =
@@ -114,6 +115,14 @@ public class BraveOriginPreferences extends BravePreferenceFragment
Preference linkPurchase = findPreference(PREF_LINK_PURCHASE);
if (linkPurchase != null) {
boolean isLinked = BraveOriginSubscriptionPrefs.isSubscriptionLinked(profile);
linkPurchase.setVisible(!isLinked);
Preference purchaseSection = findPreference(PREF_PURCHASE_SECTION);
if (purchaseSection != null) {
purchaseSection.setVisible(!isLinked);
}
linkPurchase.setOnPreferenceClickListener(
preference -> {
TabUtils.openURLWithBraveActivity(
+27
View File
@@ -258,6 +258,8 @@ using extensions::ChromeContentBrowserClientExtensionsPart;
#if BUILDFLAG(ENABLE_AI_CHAT)
#include "brave/components/ai_chat/core/browser/android/ai_chat_iap_subscription_android.h"
#endif
#include "brave/components/brave_origin/features.h"
#include "brave/components/brave_origin/origin_iap_subscription.h"
#endif
#if BUILDFLAG(ENABLE_TOR)
@@ -435,6 +437,24 @@ void BindIAPSubscription(
}
#endif
#if BUILDFLAG(IS_ANDROID)
void BindOriginIAPSubscription(
content::RenderFrameHost* const frame_host,
mojo::PendingReceiver<brave_origin::mojom::OriginIAPSubscription>
receiver) {
const GURL& frame_host_url = frame_host->GetLastCommittedURL();
if (!skus::IsSafeOrigin(frame_host_url)) {
return;
}
auto* context = frame_host->GetBrowserContext();
auto* profile = Profile::FromBrowserContext(context);
mojo::MakeSelfOwnedReceiver(
std::make_unique<brave_origin::OriginIAPSubscription>(
profile->GetPrefs()),
std::move(receiver));
}
#endif
#if BUILDFLAG(ENABLE_BRAVE_VPN)
void MaybeBindBraveVpnImpl(
content::RenderFrameHost* const frame_host,
@@ -973,6 +993,13 @@ void BraveContentBrowserClient::RegisterBrowserInterfaceBindersForFrame(
#endif
#endif
#if BUILDFLAG(IS_ANDROID)
if (base::FeatureList::IsEnabled(brave_origin::features::kBraveOrigin)) {
map->Add<brave_origin::mojom::OriginIAPSubscription>(
base::BindRepeating(&BindOriginIAPSubscription));
}
#endif
#if BUILDFLAG(ENABLE_SPEEDREADER) && !BUILDFLAG(IS_ANDROID)
content::RegisterWebUIControllerInterfaceBinder<
speedreader::mojom::ToolbarFactory, SpeedreaderToolbarUI>(map);
@@ -17,6 +17,8 @@ source_set("android") {
"//base",
"//brave/components/ai_chat/core/common",
"//brave/components/ai_chat/core/common/mojom",
"//brave/components/brave_origin:features",
"//brave/components/brave_origin/mojom",
"//brave/components/brave_vpn/common/buildflags",
"//brave/components/skus/renderer",
"//brave/gin:converter_specializations",
@@ -47,6 +49,7 @@ source_set("browser_tests") {
deps = [
"//brave/components/ai_chat/core/common",
"//brave/components/brave_mobile_subscription/renderer/android",
"//brave/components/brave_origin:features",
"//brave/components/brave_vpn/common/buildflags",
"//brave/components/skus/common",
"//chrome/common",
@@ -15,6 +15,7 @@
#include "base/strings/strcat.h"
#include "base/strings/utf_string_conversions.h"
#include "brave/components/ai_chat/core/common/features.h"
#include "brave/components/brave_origin/features.h"
#include "brave/components/skus/renderer/skus_utils.h"
#include "brave/gin/converter_specializations.h"
#include "build/build_config.h"
@@ -40,8 +41,9 @@ inline constexpr char kIntentParamTestValue[] = "connect-receipt-test";
inline constexpr char kProductParamName[] = "product";
inline constexpr char kProductVPNParamValue[] = "vpn";
inline constexpr char kProductLeoParamValue[] = "leo";
inline constexpr char kIntentParamValueLeo[] = "link-order";
inline constexpr char kResultLandingPagePathLeo[] = "/order-link/";
inline constexpr char kProductOriginParamValue[] = "origin";
inline constexpr char kIntentParamValueLinkOrder[] = "link-order";
inline constexpr char kResultLandingPagePath[] = "/order-link/";
} // namespace
@@ -71,6 +73,15 @@ bool SubscriptionRenderFrameObserver::EnsureConnected() {
}
bound |= ai_chat_subscription_.is_bound();
}
if (base::FeatureList::IsEnabled(brave_origin::features::kBraveOrigin) &&
product_ == Product::kOrigin) {
if (!origin_subscription_.is_bound()) {
render_frame()->GetBrowserInterfaceBroker().GetInterface(
origin_subscription_.BindNewPipeAndPassReceiver());
}
bound |= origin_subscription_.is_bound();
}
return bound;
}
@@ -117,6 +128,16 @@ void SubscriptionRenderFrameObserver::DidCreateScriptContext(
weak_factory_.GetWeakPtr()));
}
}
} else if (product_ == Product::kOrigin) {
if (origin_subscription_.is_bound()) {
if (page_ == Page::kResultLandingPage) {
AddJavaScriptObjectToFrame(context);
} else if (page_ == Page::kInitialLandingPage) {
origin_subscription_->GetPurchaseTokenOrderId(base::BindOnce(
&SubscriptionRenderFrameObserver::OnGetPurchaseTokenOrderId,
weak_factory_.GetWeakPtr()));
}
}
}
}
@@ -178,13 +199,17 @@ void SubscriptionRenderFrameObserver::SetLinkStatus(
// where value 0 means it's not linked otherwise it's linked.
// VPN uses a different way to detect that.
// It uses Guardian backend call for that.
if (product_ != Product::kLeo || !ai_chat_subscription_.is_bound() ||
status_dict.empty()) {
if (status_dict.empty()) {
return;
}
ai_chat_subscription_->SetLinkStatus(
status_dict.FindInt("status").value_or(0));
if (product_ == Product::kLeo && ai_chat_subscription_.is_bound()) {
ai_chat_subscription_->SetLinkStatus(
status_dict.FindInt("status").value_or(0));
} else if (product_ == Product::kOrigin && origin_subscription_.is_bound()) {
origin_subscription_->SetLinkStatus(
status_dict.FindInt("status").value_or(0));
}
}
std::string SubscriptionRenderFrameObserver::GetPurchaseTokenJSString(
@@ -198,6 +223,8 @@ std::string SubscriptionRenderFrameObserver::GetPurchaseTokenJSString(
receipt_var_name = "braveVpn.receipt";
} else if (product_ == Product::kLeo) {
receipt_var_name = "braveLeo.receipt";
} else if (product_ == Product::kOrigin) {
receipt_var_name = "braveOrigin.receipt";
}
return base::StrCat({"window.localStorage.setItem(\"", receipt_var_name,
@@ -226,8 +253,14 @@ void SubscriptionRenderFrameObserver::OnGetPurchaseTokenOrderId(
}
auto* frame = render_frame();
if (frame && !order_id.empty() && !purchase_token.empty()) {
std::string_view order_id_key;
if (product_ == Product::kLeo) {
order_id_key = "braveLeo.orderId";
} else if (product_ == Product::kOrigin) {
order_id_key = "braveOrigin.orderId";
}
frame->ExecuteJavaScript(base::UTF8ToUTF16(base::StrCat(
{"window.localStorage.setItem(\"braveLeo.orderId\", \"", order_id,
{"window.localStorage.setItem(\"", order_id_key, "\", \"", order_id,
"\");", GetPurchaseTokenJSString(purchase_token)})));
}
}
@@ -274,23 +307,30 @@ bool SubscriptionRenderFrameObserver::IsAllowed() {
product_ = Product::kVPN;
} else if (product == kProductLeoParamValue) {
product_ = Product::kLeo;
// We allow to inject linkResult object if intent value is empty and path is
// /order-link/ as https://account.brave.com?intent=link-order&product=leo
// gets redirected to https://account.brave.com/order-link/?product=leo for
// an actual linking where we should receive the result of linking
} else if (product == kProductOriginParamValue) {
product_ = Product::kOrigin;
} else {
product_ = std::nullopt;
}
// For link-order products (Leo, Origin), we allow injecting the linkResult
// object if intent value is empty and path is /order-link/ as
// https://account.brave.com?intent=link-order&product=<product>
// gets redirected to https://account.brave.com/order-link/?product=<product>
// for an actual linking where we should receive the result of linking.
if (product_ == Product::kLeo || product_ == Product::kOrigin) {
if (intent.empty()) {
std::string_view path = current_url.has_path() ? current_url.path() : "";
if (path == kResultLandingPagePathLeo) {
if (path == kResultLandingPagePath) {
page_ = Page::kResultLandingPage;
}
} else {
page_ = Page::kInitialLandingPage;
}
} else {
product_ = std::nullopt;
}
return (intent == kIntentParamValue || intent == kIntentParamTestValue ||
intent == kIntentParamValueLeo ||
intent == kIntentParamValueLinkOrder ||
(intent.empty() && page_.has_value() &&
page_ == Page::kResultLandingPage)) &&
product_.has_value();
@@ -15,6 +15,7 @@
#include "base/values.h"
#include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom.h"
#include "brave/components/ai_chat/core/common/mojom/common.mojom.h"
#include "brave/components/brave_origin/mojom/brave_origin_settings.mojom.h"
#include "brave/components/brave_vpn/common/buildflags/buildflags.h"
#include "content/public/renderer/render_frame.h"
#include "content/public/renderer/render_frame_observer.h"
@@ -54,7 +55,7 @@ class SubscriptionRenderFrameObserver : public content::RenderFrameObserver {
IsAllowed);
FRIEND_TEST_ALL_PREFIXES(SubscriptionRenderFrameObserverTest, ExtractParam);
FRIEND_TEST_ALL_PREFIXES(SubscriptionRenderFrameObserverTest, IsValueAllowed);
enum class Product { kVPN = 0, kLeo = 1 };
enum class Product { kVPN = 0, kLeo = 1, kOrigin = 2 };
enum class Page { kInitialLandingPage = 0, kResultLandingPage = 1 };
bool EnsureConnected();
@@ -88,6 +89,7 @@ class SubscriptionRenderFrameObserver : public content::RenderFrameObserver {
mojo::Remote<brave_vpn::mojom::ServiceHandler> vpn_service_;
#endif
mojo::Remote<ai_chat::mojom::IAPSubscription> ai_chat_subscription_;
mojo::Remote<brave_origin::mojom::OriginIAPSubscription> origin_subscription_;
base::WeakPtrFactory<SubscriptionRenderFrameObserver> weak_factory_{this};
};
@@ -7,6 +7,7 @@
#include "base/test/scoped_feature_list.h"
#include "brave/components/ai_chat/core/common/buildflags/buildflags.h"
#include "brave/components/brave_origin/features.h"
#include "brave/components/brave_vpn/common/buildflags/buildflags.h"
#include "brave/components/brave_vpn/common/features.h"
#include "brave/components/skus/common/features.h"
@@ -31,6 +32,7 @@ class SubscriptionRenderFrameObserverBrowserTest
#if BUILDFLAG(ENABLE_AI_CHAT)
ai_chat::features::kAIChat,
#endif
brave_origin::features::kBraveOrigin,
},
{});
}
@@ -81,6 +83,13 @@ TEST_F(SubscriptionRenderFrameObserverBrowserTest, IsAllowed) {
EXPECT_TRUE(observer.IsAllowed());
// Origin
LoadHTMLWithUrlOverride(
R"(<html><body></body></html>)",
"https://account.brave.com/?intent=link-order&product=origin");
EXPECT_TRUE(observer.IsAllowed());
// http
LoadHTMLWithUrlOverride(
R"(<html><body></body></html>)",
+7
View File
@@ -45,6 +45,13 @@ static_library("brave_origin") {
"profile_id.h",
]
if (is_android) {
sources += [
"origin_iap_subscription.cc",
"origin_iap_subscription.h",
]
}
friend = [ ":unit_tests" ]
public_deps = [
@@ -25,3 +25,11 @@ interface BraveOriginSettingsHandler {
// Set the value of a Brave Origin controlled policy
SetPolicyValue(string policy_key, bool value) => (bool success);
};
// Interface for interaction between SubscriptionRenderFrameObserver
// (renderer process) and browser process for Origin subscription linking.
[EnableIf=is_android]
interface OriginIAPSubscription {
GetPurchaseTokenOrderId() => (string token, string order_id);
SetLinkStatus(int32 status);
};
@@ -0,0 +1,75 @@
/* Copyright (c) 2026 The Brave Authors. All rights reserved.
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/. */
#include "brave/components/brave_origin/origin_iap_subscription.h"
#include <string>
#include <utility>
#include "base/base64.h"
#include "base/json/json_writer.h"
#include "brave/components/brave_origin/pref_names.h"
#include "components/prefs/pref_service.h"
namespace {
inline constexpr char kDefaultPackage[] = "com.brave.browser";
inline constexpr char kProductId[] = "brave.origin.perpetual";
} // namespace
namespace brave_origin {
OriginIAPSubscription::OriginIAPSubscription(PrefService* prefs)
: prefs_(prefs) {}
OriginIAPSubscription::~OriginIAPSubscription() = default;
void OriginIAPSubscription::GetPurchaseTokenOrderId(
GetPurchaseTokenOrderIdCallback callback) {
std::string order_id_string;
std::string purchase_token_string;
std::string package_string = kDefaultPackage;
std::string product_id_string = kProductId;
auto* purchase_token =
prefs_->FindPreference(prefs::kBraveOriginPurchaseTokenAndroid);
if (purchase_token && !purchase_token->IsDefaultValue()) {
purchase_token_string =
prefs_->GetString(prefs::kBraveOriginPurchaseTokenAndroid);
}
auto* package = prefs_->FindPreference(prefs::kBraveOriginPackageNameAndroid);
if (package && !package->IsDefaultValue()) {
package_string = prefs_->GetString(prefs::kBraveOriginPackageNameAndroid);
}
auto* product_id =
prefs_->FindPreference(prefs::kBraveOriginProductIdAndroid);
if (product_id && !product_id->IsDefaultValue()) {
product_id_string = prefs_->GetString(prefs::kBraveOriginProductIdAndroid);
}
auto* order_id = prefs_->FindPreference(prefs::kBraveOriginOrderIdAndroid);
if (order_id && !order_id->IsDefaultValue()) {
order_id_string = prefs_->GetString(prefs::kBraveOriginOrderIdAndroid);
}
base::DictValue response;
response.Set("type", "android");
response.Set("raw_receipt", purchase_token_string);
response.Set("package", package_string);
response.Set("subscription_id", product_id_string);
std::string response_json;
base::JSONWriter::Write(response, &response_json);
std::move(callback).Run(base::Base64Encode(response_json), order_id_string);
}
void OriginIAPSubscription::SetLinkStatus(int32_t status) {
prefs_->SetInteger(prefs::kBraveOriginSubscriptionLinkStatusAndroid, status);
}
} // namespace brave_origin
@@ -0,0 +1,37 @@
/* Copyright (c) 2026 The Brave Authors. All rights reserved.
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/. */
#ifndef BRAVE_COMPONENTS_BRAVE_ORIGIN_ORIGIN_IAP_SUBSCRIPTION_H_
#define BRAVE_COMPONENTS_BRAVE_ORIGIN_ORIGIN_IAP_SUBSCRIPTION_H_
#include "brave/components/brave_origin/mojom/brave_origin_settings.mojom.h"
class PrefService;
namespace brave_origin {
// OriginIAPSubscription is responsible for interaction between
// SubscriptionRenderFrameObserver (renderer process) and browser process
// for Origin subscription linking on Android.
class OriginIAPSubscription final
: public brave_origin::mojom::OriginIAPSubscription {
public:
OriginIAPSubscription(const OriginIAPSubscription&) = delete;
OriginIAPSubscription& operator=(const OriginIAPSubscription&) = delete;
explicit OriginIAPSubscription(PrefService* prefs);
~OriginIAPSubscription() override;
// brave_origin::mojom::OriginIAPSubscription
void GetPurchaseTokenOrderId(
GetPurchaseTokenOrderIdCallback callback) override;
void SetLinkStatus(int32_t status) override;
private:
raw_ptr<PrefService> prefs_ = nullptr;
};
} // namespace brave_origin
#endif // BRAVE_COMPONENTS_BRAVE_ORIGIN_ORIGIN_IAP_SUBSCRIPTION_H_
+1
View File
@@ -26,6 +26,7 @@ include_rules += [
"+brave/components/speedreader/renderer",
"+brave/components/brave_vpn/common",
"!brave/components/brave_mobile_subscription/renderer/android/subscription_render_frame_observer.h",
"+brave/components/brave_origin/features.h",
"+brave/components/playlist/core/common",
"+brave/components/playlist/content/renderer",
"+brave/components/web_discovery/buildflags",
+5 -1
View File
@@ -52,10 +52,12 @@
#if BUILDFLAG(ENABLE_BRAVE_VPN)
#include "brave/components/brave_vpn/common/brave_vpn_utils.h"
#endif // BUILDFLAG(ENABLE_BRAVE_VPN)
#if BUILDFLAG(IS_ANDROID)
#include "brave/components/brave_mobile_subscription/renderer/android/subscription_render_frame_observer.h"
#include "brave/components/brave_origin/features.h"
#endif // BUILDFLAG(IS_ANDROID)
#endif // BUILDFLAG(ENABLE_BRAVE_VPN)
#if BUILDFLAG(ENABLE_WIDEVINE)
#include "media/base/key_system_info.h"
@@ -191,6 +193,8 @@ void BraveContentRendererClient::RenderFrameCreated(
should_create_subscription_observer |=
ai_chat::features::IsAIChatHistoryEnabled();
#endif // BUILDFLAG(ENABLE_AI_CHAT)
should_create_subscription_observer |=
base::FeatureList::IsEnabled(brave_origin::features::kBraveOrigin);
if (should_create_subscription_observer) {
new brave_subscription::SubscriptionRenderFrameObserver(
render_frame, content::ISOLATED_WORLD_ID_GLOBAL);
+1
View File
@@ -23,6 +23,7 @@ brave_chrome_renderer_sources = [
brave_chrome_renderer_deps = [
"//brave/common:mojo_bindings",
"//brave/components/ai_chat/core/common/buildflags",
"//brave/components/brave_origin:features",
"//brave/components/brave_search/common",
"//brave/components/brave_search/renderer",
"//brave/components/brave_shields/core/common",