SubSovereign
All guides

SubSovereign no Android — guia de integração

Este guia leva a sua app Android (telemóvel, tablet, Android TV ou Amazon Fire TV) de "não faço ideia de quem me pagou" a "a minha app desbloqueia as funcionalidades certas para o utilizador certo, verificado no meu próprio servidor". Está escrito para ser seguido do início ao fim — não é assumida qualquer experiência prévia com ferramentas de subscrições. Todos os exemplos de código Kotlin são compilados contra o SDK em cada execução de teste, por isso o que lê aqui é o que o código faz.

O que o SubSovereign faz por si

Os seus utilizadores subscrevem através da loja Google Play (ou Amazon na Fire TV). O SubSovereign responde a uma pergunta para a sua app, de forma fiável: o que é que este utilizador pagou realmente?

  • A sua app pergunta ao SubSovereign qual é o direito de acesso do utilizador — o que desbloquearam.
  • A validação do recibo acontece no servidor, diretamente com a loja, para que uma app modificada não possa falsificar uma subscrição.
  • O paywall — o ecrã que apresenta os seus planos — é configurado remotamente e desenhado pelo SDK, permitindo-lhe alterar preços, períodos de teste e texto sem lançar uma nova versão da app.
  • A função de desistência da UE — o controlo de cancelamento legal — também é desenhada pelo SDK, no idioma do cliente, para que um aviso legal não se transforme em marketing.
  • É auto-hospedado: o SubSovereign corre na sua infraestrutura, os dados dos seus utilizadores ficam consigo, e não há partilha de receitas — fica com 100% do que os seus utilizadores pagam.

Nunca confie no telemóvel. O telemóvel pergunta; o servidor decide.

Antes de começar

Vai precisar de:

  1. Um servidor SubSovereign em execução (a sua implementação auto-hospedada). Vai apontar o SDK para o seu URL. Se ainda não o tiver, implemente-o primeiro — o resto deste guia assume que está ativo.
  2. Uma app registada no dashboard. No dashboard do SubSovereign, crie uma app. Vai obter duas coisas:
    • um appId (identifica a sua app), e
    • uma chave API (a credencial que a sua app usa para comunicar com o servidor). Registe-a para a superfície em que é distribuída — telemóvel, Android TV ou Fire TV. Um único código que é distribuído para mais do que uma superfície é aceitável; veja Telemóveis e televisões no Passo 2.
  3. Níveis de acesso criados. No dashboard, defina os níveis de acesso (também chamados tiers) que a sua app concede — por exemplo pro ou premium — e associe cada um aos IDs dos produtos da loja que os seus utilizadores compram.
  4. Faturação da loja já funcional. O SubSovereign valida e acompanha compras; não substitui o Google Play Billing (ou Amazon IAP). Configure-os na sua app como habitualmente — o SubSovereign atua logo após a compra para a validar e registar.

Do lado do código, precisa de Kotlin com corrotinas (as chamadas do SDK são funções suspend), Jetpack Compose (o paywall e o controlo de cancelamento são composables), e minSdk 24 ou superior.

Passo 1 — Adicionar o SDK

O SDK é distribuído como código-fonte por agora: não há nada para descarregar do Maven Central. Copie a pasta sdk-android/src/main/java do repositório do SubSovereign para o seu projeto e compile-a como parte da sua app — exatamente como a app de exemplo no mesmo repositório faz. Abaixo está o que o SDK precisa, para além do que a sua app já tem: os dois plugins que o seu código usa, e as suas próprias dependências fixadas nas versões com que é construído e testado (a última linha é o que a amostra usa para colocar um composable no ecrã). No AGP 8, aplique também org.jetbrains.kotlin.android; no AGP 9, o Kotlin está integrado. Declare as versões dos plugins no seu ficheiro de compilação raiz, como a amostra faz.

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
}

Certifique-se de que a sua app tem a permissão de internet no AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET" />

Como o SDK é compilado como o seu próprio código-fonte, não há versão para atualizar: para o atualizar, substitua a pasta. Quando existir um artefacto publicado, este passo será uma única linha de dependência e este guia dirá isso.

