SubSovereign sur Android — guide d'intégration
Ce guide accompagne votre application Android (téléphone, tablette, Android TV ou Amazon Fire TV) depuis « Je ne sais pas qui m'a payé » jusqu'à « Mon application débloque les bonnes fonctionnalités pour le bon utilisateur, vérifiées sur mon propre serveur. » Il est conçu pour être suivi du début à la fin — aucune expérience préalable avec les outils de gestion des abonnements n'est requise. Chaque exemple de code Kotlin est compilé contre le SDK à chaque exécution de test, donc ce que vous lisez ici correspond exactement à ce que fait le code.
Ce que SubSovereign fait pour vous
Vos utilisateurs s'abonnent via le magasin Google Play (ou Amazon sur Fire TV). SubSovereign répond à une seule question pour votre application, de manière fiable : qu'est-ce que cet utilisateur a réellement payé ?
- Votre application demande à SubSovereign le droit d'accès de l'utilisateur — les fonctionnalités qu'il a débloquées.
- La validation du reçu se fait sur le serveur, directement avec le magasin, donc une application modifiée ne peut pas simuler un abonnement.
- Le mur de paiement — l'écran qui propose vos offres — est configuré à distance et dessiné par le SDK, ce qui vous permet de modifier les prix, les essais et les formulations sans publier une nouvelle version de l'application.
- La fonction de rétractation UE — le contrôle de résiliation statutaire — est également dessinée par le SDK, dans la langue du client, afin qu'un avis légal ne se transforme pas en argument marketing.
- Il est auto-hébergé : SubSovereign s'exécute sur votre infrastructure, les données de vos utilisateurs restent chez vous, et il n'y a aucun partage de revenus — vous conservez 100 % de ce que vos utilisateurs paient.
Vous ne faites jamais confiance au téléphone. Le téléphone demande ; le serveur décide.
Avant de commencer
Vous aurez besoin de :
- Un serveur SubSovereign en cours d'exécution (votre déploiement auto-hébergé). Vous indiquerez au SDK son URL. Si vous n'en avez pas encore, déployez-le d'abord — le reste de ce guide suppose qu'il est en ligne.
- Une application enregistrée dans le tableau de bord. Dans le tableau de bord SubSovereign, créez une application. Vous obtiendrez deux éléments :
- un
appId(identifie votre application), et - une clé API (le justificatif que votre application utilise pour communiquer avec le serveur). Enregistrez-la pour la surface sur laquelle elle est diffusée — téléphone, Android TV ou Fire TV. Un même codebase diffusé sur plusieurs surfaces est acceptable ; voir Téléphones et téléviseurs sous l'étape 2.
- un
- Niveaux d'accès créés. Dans le tableau de bord, définissez les niveaux d'accès (également appelés paliers) que votre application accorde — par exemple
prooupremium— et associez chacun aux identifiants de produit du magasin que vos utilisateurs achètent. - Le système de facturation du magasin déjà fonctionnel. SubSovereign valide et suit les achats ; il ne remplace pas Google Play Billing (ou Amazon IAP). Configurez-les normalement dans votre application — SubSovereign intervient juste après l'achat pour le vérifier et l'enregistrer.
Côté code, vous avez besoin de Kotlin avec coroutines (les appels du SDK sont des fonctions suspend), Jetpack Compose (le mur de paiement et le contrôle de résiliation sont des composables), et minSdk 24 ou supérieur.
Étape 1 — Ajouter le SDK
Le SDK est distribué sous forme de code source pour l'instant : il n'y a rien à télécharger depuis Maven Central. Prenez le dossier sdk-android/src/main/java du dépôt SubSovereign dans votre projet et compilez-le comme partie de votre application — exactement comme le fait l'application exemple dans le même dépôt. Voici ce dont le SDK a besoin, en plus de ce que votre application possède déjà : les deux plugins que son code utilise, et ses propres dépendances verrouillées aux versions avec lesquelles il est construit et testé (la dernière ligne est ce que l'exemple utilise pour afficher un composable à l'écran). Sur AGP 8, appliquez également org.jetbrains.kotlin.android ; sur AGP 9, Kotlin est intégré. Déclarez les versions des plugins dans votre fichier de build racine, comme le fait l'exemple.
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization") // the SDK's models are @Serializable
}
android {
// ... your existing settings; minSdk 24 or higher
buildFeatures { compose = true }
// Compile the SDK's source folder as part of your app — wherever you put it.
sourceSets["main"].kotlin.srcDir("../subsovereign-sdk/src/main/java")
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation("io.coil-kt:coil-compose:2.7.0") // hero images — the SDK's one UI dependency
implementation(platform("androidx.compose:compose-bom:2024.09.02"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.runtime:runtime")
implementation("androidx.activity:activity-compose:1.9.3") // setContent, as in the sample
}
Assurez-vous que votre application dispose de la permission internet dans AndroidManifest.xml :
<uses-permission android:name="android.permission.INTERNET" />
Comme le SDK est compilé en tant que votre propre code source, il n'y a pas de version à mettre à jour : pour le mettre à jour, remplacez le dossier. Lorsqu'un artefact publié existera, cette étape se résumera à une ligne de dépendance et ce guide le mentionnera.
Étape 2 — Configurer une seule fois, au démarrage de l'application
Configurez le SDK une seule fois — le meilleur endroit est votre Application.onCreate(), ou juste après la connexion de l'utilisateur. Vous lui fournissez votre clé API, votre identifiant d'application, un identifiant stable pour cet utilisateur, sa langue, et sur quelle surface l'application s'exécute.
import com.subsovereign.sdk.SubSovereign
import com.subsovereign.sdk.SubSovereignConfig
import com.subsovereign.sdk.detectPlatform
SubSovereign.configure(
SubSovereignConfig(
apiKey = "YOUR_APP_API_KEY", // from the dashboard
appId = "your-app-id", // from the dashboard
baseUrl = "https://subs.yourdomain.com/api/v1", // YOUR self-hosted server
userId = currentUser.id, // your own stable user id
locale = customerLanguage, // the CUSTOMER's language, e.g. "fr" — decides the words they get
platform = detectPlatform(this), // "android", "androidtv" or "firetv"
)
)
Quelques points à connaître :
userIdvous appartient. Utilisez l'identifiant stable que vous avez déjà pour un utilisateur connecté. Utilisez la même valeur à chaque fois pour que l'accès de l'utilisateur le suive sur tous ses appareils. Si vous gérez la déconnexion et qu'un autre utilisateur se connecte, appelez simplementconfigureà nouveau avec le nouveauuserId.baseUrlpointe vers votre serveur. La valeur par défaut dans le SDK est un placeholder — définissez-la sur votre propre déploiement SubSovereign.localeest la langue du client, pas celle de votre console, et elle est décidée ICI : le serveur renvoie le mur de paiement et les termes de l'avis statutaire dans la langue que vous configurez, et le SDK formate les dates pour celle-ci. Prenez-la depuis le profil du client ou l'appareil, pas une valeur littérale.
Téléphones et téléviseurs
Un même codebase Play peut être diffusé sur téléphones, tablettes et Android TV ; une build Fire TV est diffusée via Amazon. platform indique au SDK sur quelle surface il se trouve — "android", "androidtv" ou "firetv" — et cela détermine trois choses : quel mur de paiement le serveur envoie (un mur de paiement télévisé est conçu pour une pièce, pas pour une main), la surface sous laquelle une résiliation est enregistrée, et où vos clients sont enregistrés comme provenant. detectPlatform(context) le détermine à l'exécution : un Fire TV par la fonctionnalité propre à Amazon, une Android TV par sa configuration ou Leanback, tout le reste comme un téléphone. Ne codez pas en dur "android" dans une build qui s'exécute également sur des téléviseurs — cela récupérerait le mur de paiement téléphone sur le téléviseur.
Sur Fire TV, enregistrez l'application en tant que Fire TV dans le tableau de bord, ou publiez un mur de paiement Fire TV pour celle-ci. Une build Fire TV demande au serveur le mur de paiement Fire TV, et un serveur qui n'en a pas répond avec une Error plutôt qu'avec le mur de paiement téléphone (le serveur fait une fallback d'Android TV vers le mur de paiement téléphone, et de Fire TV vers rien). Le SDK ne devine jamais une surface : une valeur platform qu'il ne reconnaît pas est une Error qui la nomme, et rien n'est envoyé.
Étape 3 — Vérifier ce à quoi l'utilisateur peut accéder
La réponse rapide oui/non est hasAccess(). Elle échoue en mode sécurisé : en cas de problème réseau, d'erreur serveur, ou de SDK non configuré, elle répond false, donc une panne momentanée ne peut jamais débloquer une fonctionnalité payante. Utilisez-la au point de verrouillage d'une fonctionnalité :
lifecycleScope.launch {
if (SubSovereign.hasAccess("pro")) unlockProFeatures() else showPaywall()
}
hasAccess("pro") est vrai uniquement lorsqu'un droit d'accès actif porte ce nom de niveau d'accès ; hasAccess() sans nom est le verdict global du serveur.
Lorsque vous avez besoin des détails — ce qui est actif exactement, quand cela se renouvelle, ou pour distinguer un « non » d'un « impossible de vérifier » — appelez checkEntitlements(). Il renvoie un résultat sur lequel vous décidez, et la bonne décision en cas d'erreur est généralement de maintenir le client là où il était :
import com.subsovereign.sdk.SubSovereignResult
import kotlinx.coroutines.launch
lifecycleScope.launch {
when (val result = SubSovereign.checkEntitlements()) {
is SubSovereignResult.Success -> {
val ent = result.data
if (ent.hasAccess) unlockProFeatures() else showPaywall()
}
is SubSovereignResult.Error -> {
// A network hiccup or a server error. Keep the customer on whatever access they
// last had and try again later — never lock a paying customer out over a blip.
Log.w("MyApp", "Entitlement check failed: ${result.message}")
}
}
}
Si votre application a plusieurs paliers, examinez result.data.entitlements — chacun vous indique exactement quel niveau d'accès est actif. Faites correspondre le nom que vous avez donné au palier dans le tableau de bord ; accessLevelId est l'identifiant interne du serveur, pas ce nom :
val isPro = result.data.entitlements.any { it.isActive && it.accessLevelName.equals("pro", ignoreCase = true) }
Chaque droit d'accès porte également expiresAt, willRenew, et le store d'où il provient — utile pour les messages du type « votre abonnement se renouvelle le… ». fromCache signifie que le serveur a répondu depuis son propre cache de courte durée plutôt que depuis sa base de données ; ce n'est jamais une réponse hors ligne — un serveur inaccessible est une Error, et ce SDK ne conserve aucune copie.
Étape 4 — Vendre un abonnement
Récupérer le mur de paiement
Récupérez le mur de paiement depuis le serveur plutôt que de coder en dur les prix dans votre application. Cela vous permet de lancer une promotion ou de modifier la durée d'un essai sans publier de mise à jour :
lifecycleScope.launch {
when (val result = SubSovereign.getPaywallConfig()) {
is SubSovereignResult.Success -> showPaywall(result.data) // the paywall published for THIS surface
is SubSovereignResult.Error -> showFallbackPaywall() // your own built-in default
}
}
PaywallConfig contient le titre, les fonctionnalités, les produits à proposer (chacun avec un prix d'affichage, une période, un essai et un badge optionnel), l'appel à l'action, le pied de page, les couleurs et le modèle — tout ce dont l'écran a besoin.
Le dessiner
Le SDK dessine le mur de paiement. PaywallView est un composable : donnez-lui la configuration et dites-lui quoi faire lorsque le client choisit un produit.
import androidx.compose.runtime.Composable
@Composable
fun PaywallScreen(config: PaywallConfig, onBuy: (String) -> Unit) {
PaywallView(
config = config,
onDismiss = { /* the customer closed it — only reachable when you allow closing in the console */ },
) { productId -> onBuy(productId) } // start Google Play Billing (or Amazon IAP) for this product
}
Il gère la mise en page, les couleurs que vous avez choisies dans la console (et garde le texte lisible même lorsqu'un dégradé l'aurait rendu peu visible), le bouton de fermeture si vous l'avez autorisé, et — sur un téléviseur — le cercle qui montre quel contrôle la télécommande a sélectionné. Vous lancez le flux d'achat du magasin pour le produit qu'il vous transmet. L'afficher à l'écran suit la structure Compose habituelle :
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
class PaywallActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { PaywallScreen(config = paywall) { productId -> startPurchase(productId) } } // `paywall`: the PaywallConfig you fetched above
}
}
Finaliser l'achat, puis le vérifier
Exécutez l'achat via Google Play Billing exactement comme vous le feriez normalement. Lorsque Google vous renvoie un purchaseToken, transmettez-le à SubSovereign pour que le serveur puisse valider le reçu directement avec le magasin et accorder le niveau d'accès. Le palier provient de la correspondance produit-niveau dans votre tableau de bord, jamais de l'application — une application modifiée ne peut pas s'octroyer des droits supérieurs — donc accessLevelId est accepté pour la compatibilité et ignoré :
lifecycleScope.launch {
val ok = SubSovereign.validateGooglePurchase(
purchaseToken = purchase.purchaseToken, // from Google Play Billing
productId = purchase.products.first(),
accessLevelId = "pro", // required by the SDK; the server grants the tier YOUR DASHBOARD maps this product to
)
when (ok) {
is SubSovereignResult.Success ->
// Confirmed by the server: re-check (fail-closed) and unlock on the answer, not on hope.
if (ok.data && SubSovereign.hasAccess("pro")) unlockProFeatures() else showTryAgain("Not confirmed yet")
is SubSovereignResult.Error -> showTryAgain(ok.message)
}
}
Voilà tout le modèle de confiance : l'achat n'est réel que lorsque le serveur l'a confirmé.
Sur Amazon Fire TV
Les applications Fire TV utilisent le système d'achat intégré d'Amazon au lieu de Google Play. Le principe est le même avec des entrées différentes : Amazon vous fournit un reçu et un objet données utilisateur, et le reçu n'est valide qu'avec l'identifiant utilisateur propre à Amazon — qui n'est pas votre userId. Le serveur fait correspondre le SKU au niveau d'accès, donc il n'y a pas de accessLevelId à transmettre :
SubSovereign.validateAmazonPurchase(
receiptId = receipt.receiptId, // from Amazon's PurchaseResponse
amazonUserId = userData.userId, // from Amazon's UserData — NOT your own userId
productId = receipt.sku,
)
Étape 5 — La fonction de rétractation UE (Compliance Passport)
Si vous vendez des abonnements à des consommateurs de l'UE, la Directive (UE) 2023/2673 exige un contrôle de rétractation clairement identifié. Le SDK l'intègre prêt à l'emploi, et il est dessiné en deux parties : récupérez les paramètres pour votre application, puis dessinez le contrôle avec ceux-ci.
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@Composable
fun CancelSubscription(subscriptionId: String, customerLocale: String) {
var settings by remember { mutableStateOf<WithdrawalConfig?>(null) }
LaunchedEffect(Unit) {
when (val r = SubSovereign.getWithdrawalConfig()) {
is SubSovereignResult.Success -> settings = r.data
// Log it: a silent failure here is how you ship English to German customers and never find out.
is SubSovereignResult.Error -> Log.w("MyApp", "Withdrawal settings unavailable: ${r.message}")
}
}
CancelButton(subscriptionId, settings, customerLocale)
}
@Composable
fun CancelButton(subscriptionId: String, settings: WithdrawalConfig?, customerLocale: String) {
if (settings?.enabled == false) return // switched off for this app in the console
// The WORDS are not passed in and cannot be: getWithdrawalConfig() fetched them in your
// customer's language and the SDK kept them, and this control reads them from there.
// Fetch FIRST — without it the notice falls back to English for every customer.
WithdrawalView(
subscriptionId = subscriptionId,
labelKey = settings?.labelKey, // ← the label chosen for this app
locale = customerLocale, // ← without this the DATE is formatted for the device
appearance = Appearance.AUTO, // LIGHT, DARK or AUTO — and nothing else
)
}
Vous ne pouvez pas modifier la formulation. Il existe deux libellés pour la fonction de rétractation — « Rétractez-vous du contrat ici » et « Résilier ici » — et labelKey porte celui que votre application utilise ; les mots eux-mêmes proviennent du serveur dans la langue du client. Il n'est pas possible de transmettre votre propre texte : un paramètre label acceptait autrefois du texte libre et est désormais ignoré avec un avertissement dans les logs. Un avis statutaire que l'on peut reformuler est un avis que l'on peut adoucir en argument marketing, ce qui est la même raison pour laquelle vous ne pouvez pas transmettre de couleur.
🚩 Transmettez strings, sinon l'avis statutaire sera en anglais pour tout le monde. Les mots sont fournis par votre serveur SubSovereign dans les langues qu'il propose ; une langue qu'il ne possède pas revient en anglais. La langue que vous obtenez est décidée par le locale que vous avez configuré à l'étape 2 — l'appel des paramètres l'envoie — donc customerLocale ici doit avoir la même valeur ; il formate la date, il ne choisit pas les mots. Le contrôle affiche ce qu'on lui donne et ne récupère pas les mots lui-même, car le même contrat doit s'appliquer sur toutes les plateformes, y compris celles où un composant d'interface ne peut pas faire de réseau du tout. Si la récupération échoue, le contrôle apparaît tout de même, en anglais, plutôt que de ne pas apparaître : ne pas mettre un contrôle de rétractation identifiable devant le consommateur est une infraction ; l'afficher dans la mauvaise langue ne l'est pas. Journalisez l'erreur ; ne la montrez jamais au client.
🚩 Transmettez également locale. Il formate l'horodatage sur l'accusé de réception. Sans cela, le SDK utilise la locale que vous avez configurée ; sans les deux, la date est imprimée sous une forme qu'aucun lecteur ne peut mal interpréter. Un client allemand sur un appareil configuré en anglais américain ne doit pas voir 9/3/2026, qu'il interpréterait comme le 9 mars.
Apparence — LIGHT, DARK ou AUTO (par défaut), et rien d'autre. Le contrôle dessine sa propre carte et son propre texte, afin qu'il soit lisible sur n'importe quel écran, clair ou sombre. AUTO suit le paramètre de mode sombre de l'appareil sur un téléphone ou une tablette et est toujours sombre sur un téléviseur. Il n'y a délibérément aucun moyen de transmettre une couleur.
Il affiche le bouton libellé, demande une confirmation, soumet de manière idempotente (une nouvelle tentative réseau ne peut jamais créer deux rétractations), et montre l'accusé de réception avec sa date. Le verrouillage sur enabled vous appartient, comme ci-dessus ; le contrôle lui-même ne fait aucun réseau au-delà de la soumission.
Étape 6 — Consentement et confidentialité
Tout ce dont une demande de confidentialité a besoin tient en un appel pour chaque action, et tous passent par votre propre serveur :
SubSovereign.recordConsent(purpose = "analytics", granted = true)
SubSovereign.setDoNotSell(enabled = true) // CCPA "do not sell" / Global Privacy Control
SubSovereign.requestErasure() // GDPR Art. 17 — the customer's right to be forgotten
val export = SubSovereign.exportMyData() // GDPR Art. 20 — their data, as JSON
val flags = SubSovereign.getFeatureFlags() // remote feature flags for this app
SubSovereign.recordAttributionTouch(utmSource = "newsletter") // where this customer first came from
Le purpose de recordConsent est l'un des suivants : analytics, marketing, personalisation ou consumption_data_sharing — tout autre chose est une Error ; jurisdiction est par défaut "GDPR" et policyVersion "1.0", transmettez les vôtres si elles diffèrent. Chacun renvoie un SubSovereignResult comme tout autre appel.
Travailler avec les résultats
Chaque appel du SDK, à l'exception de hasAccess(), renvoie un SubSovereignResult, qui est soit Success (avec les données), soit Error (avec un message et, le cas échéant, un code de statut HTTP code). C'est délibéré : les appels réseau peuvent échouer, et votre application doit décider quoi faire plutôt que de planter. Une habitude simple :
- En cas de
Success— utilisez les données. - En cas d'
Error— journalisez-la, maintenez l'utilisateur sur son dernier accès connu, et réessayez plus tard. Ne bloquez jamais un utilisateur payant à cause d'une panne réseau momentanée. hasAccess()est l'exception : c'est un simpleBooleanet répondfalseen cas d'erreur, donc utilisez-le pour le verrouillage etcheckEntitlements()pour expliquer.
Bonnes pratiques
- Vérifiez au démarrage à froid. Appelez
checkEntitlements()au lancement de l'application pour que le verrouillage soit correct avant que l'utilisateur n'atteigne une fonctionnalité verrouillée. - Revérifiez après un achat. Juste après un appel
validate…réussi, exécutez à nouveaucheckEntitlements()pour que l'interface reflète immédiatement le nouvel accès. - Ne faites jamais confiance au client. Ne stockez pas « est pro » dans l'application et ne le traitez pas comme une vérité. Demandez au serveur ; le serveur a vérifié le reçu.
- Un
userIdpar utilisateur réel. Gardez-le stable pour que l'accès suive l'utilisateur sur tous ses appareils, et reconfigurez-le lorsque l'utilisateur connecté change. - Laissez le SDK détecter la surface.
detectPlatform(context)au moment de la configuration, jamais une valeur littérale dans une build diffusée sur plusieurs types d'appareils. - Gardez les coroutines propres. Ce sont des fonctions
suspend— appelez-les depuis unlifecycleScopeouviewModelScopepour qu'elles s'annulent avec l'écran.
Référence rapide
| Vous voulez… | Appelez |
|---|---|
| Configurer le SDK | SubSovereign.configure(config) avec platform = detectPlatform(context) |
| Verrouiller une fonctionnalité, échouer en mode sécurisé | hasAccess(nomNiveauAcces?) → Boolean |
| Voir ce que l'utilisateur a débloqué | checkEntitlements() → EntitlementResult |
| Récupérer le mur de paiement pour cette surface | getPaywallConfig() → PaywallConfig |
| Dessiner le mur de paiement | PaywallView(config, onDismiss) { productId -> … } |
| Vérifier un achat Google Play | validateGooglePurchase(purchaseToken, productId, accessLevelId) |
| Vérifier un achat Amazon (Fire TV) | validateAmazonPurchase(receiptId, amazonUserId, productId) |
| Récupérer les paramètres et les mots de la rétractation | getWithdrawalConfig() → WithdrawalConfig |
| Dessiner la fonction de rétractation UE | WithdrawalView(subscriptionId, strings, labelKey, locale, appearance) |
| Enregistrer un consentement GDPR | recordConsent(purpose, granted) |
| CCPA ne pas vendre · effacement · export · indicateurs · attribution | setDoNotSell · requestErasure · exportMyData · getFeatureFlags · recordAttributionTouch |
Prochaines étapes
- Faites de même sur vos autres plateformes — les SDK iOS, Web/JavaScript et Roku suivent la même structure.
- Nouveau sur ces concepts ? Lisez Comment fonctionne SubSovereign.