SubSovereign su Android — guida all'integrazione
Questa guida accompagna la tua app Android (smartphone, tablet, Android TV o Amazon Fire TV) dal punto "Non so chi mi ha pagato" a "La mia app sblocca le funzionalità giuste per l'utente giusto, verificate sul mio server." È scritta per essere seguita dall'inizio alla fine — non si presuppone alcuna esperienza precedente con strumenti di gestione degli abbonamenti. Ogni esempio di codice Kotlin è compilato contro l'SDK ad ogni esecuzione di test, quindi ciò che leggi qui corrisponde esattamente a ciò che fa il codice.
Cosa fa SubSovereign per te
I tuoi utenti si abbonano tramite il negozio Google Play (o Amazon su Fire TV). SubSovereign risponde a una sola domanda per la tua app, in modo affidabile: per cosa ha effettivamente pagato questo utente?
- La tua app chiede a SubSovereign il diritto di accesso dell'utente — ciò che ha sbloccato.
- La validazione della ricevuta avviene sul server, direttamente con il negozio, quindi un'app modificata non può simulare un abbonamento.
- Il paywall — la schermata che propone i tuoi piani — è configurato da remoto e disegnato dall'SDK, così puoi modificare prezzi, prove gratuite e testi senza rilasciare una nuova versione dell'app.
- La funzione di recesso UE — il controllo di cancellazione statutario — è disegnata anch'essa dall'SDK, nella lingua del cliente, in modo che un avviso legale non si trasformi in marketing.
- È self-hosted: SubSovereign gira sulla tua infrastruttura, i dati dei tuoi utenti rimangono con te e non c'è alcuna condivisione dei ricavi — trattieni il 100% di ciò che pagano i tuoi utenti.
Non fidarti mai del telefono. Il telefono chiede; il server decide.
Prima di iniziare
Avrai bisogno di:
- Un server SubSovereign in esecuzione (la tua distribuzione self-hosted). Punterai l'SDK al suo URL. Se non ne hai ancora uno, distribuiscilo prima — il resto di questa guida presuppone che sia attivo.
- Un'app registrata nella dashboard. Nella dashboard di SubSovereign, crea un'app. Otterrai due cose:
- un
appId(identifica la tua app), e - una chiave API (la credenziale che la tua app usa per comunicare con il server). Registrala per la superficie su cui viene distribuita — smartphone, Android TV o Fire TV. Un unico codebase distribuito su più superfici va bene; vedi Smartphone e televisori sotto il Passo 2.
- un
- Livelli di accesso creati. Nella dashboard, definisci i livelli di accesso (chiamati anche tier) che la tua app concede — ad esempio
proopremium— e collega ciascuno agli ID dei prodotti del negozio che i tuoi utenti acquistano. - Il billing del negozio già funzionante. SubSovereign convalida e traccia gli acquisti; non sostituisce Google Play Billing (o Amazon IAP). Configurali nella tua app come di consueto — SubSovereign si posiziona subito dopo l'acquisto per verificarlo e registrarlo.
Dal lato del codice, ti serve Kotlin con coroutine (le chiamate dell'SDK sono funzioni suspend), Jetpack Compose (il paywall e il controllo di cancellazione sono composable), e minSdk 24 o superiore.
Passo 1 — Aggiungi l'SDK
L'SDK viene distribuito come codice sorgente per ora: non c'è nulla da scaricare da Maven Central. Prendi la cartella sdk-android/src/main/java dal repository di SubSovereign e includila nel tuo progetto, compilandola come parte della tua app — esattamente come fa l'app di esempio nello stesso repository. Di seguito trovi ciò di cui l'SDK stesso ha bisogno, da aggiungere a ciò che la tua app ha già: i due plugin che il suo codice utilizza e le sue dipendenze bloccate alle versioni con cui è stato costruito e testato (l'ultima riga è ciò che l'esempio usa per mostrare un composable sullo schermo). Su AGP 8 applica anche org.jetbrains.kotlin.android; su AGP 9 Kotlin è integrato. Dichiara le versioni dei plugin nel file di build principale, come fa l'esempio.
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
}
Assicurati che la tua app abbia il permesso di internet in AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
Poiché l'SDK viene compilato come tuo codice sorgente, non c'è una versione da aggiornare: per aggiornarlo, sostituisci la cartella. Quando esisterà un artefatto pubblicato, questo passaggio diventerà una riga di dipendenza e questa guida lo specificherà.
Passo 2 — Configura una volta sola, all'avvio dell'app
Configura l'SDK una sola volta — il posto migliore è Application.onCreate(), o subito dopo che l'utente ha effettuato l'accesso. Gli fornisci la tua chiave API, il tuo app ID, un identificatore stabile per questo utente, la sua lingua e su quale superficie l'app è in esecuzione.
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"
)
)
Alcune cose da sapere:
userIdè tuo. Usa qualsiasi ID stabile tu abbia già per un utente che ha effettuato l'accesso. Usa lo stesso valore ogni volta in modo che l'accesso dell'utente lo segua su più dispositivi. Se supporti il logout e un utente diverso effettua l'accesso, chiama semplicementeconfiguredi nuovo con il nuovouserId.baseUrlpunta al tuo server. Il valore predefinito nell'SDK è un segnaposto — impostalo sul tuo deployment di SubSovereign.localeè la lingua del cliente, non quella della tua console, ed è decisa QUI: il server restituisce il paywall e le parole dell'avviso statutario nella lingua che configuri, e l'SDK formatta le date per essa. Prendila dal profilo del cliente o dal dispositivo, non da un valore letterale.
Smartphone e televisori
Un unico codebase di Play può essere distribuito su smartphone, tablet e Android TV; una build per Fire TV viene distribuita tramite Amazon. platform dice all'SDK su quale superficie si trova — "android", "androidtv" o "firetv" — e questo decide tre cose: quale paywall invia il server (un paywall televisivo è progettato per una stanza, non per una mano), la superficie sotto cui viene registrata una cancellazione e da dove provengono i tuoi clienti. detectPlatform(context) lo determina a runtime: un Fire TV tramite la funzionalità del dispositivo di Amazon, un Android TV tramite la sua configurazione o Leanback, tutto il resto come smartphone. Non scrivere "android" in modo fisso in una build che gira anche su televisori — altrimenti verrà recuperato il paywall per smartphone sulla TV.
Su Fire TV, registra l'app come Fire TV nella dashboard, o pubblica un paywall per Fire TV. Una build per Fire TV chiede al server il paywall per Fire TV, e un server che non ne ha risponde con un Error invece che con il paywall per smartphone (il server fa fallback da Android TV al paywall per smartphone, e da Fire TV a nulla). L'SDK non indovina mai la superficie: un valore di platform che non riconosce è un Error che lo nomina, e nulla viene inviato.
Passo 3 — Verifica cosa può accedere l'utente
La risposta rapida sì/no è hasAccess(). Fails closed (fallisce in modo sicuro): in caso di problema di rete, errore del server o SDK non configurato, risponde false, quindi un'interruzione momentanea non può mai sbloccare una funzionalità a pagamento. Usalo nel punto in cui vuoi limitare l'accesso a una funzionalità:
lifecycleScope.launch {
if (SubSovereign.hasAccess("pro")) unlockProFeatures() else showPaywall()
}
hasAccess("pro") è vero solo quando un diritto di accesso attivo ha quel nome di livello di accesso; hasAccess() senza nome è il verdetto complessivo del server.
Quando hai bisogno dei dettagli — cosa è attivo esattamente, quando si rinnova, o per distinguere un "no" da un "impossibile verificare" — chiama checkEntitlements(). Restituisce un risultato su cui decidi tu, e la decisione giusta in caso di errore è di solito mantenere il cliente dove si trovava:
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}")
}
}
}
Se la tua app ha più di un livello, controlla all'interno di result.data.entitlements — ognuno ti dice esattamente quale livello di accesso è attivo. Fai il match sul nome che hai dato al livello nella dashboard; accessLevelId è l'id interno del server, non quel nome:
val isPro = result.data.entitlements.any { it.isActive && it.accessLevelName.equals("pro", ignoreCase = true) }
Ogni diritto di accesso riporta anche expiresAt, willRenew e lo store da cui proviene — utile per messaggi come "il tuo abbonamento si rinnova il...". fromCache significa che il server ha risposto dalla propria cache a breve termine piuttosto che dal database; non è mai una risposta offline — un server irraggiungibile è un Error, e questo SDK non mantiene alcuna copia.
Passo 4 — Vendi un abbonamento
Recupera il paywall
Recupera il paywall dal server invece di codificare i prezzi nella tua app. Questo ti permette di lanciare una promozione o modificare la durata di una prova senza rilasciare una nuova versione:
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 contiene il titolo, le funzionalità, i prodotti da offrire (ognuno con prezzo di visualizzazione, periodo, prova e un badge opzionale), la call-to-action, il footer, i colori e il template — tutto ciò di cui lo schermo ha bisogno.
Disegnalo
L'SDK disegna il paywall. PaywallView è un composable: forniscigli la configurazione e digli cosa fare quando il cliente sceglie un prodotto.
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
}
Gestisce il layout, i colori che hai scelto nella console (e mantiene il testo leggibile anche quando un effetto di dissolvenza lo renderebbe poco visibile), il pulsante di chiusura se lo hai abilitato, e — su un televisore — l'anello che mostra quale controllo è selezionato sul telecomando. Avvii il flusso di acquisto del negozio per il prodotto che ti passa. Metterlo sullo schermo segue la struttura abituale di Compose:
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
}
}
Completa l'acquisto, poi verificalo
Esegui l'acquisto tramite Google Play Billing esattamente come faresti normalmente. Quando Google ti restituisce un purchaseToken, passalo a SubSovereign in modo che il server possa validare la ricevuta direttamente con il negozio e concedere il livello di accesso. Il livello proviene dalla mappa prodotto-livello nella tua dashboard, mai dall'app — un'app modificata non può auto-promuoversi — quindi accessLevelId viene accettato per compatibilità e ignorato:
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)
}
}
Questo è l'intero modello di fiducia: l'acquisto è reale solo quando il server lo ha confermato.
Su Amazon Fire TV
Le app Fire TV utilizzano l'In-App Purchasing di Amazon invece di Google Play. È lo stesso schema con input diversi: Amazon ti fornisce una ricevuta e un oggetto dati utente, e la ricevuta è valida solo insieme all'ID utente di Amazon — che non è il tuo userId. Il server mappa lo SKU al livello di accesso, quindi non c'è un accessLevelId da passare:
SubSovereign.validateAmazonPurchase(
receiptId = receipt.receiptId, // from Amazon's PurchaseResponse
amazonUserId = userData.userId, // from Amazon's UserData — NOT your own userId
productId = receipt.sku,
)
Passo 5 — La funzione di recesso UE (Compliance Passport)
Se vendi abbonamenti a consumatori dell'UE, la Direttiva (UE) 2023/2673 richiede una funzione di recesso chiaramente etichettata — il controllo di cancellazione. L'SDK la fornisce già pronta, ed è disegnata in due parti: recupera le impostazioni per la tua app, poi disegna il controllo con esse.
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
)
}
Non puoi modificare il testo. Ci sono due etichette per la funzione di recesso — "Recedi dal contratto qui" e "Disdici qui" — e labelKey contiene quella impostata per la tua app; le parole stesse provengono dal server nella lingua del cliente. Non c'è modo di passare un testo personalizzato: un parametro label accettava testo libero ed è ora ignorato con un avviso nei log. Un avviso statutario che può essere riformulato è uno che può essere ammorbidito in marketing, ed è per questo che non puoi nemmeno passare un colore.
🚩 Passa strings, altrimenti l'avviso statutario sarà in inglese per tutti. Le parole sono fornite dal tuo server SubSovereign nelle lingue che supporta; una lingua non disponibile viene restituita in inglese. La lingua che ottieni è decisa dal locale che hai configurato nel Passo 2 — la chiamata alle impostazioni lo invia — quindi customerLocale qui deve essere lo stesso valore; formatta la data, non sceglie le parole. Il controllo visualizza ciò che gli viene passato e non recupera le parole da solo, perché lo stesso contratto deve valere su ogni piattaforma, inclusa una in cui un componente UI non può effettuare chiamate di rete. Se il recupero fallisce, il controllo appare comunque, in inglese, piuttosto che non apparire: non mostrare una funzione di recesso facilmente individuabile al consumatore è una tua violazione; mostrarla nella lingua sbagliata non lo è. Registra l'errore; non mostrarlo mai al cliente.
🚩 Passa anche locale. Serve per formattare il timestamp sull'avviso di conferma. Senza di esso, l'SDK ricade sulla lingua configurata; senza entrambi, la data viene stampata in una forma che nessun lettore può fraintendere. Un cliente tedesco su un dispositivo con locale statunitense non deve vedere 9/3/2026, che interpreterebbe come 9 marzo.
Aspetto — LIGHT, DARK o AUTO (impostazione predefinita), e nient'altro. Il controllo disegna la propria scheda e il proprio testo, in modo che sia leggibile su qualsiasi schermo, chiaro o scuro. AUTO segue l'impostazione della modalità scura del dispositivo su smartphone o tablet ed è sempre scuro su un televisore. Non c'è modo di passare un colore.
Mostra il pulsante etichettato, chiede conferma una volta, invia in modo idempotente (un nuovo tentativo di rete non può mai creare due recessi), e mostra l'avviso di conferma con la data. La gestione di enabled è a tua discrezione, come sopra; il controllo stesso non effettua chiamate di rete oltre all'invio.
Passo 6 — Consenso e privacy
Tutto ciò di cui ha bisogno una richiesta di privacy è una chiamata ciascuna, e tutte passano attraverso il tuo server:
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
Il parametro purpose di recordConsent può essere uno tra analytics, marketing, personalisation o consumption_data_sharing — qualsiasi altro valore è un Error; jurisdiction ha come predefinito "GDPR" e policyVersion "1.0", passa i tuoi se differiscono. Ognuna restituisce un SubSovereignResult come ogni altra chiamata.
Gestione dei risultati
Ogni chiamata dell'SDK tranne hasAccess() restituisce un SubSovereignResult, che può essere Success (con i dati) o Error (con un messaggio e, dove rilevante, un codice di stato HTTP code). Questo è intenzionale: le chiamate di rete possono fallire, e la tua app dovrebbe decidere cosa fare invece di bloccarsi. Una semplice abitudine:
- Su
Success— usa i dati. - Su
Error— registralo, mantieni l'utente sul suo ultimo accesso noto e riprova più tardi. Non bloccare mai un utente pagante a causa di un'interruzione momentanea della rete. hasAccess()è l'eccezione: è un sempliceBooleane rispondefalsein caso di errore, quindi usalo per limitare l'accesso echeckEntitlements()per spiegare.
Best practice
- Verifica all'avvio a freddo. Chiama
checkEntitlements()quando l'app si avvia in modo che il controllo di accesso sia corretto prima che l'utente raggiunga una funzionalità bloccata. - Verifica nuovamente dopo l'acquisto. Subito dopo una chiamata
validate…riuscita, esegui di nuovocheckEntitlements()in modo che l'interfaccia rifletta immediatamente il nuovo accesso. - Non fidarti mai del client. Non memorizzare "è pro" nell'app e trattarlo come verità. Chiedi al server; il server ha verificato la ricevuta.
- Un
userIdper ogni utente reale. Mantienilo stabile in modo che l'accesso segua l'utente su più dispositivi e riconfigura quando l'utente che ha effettuato l'accesso cambia. - Lascia che l'SDK rilevi la superficie. Usa
detectPlatform(context)al momento della configurazione, mai un valore letterale in una build distribuita su più tipi di dispositivi. - Mantieni le coroutine ordinate. Queste sono funzioni
suspend— chiamale da unlifecycleScopeoviewModelScopein modo che si annullino con lo schermo.
Riferimento rapido
| Vuoi... | Chiama |
|---|---|
| Configurare l'SDK | SubSovereign.configure(config) con platform = detectPlatform(context) |
| Limitare l'accesso a una funzionalità, fail-closed | hasAccess(nomeLivelloAccesso?) → Boolean |
| Vedere cosa ha sbloccato l'utente | checkEntitlements() → EntitlementResult |
| Recuperare il paywall per questa superficie | getPaywallConfig() → PaywallConfig |
| Disegnare il paywall | PaywallView(config, onDismiss) { productId -> … } |
| Verificare un acquisto Google Play | validateGooglePurchase(purchaseToken, productId, accessLevelId) |
| Verificare un acquisto Amazon (Fire TV) | validateAmazonPurchase(receiptId, amazonUserId, productId) |
| Recuperare le impostazioni e le parole per il recesso | getWithdrawalConfig() → WithdrawalConfig |
| Disegnare la funzione di recesso UE | WithdrawalView(idAbbonamento, strings, labelKey, locale, appearance) |
| Registrare il consenso GDPR | recordConsent(purpose, granted) |
| CCPA do-not-sell · cancellazione · esportazione · flag · attribuzione | setDoNotSell · requestErasure · exportMyData · getFeatureFlags · recordAttributionTouch |
Prossimi passi
- Fai lo stesso sulle altre piattaforme — gli SDK per iOS, Web/JavaScript e Roku seguono la stessa struttura.
- Sei nuovo ai concetti? Leggi Come funziona SubSovereign.