Passo 2 — Configurar uma vez, quando a app inicia

Configure o SDK uma única vez — o melhor local é o Application.onCreate(), ou logo após o utilizador iniciar sessão. Forneça-lhe a sua chave API, o ID da sua app, um identificador estável para este utilizador, o idioma, e em que superfície a app está a correr.

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"
    )
)

Algumas coisas que vale a pena saber:

  • O userId é seu. Use qualquer ID estável que já tenha para um utilizador com sessão iniciada. Use o mesmo valor sempre para que o acesso do utilizador o acompanhe em diferentes dispositivos. Se suportar logout e um utilizador diferente iniciar sessão, basta chamar configure novamente com o novo userId.
  • O baseUrl aponta para o seu servidor. O padrão no SDK é um marcador de posição — defina-o para a sua própria implementação do SubSovereign.
  • O locale é o idioma do cliente, não o da sua consola, e é decidido AQUI: o servidor devolve o paywall e o texto do aviso legal no idioma que configurar, e o SDK formata as datas para esse idioma. Obtenha-o do perfil do cliente ou do dispositivo, não de um valor literal.

Telemóveis e televisões

Um único código do Play pode ser distribuído para telemóveis, tablets e Android TV; uma compilação para Fire TV é distribuída através da Amazon. O platform informa o SDK em que superfície está — "android", "androidtv" ou "firetv" — e isso decide três coisas: qual paywall o servidor envia (um paywall para televisão é desenhado para uma sala, não para a mão), a superfície sob a qual um cancelamento é registado, e onde os seus clientes são registados como provenientes. detectPlatform(context) responde a isto em tempo de execução: uma Fire TV pelo recurso próprio do dispositivo da Amazon, uma Android TV pela sua configuração ou Leanback, tudo o resto como telemóvel. Não codifique "android" numa compilação que também corre em televisões — isso iria buscar o paywall do telemóvel para a TV.

Na Fire TV, registe a app como Fire TV no dashboard, ou publique um paywall para Fire TV. Uma compilação para Fire TV pede ao servidor o paywall da Fire TV, e um servidor que não o tenha responde com um Error em vez do paywall do telemóvel (o servidor faz fallback do Android TV para o paywall do telemóvel, e da Fire TV para nada). O SDK nunca adivinha uma superfície: um valor de platform que não reconhece é um Error que o nomeia, e nada é enviado.

Passo 3 — Verificar o que o utilizador pode aceder

A resposta rápida de sim/não é hasAccess(). Falha em modo fechado (fail-closed): numa falha de rede, erro do servidor, ou SDK não configurado, responde false, para que uma falha momentânea nunca desbloqueie uma funcionalidade paga. Use-a no ponto de bloqueio de uma funcionalidade:

lifecycleScope.launch {
    if (SubSovereign.hasAccess("pro")) unlockProFeatures() else showPaywall()
}

hasAccess("pro") só é verdadeiro quando um direito de acesso ativo tem esse nome de nível de acesso; hasAccess() sem nome é o veredicto geral do servidor.

Quando precisa de detalhes — o que está ativo, quando renova, ou para distinguir um "não" de um "não foi possível verificar" — chame checkEntitlements(). Devolve um resultado sobre o qual decide, e a decisão certa num erro é geralmente manter o cliente onde estava:

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 a sua app tiver mais do que um tier, veja dentro de result.data.entitlements — cada um informa exatamente qual nível de acesso está ativo. Faça correspondência pelo nome que deu ao nível no dashboard; accessLevelId é o ID interno do servidor, não esse nome:

val isPro = result.data.entitlements.any { it.isActive && it.accessLevelName.equals("pro", ignoreCase = true) }

Cada direito de acesso também inclui expiresAt, willRenew, e a store de onde veio — útil para mensagens como "a sua subscrição renova em…". fromCache significa que o servidor respondeu a partir da sua própria cache de curta duração em vez da base de dados; nunca é uma resposta offline — um servidor inacessível é um Error, e este SDK não guarda cópia.

Passo 4 — Vender uma subscrição

