From 93b2cb9cacd45709b98a0b95fc8901df438f6019 Mon Sep 17 00:00:00 2001 From: Haripriyan Soundarrajan Date: Tue, 4 Aug 2026 15:17:50 +0530 Subject: [PATCH 1/2] Adds a mobile token provider to the SDK --- .../java/com/chargebee/android/Chargebee.kt | 115 +++++++++++++++++- .../network/MobileTokenAuthenticator.kt | 50 ++++++++ .../android/resources/BaseResource.kt | 8 ++ 3 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 chargebee/src/main/java/com/chargebee/android/network/MobileTokenAuthenticator.kt diff --git a/chargebee/src/main/java/com/chargebee/android/Chargebee.kt b/chargebee/src/main/java/com/chargebee/android/Chargebee.kt index 0644140..425ac33 100644 --- a/chargebee/src/main/java/com/chargebee/android/Chargebee.kt +++ b/chargebee/src/main/java/com/chargebee/android/Chargebee.kt @@ -22,10 +22,17 @@ import com.chargebee.android.resources.PlanResource import com.chargebee.android.resources.SubscriptionResource import okhttp3.Credentials +/** + * Closure that returns a fresh mobile token from the backend. + * The SDK invokes it during [Chargebee.configure] and whenever a request fails with a 401. + * The provided callback must be invoked with a token, or with null when one cannot be obtained. + */ +typealias CBMobileTokenProvider = (completion: (String?) -> Unit) -> Unit + object Chargebee { var site: String = "" var publishableApiKey: String = "" - var encodedApiKey: String = "" + private var encodedPublishableApiKey: String = "" var sdkKey: String = "" var baseUrl: String = "" var allowErrorLogging: Boolean = true @@ -37,6 +44,23 @@ object Chargebee { const val platform: String = "Android" const val sdkVersion: String = "2.0.0-beta-5" const val limit: String = "100" + + /* + * Mobile token auth. When a token is present the SDK sends it as the Authorization header + * instead of the publishable key. If empty, we fall back to using publishable-key. + */ + var mobileToken: String = "" + var tokenProvider: CBMobileTokenProvider? = null + + val encodedMobileToken: String + get() = if (mobileToken.isNotEmpty()) Credentials.basic(mobileToken, "") else "" + + /* + * The Authorization header the SDK sends on every request: the mobile token when one is configured, + * otherwise the publishable key. Resolved lazily so the value picks up a refreshed token. + */ + val encodedApiKey: String + get() = if (mobileToken.isNotEmpty()) encodedMobileToken else encodedPublishableApiKey private const val PLAY_STORE_SUBSCRIPTION_URL = "https://play.google.com/store/account/subscriptions" private const val SUBSCRIPTION_URL = @@ -53,7 +77,9 @@ object Chargebee { this.applicationId = packageName this.publishableApiKey = publishableApiKey this.site = site - this.encodedApiKey = Credentials.basic(publishableApiKey, "") + this.encodedPublishableApiKey = Credentials.basic(publishableApiKey, "") + this.mobileToken = "" + this.tokenProvider = null this.baseUrl = "https://${site}.chargebee.com/api/" this.allowErrorLogging = allowErrorLogging this.sdkKey = sdkKey @@ -89,7 +115,9 @@ object Chargebee { this.applicationId = packageName this.publishableApiKey = publishableApiKey this.site = site - this.encodedApiKey = Credentials.basic(publishableApiKey, "") + this.encodedPublishableApiKey = Credentials.basic(publishableApiKey, "") + this.mobileToken = "" + this.tokenProvider = null this.baseUrl = "https://${site}.chargebee.com/api/" this.allowErrorLogging = allowErrorLogging this.sdkKey = sdkKey @@ -115,6 +143,87 @@ object Chargebee { } } + /* + * Configure the SDK using a mobile token instead of a publishable API key. The [tokenProvider] is + * invoked to obtain a token from the merchant's backend, both now and whenever a request is + * rejected with a 401 (expired/revoked token). + */ + fun configure( + site: String, + sdkKey: String = "", + packageName: String = "", + allowErrorLogging: Boolean = true, + tokenProvider: CBMobileTokenProvider, + completion: (ChargebeeResult) -> Unit + ) { + this.site = site + this.publishableApiKey = "" + this.encodedPublishableApiKey = "" + this.baseUrl = "https://${site}.chargebee.com/api/" + this.allowErrorLogging = allowErrorLogging + this.sdkKey = sdkKey + this.applicationId = packageName + this.tokenProvider = tokenProvider + + refreshMobileToken { success -> + if (!success) { + completion( + ChargebeeResult.Error( + exp = CBException( + error = ErrorDetail( + message = "Unable to fetch a mobile token from the token provider", + apiErrorCode = "401", + httpStatusCode = 401 + ) + ) + ) + ) + return@refreshMobileToken + } + // Nothing to verify without an SDK key; the environment is ready. + if (TextUtils.isEmpty(sdkKey)) { + completion(ChargebeeResult.Success("Environment Setup Completed")) + return@refreshMobileToken + } + val auth = Auth(sdkKey, applicationId, appName, channel) + CBAuthentication.authenticate(auth) { + when (it) { + is ChargebeeResult.Success -> { + val response = it.data as CBAuthResponse + this.version = response.in_app_detail.product_catalog_version + this.applicationId = response.in_app_detail.app_id + this.appName = response.in_app_detail.app_name + completion(ChargebeeResult.Success(response)) + } + is ChargebeeResult.Error -> { + this.version = CatalogVersion.Unknown.value + completion(it) + } + } + } + } + } + + /* + * Fetches a fresh token from the merchant-supplied provider and stores it. Invokes [completion] + * with false when no provider is configured or the provider returns an empty token. + */ + fun refreshMobileToken(completion: (Boolean) -> Unit) { + val provider = tokenProvider + if (provider == null) { + completion(false) + return + } + provider { token -> + if (!token.isNullOrEmpty()) { + this.mobileToken = token + completion(true) + } else { + completion(false) + } + } + } + /* Get the subscription details from chargebee system */ @Throws(InvalidRequestException::class, OperationFailedException::class) fun retrieveSubscription(subscriptionId: String, completion: (ChargebeeResult) -> Unit) { diff --git a/chargebee/src/main/java/com/chargebee/android/network/MobileTokenAuthenticator.kt b/chargebee/src/main/java/com/chargebee/android/network/MobileTokenAuthenticator.kt new file mode 100644 index 0000000..fcb62a9 --- /dev/null +++ b/chargebee/src/main/java/com/chargebee/android/network/MobileTokenAuthenticator.kt @@ -0,0 +1,50 @@ +package com.chargebee.android.network + +import com.chargebee.android.Chargebee +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Handles expired/revoked mobile tokens: when a request authenticated with a mobile token comes back + * 401, a fresh token is fetched from the provider and the request is retried once + * with it. Requests that do not use a mobile token (no provider configured) are left untouched. + */ +internal class MobileTokenAuthenticator : Authenticator { + + override fun authenticate(route: Route?, response: Response): Request? { + if (Chargebee.tokenProvider == null) { + return null + } + // Retry only once: a non-null priorResponse means we already refreshed and retried. + if (response.priorResponse() != null) { + return null + } + val refreshedHeader = refreshTokenBlocking() ?: return null + return response.request().newBuilder() + .header("Authorization", refreshedHeader) + .build() + } + + // The token provider is asynchronous while the authenticator runs synchronously on OkHttp's + // background thread, so we bridge the callback with a short-lived latch. + private fun refreshTokenBlocking(): String? { + val latch = CountDownLatch(1) + var refreshedHeader: String? = null + Chargebee.refreshMobileToken { success -> + if (success) { + refreshedHeader = Chargebee.encodedApiKey + } + latch.countDown() + } + latch.await(TOKEN_REFRESH_TIMEOUT_SECONDS, TimeUnit.SECONDS) + return refreshedHeader + } + + private companion object { + private const val TOKEN_REFRESH_TIMEOUT_SECONDS = 30L + } +} diff --git a/chargebee/src/main/java/com/chargebee/android/resources/BaseResource.kt b/chargebee/src/main/java/com/chargebee/android/resources/BaseResource.kt index 3f2e42e..5e7069d 100644 --- a/chargebee/src/main/java/com/chargebee/android/resources/BaseResource.kt +++ b/chargebee/src/main/java/com/chargebee/android/resources/BaseResource.kt @@ -1,8 +1,10 @@ package com.chargebee.android.resources +import com.chargebee.android.network.MobileTokenAuthenticator import com.google.gson.FieldNamingPolicy import com.google.gson.Gson import com.google.gson.GsonBuilder +import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory @@ -15,8 +17,14 @@ internal open class BaseResource(baseUrl: String) { .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .create() + // Refreshes and retries once when a mobile-token request is rejected with a 401. + val httpClient = OkHttpClient.Builder() + .authenticator(MobileTokenAuthenticator()) + .build() + apiClient = Retrofit.Builder() .baseUrl(baseUrl) + .client(httpClient) .addConverterFactory(GsonConverterFactory.create(gson)) .build() } From e23b2479769454c5cae67ca001b789344f5ca701 Mon Sep 17 00:00:00 2001 From: Haripriyan Soundarrajan Date: Thu, 6 Aug 2026 13:42:46 +0530 Subject: [PATCH 2/2] Updates example app --- .../com/chargebee/example/MainActivity.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/app/src/main/java/com/chargebee/example/MainActivity.kt b/app/src/main/java/com/chargebee/example/MainActivity.kt index a16cc99..4b450ae 100644 --- a/app/src/main/java/com/chargebee/example/MainActivity.kt +++ b/app/src/main/java/com/chargebee/example/MainActivity.kt @@ -191,6 +191,42 @@ class MainActivity : BaseActivity(), ListItemsAdapter.ItemClickListener { builder.show() } + /* + * Example: configure the SDK with a mobile token fetched from your backend instead of + * embedding a publishable API key in the app. The tokenProvider is invoked now and again + * whenever a request is rejected with a 401, so the SDK can refresh the token. + */ + private fun configureWithMobileToken() { + Chargebee.configure( + site = "cb-abc-test", + sdkKey = "SDK-KEY", + packageName = this.packageName, + tokenProvider = { completion -> + // Ask your backend for a fresh mobile token (it mints one via + // `create_mobile_token`), then hand the raw token back to the SDK. + // Pass null if the token could not be obtained. + fetchMobileToken(completion) + } + ) { + when (it) { + is ChargebeeResult.Success -> { + Log.i(javaClass.simpleName, "Configured with mobile token") + } + is ChargebeeResult.Error -> { + Log.e(javaClass.simpleName, "Configuration failed: ${it.exp.message}") + } + } + } + } + + /* + * Stand-in for the call to your own backend that returns a Chargebee mobile token. + * Replace the body with a real network request to your server. + */ + private fun fetchMobileToken(completion: (String?) -> Unit) { + completion("cb_mob_replace_with_token_from_your_backend") + } + private fun getProductIdFromCustomer() { val dialog = Dialog(this) dialog.setContentView(R.layout.dialog_input_layout)