Obter o paywall

Obtenha o paywall do servidor em vez de codificar os preços na sua app. Isto permite-lhe fazer uma promoção ou alterar a duração de um teste sem lançar uma nova versão:

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 inclui o título, as funcionalidades, os produtos a oferecer (cada um com preço de exibição, período, teste e um badge opcional), o call-to-action, o rodapé, as cores e o template — tudo o que o ecrã precisa.

Desenhá-lo

O SDK desenha o paywall. PaywallView é um composable: forneça-lhe a configuração e diga-lhe o que fazer quando o cliente escolher um produto.

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
}

Trata do layout, das cores que escolheu na consola (e mantém o texto legível mesmo quando um gradiente o tornaria pouco visível), do botão de fechar se o permitiu, e — numa televisão — do anel que mostra qual o controlo selecionado no comando. Inicia o fluxo de compra da loja para o produto que lhe é passado. Colocá-lo no ecrã segue a estrutura habitual do 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
    }
}

Concluir a compra e validá-la

Execute a compra através do Google Play Billing exatamente como faria normalmente. Quando o Google lhe devolver um purchaseToken, passe-o ao SubSovereign para que o servidor possa validar o recibo diretamente com a loja e conceder o nível de acesso. O tier vem do mapeamento produto-nível no seu dashboard, nunca da app — uma app modificada não se pode escalar — por isso accessLevelId é aceite por compatibilidade e ignorado:

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)
    }
}

Este é todo o modelo de confiança: a compra só é real depois de o servidor a confirmar.

Na Amazon Fire TV

As apps para Fire TV usam o In-App Purchasing da Amazon em vez do Google Play. O padrão é o mesmo, com entradas diferentes: a Amazon devolve-lhe um recibo e um objeto de dados do utilizador, e o recibo só é válido juntamente com o ID de utilizador da própria Amazon — que não é o seu userId. O servidor mapeia o SKU para o nível de acesso, por isso não há accessLevelId para passar:

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 — A função de desistência da UE (Compliance Passport)

Se vender subscrições a consumidores da UE, a Diretiva (UE) 2023/2673 exige um controlo de desistência claramente identificado. O SDK inclui-o pronto a usar, e é desenhado em duas partes: obtenha as definições para a sua app e, em seguida, desenhe o controlo com elas.

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
    )
}

Não pode alterar o texto. Existem duas etiquetas para a função de desistência — "Desista do contrato aqui" e "Cancelar aqui" — e labelKey transporta aquela para a qual a sua app está configurada; as próprias palavras vêm do servidor no idioma do cliente. Não há forma de passar o seu próprio texto: um parâmetro label costumava aceitar texto livre e agora é ignorado com um aviso registado. Um aviso legal que pode ser reescrito é um que pode ser suavizado para marketing, que é a mesma razão pela qual não pode passar uma cor.

🚩 Passe strings, ou o aviso legal aparece em inglês para todos. As palavras são servidas pelo seu servidor SubSovereign nos idiomas que este disponibiliza; um idioma que não tenha é devolvido em inglês. O idioma que recebe é decidido pelo locale que configurou no Passo 2 — a chamada das definições envia-o — por isso customerLocale aqui deve ser o mesmo valor; formata a data, não escolhe as palavras. O controlo renderiza o que lhe é passado e não obtém as palavras por si, porque o mesmo contrato tem de ser válido em todas as plataformas, incluindo uma onde um componente de UI não pode fazer rede. Se a obtenção falhar, o controlo ainda aparece, em inglês, em vez de não aparecer: não colocar um controlo de desistência acessível diante do consumidor é uma violação sua; mostrá-lo no idioma errado não é. Registe o erro; nunca o mostre ao cliente.

🚩 Passe também locale. Formata o carimbo de data/hora no reconhecimento. Sem ele, o SDK recorre ao locale que configurou; sem nenhum dos dois, a data é impressa de forma que nenhum leitor possa interpretar mal. Um cliente alemão num dispositivo com locale dos EUA não deve ver 9/3/2026, que interpretaria como 9 de março.

Aparência — LIGHT, DARK ou AUTO (o padrão), e nada mais. O controlo desenha o seu próprio cartão e o seu próprio texto, para que seja legível em qualquer ecrã, claro ou escuro. AUTO segue a definição de modo escuro do dispositivo num telemóvel ou tablet e é sempre escuro numa televisão. Não há forma deliberada de passar uma cor.

Mostra o botão etiquetado, pede confirmação uma vez, submete de forma idempotente (uma repetição de rede nunca cria duas desistências) e mostra o reconhecimento com a data. O bloqueio com base em enabled é da sua responsabilidade, como acima; o controlo em si não faz mais rede além do envio.

Passo 6 — Consentimento e privacidade

Tudo o que um pedido de privacidade precisa é de uma chamada cada, e todas passam pelo seu próprio servidor:

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

O purpose de recordConsent é um de analytics, marketing, personalisation ou consumption_data_sharing — qualquer outro é um Error; jurisdiction tem como padrão "GDPR" e policyVersion "1.0", passe os seus se forem diferentes. Cada uma devolve um SubSovereignResult como qualquer outra chamada.

Trabalhar com resultados

Todas as chamadas do SDK, exceto hasAccess(), devolvem um SubSovereignResult, que é ou Success (com os dados) ou Error (com uma mensagem e, quando relevante, um código de estado HTTP code). Isto é deliberado: as chamadas de rede falham, e a sua app deve decidir o que fazer em vez de falhar. Um hábito simples:

  • Em Success — use os dados.
  • Em Error — registe-o, mantenha o utilizador no último acesso conhecido e tente novamente mais tarde. Nunca bloqueie um utilizador pagante por causa de uma falha momentânea de rede.
  • hasAccess() é a exceção: é um simples Boolean e responde false em qualquer erro, por isso use-o para bloquear e checkEntitlements() para explicar.

Boas práticas

  • Verifique no arranque a frio. Chame checkEntitlements() quando a app iniciar para que o bloqueio esteja correto antes de o utilizador chegar a uma funcionalidade bloqueada.
  • Verifique novamente após comprar. Logo após uma chamada validate… bem-sucedida, execute checkEntitlements() novamente para que a UI reflita o novo acesso imediatamente.
  • Nunca confie no cliente. Não armazene "é pro" na app e trate-o como verdade. Pergunte ao servidor; o servidor validou o recibo.
  • Um userId por utilizador real. Mantenha-o estável para que o acesso acompanhe o utilizador em diferentes dispositivos, e reconfigure quando o utilizador com sessão iniciada mudar.
  • Deixe o SDK detetar a superfície. detectPlatform(context) na configuração, nunca um valor literal numa compilação que é distribuída para mais do que um tipo de dispositivo.
  • Mantenha as corrotinas organizadas. Estas são funções suspend — chame-as a partir de um lifecycleScope ou viewModelScope para que sejam canceladas com o ecrã.

Referência rápida

Pretende… Chame
Configurar o SDK SubSovereign.configure(config) com platform = detectPlatform(context)
Bloquear uma funcionalidade, falha em modo fechado (fail-closed) hasAccess(nomeNívelAcesso?)Boolean
Ver o que o utilizador desbloqueou checkEntitlements()EntitlementResult
Obter o paywall para esta superfície getPaywallConfig()PaywallConfig
Desenhar o paywall PaywallView(config, onDismiss) { productId -> … }
Validar uma compra do Google Play validateGooglePurchase(purchaseToken, productId, accessLevelId)
Validar uma compra da Amazon (Fire TV) validateAmazonPurchase(receiptId, amazonUserId, productId)
Obter as definições e palavras da desistência getWithdrawalConfig()WithdrawalConfig
Desenhar a função de desistência da UE WithdrawalView(subscriptionId, strings, labelKey, locale, appearance)
Registar consentimento GDPR recordConsent(purpose, granted)
CCPA não vender · eliminação · exportação · sinalizadores · atribuição setDoNotSell · requestErasure · exportMyData · getFeatureFlags · recordAttributionTouch

Próximos passos