SubSovereign
All guides

SubSovereign na Androidzie — przewodnik integracji

Ten przewodnik przeprowadzi Twoją aplikację na Androida (telefon, tablet, Android TV lub Amazon Fire TV) od „Nie wiem, kto mi zapłacił” do „Moja aplikacja odblokowuje odpowiednie funkcje dla odpowiedniego użytkownika, zweryfikowane na moim własnym serwerze”. Został napisany tak, aby można było go przejść od początku do końca — nie wymaga wcześniejszego doświadczenia z narzędziami do subskrypcji. Każdy przykład kodu w Kotlinie jest kompilowany względem SDK przy każdym uruchomieniu testów, więc to, co tu czytasz, jest tym, co robi kod.

Co SubSovereign robi dla Ciebie

Twoi użytkownicy subskrybują przez sklep Google Play (lub Amazon na Fire TV). SubSovereign odpowiada na jedno pytanie dla Twojej aplikacji, niezawodnie: za co tak naprawdę zapłacił ten użytkownik?

  • Twoja aplikacja pyta SubSovereign o uprawnienie użytkownika — dostęp, który odblokował.
  • Weryfikacja paragonu odbywa się po stronie serwera, bezpośrednio ze sklepem, więc zmodyfikowana aplikacja nie może sfałszować subskrypcji.
  • Paywall — ekran, który oferuje Twoje plany — jest konfigurowany zdalnie i rysowany przez SDK, więc możesz zmieniać ceny, okresy próbne i treść bez publikowania nowej wersji aplikacji.
  • Funkcja odstąpienia UE — wymagany prawnie przycisk anulowania — jest również rysowany przez SDK, w języku klienta, więc powiadomienie prawne nie może zmienić się w materiał marketingowy.
  • Jest samohostowany: SubSovereign działa na Twojej infrastrukturze, dane Twoich użytkowników pozostają u Ciebie, a nie ma podziału przychodów — zatrzymujesz 100% tego, co płacą Twoi użytkownicy.

Nigdy nie ufaj telefonowi. Telefon pyta; serwer decyduje.

Zanim zaczniesz

Będziesz potrzebować:

  1. Działającego serwera SubSovereign (Twoje samohostowane wdrożenie). Wskażesz SDK jego adres URL. Jeśli jeszcze go nie masz, wdróż go najpierw — reszta tego przewodnika zakłada, że jest aktywny.
  2. Aplikacji zarejestrowanej w panelu. W panelu SubSovereign utwórz aplikację. Otrzymasz dwie rzeczy:
    • appId (identyfikuje Twoją aplikację), oraz
    • klucz API (poświadczenie, którego Twoja aplikacja używa do komunikacji z serwerem). Zarejestruj ją dla platformy, na której będzie działać — telefon, Android TV lub Fire TV. Jeden kod źródłowy działający na więcej niż jednej platformie jest w porządku; zobacz Telefony i telewizory w kroku 2.
  3. Utworzonych poziomów dostępu. W panelu zdefiniuj poziomy dostępu (nazywane również warstwami), które Twoja aplikacja przyznaje — na przykład pro lub premium — i powiąż każdy z identyfikatorami produktów w sklepie, które kupują Twoi użytkownicy.
  4. Działającego systemu płatności w sklepie. SubSovereign weryfikuje i śledzi zakupy; nie zastępuje Google Play Billing (ani Amazon IAP). Skonfiguruj je w swojej aplikacji normalnie — SubSovereign działa tuż po zakupie, aby go zweryfikować i zapisać.

Po stronie kodu potrzebujesz Kotlin z korutynami (wywołania SDK to funkcje suspend), Jetpack Compose (paywall i kontrolka anulowania to kompozycyjne elementy interfejsu), oraz minSdk 24 lub wyższy.

Krok 1 — Dodaj SDK

SDK jest dostarczany jako kod źródłowy: nie ma jeszcze nic do pobrania z Maven Central. Weź folder sdk-android/src/main/java z repozytorium SubSovereign do swojego projektu i skompiluj go jako część swojej aplikacji — dokładnie tak, jak robi to przykładowa aplikacja w tym samym repozytorium. Poniżej znajduje się to, czego potrzebuje samo SDK, do dodania do tego, co już ma Twoja aplikacja: dwa pluginy, których używa jego kod, oraz własne zależności przypięte do wersji, z którymi jest budowane i testowane (ostatnia linia to to, czego używa przykład, aby umieścić kompozycję na ekranie). W AGP 8 zastosuj również org.jetbrains.kotlin.android; w AGP 9 Kotlin jest wbudowany. Zadeklaruj wersje pluginów w głównym pliku build, tak jak robi to przykład.

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
}

Upewnij się, że Twoja aplikacja ma uprawnienie do internetu w AndroidManifest.xml:

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

Ponieważ SDK jest kompilowane jako Twój własny kod źródłowy, nie ma wersji do aktualizacji: aby je zaktualizować, zastąp folder. Gdy pojawi się opublikowany artefakt, ten krok stanie się jedną linią zależności, a ten przewodnik to uwzględni.

Krok 2 — Konfiguracja jednorazowa, przy starcie aplikacji

Skonfiguruj SDK jednokrotnie — najlepszym miejscem jest Application.onCreate() lub tuż po zalogowaniu użytkownika. Podajesz mu swój klucz API, swój identyfikator aplikacji, stabilny identyfikator dla tego użytkownika, jego język oraz na jakiej platformie działa aplikacja.

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

Kilka rzeczy, które warto wiedzieć:

  • userId należy do Ciebie. Użyj dowolnego stabilnego identyfikatora, który już masz dla zalogowanego użytkownika. Używaj tej samej wartości za każdym razem, aby dostęp użytkownika podążał za nim na różnych urządzeniach. Jeśli obsługujesz wylogowywanie i inny użytkownik się zaloguje, po prostu wywołaj configure ponownie z nowym userId.
  • baseUrl wskazuje na Twój serwer. Domyślna wartość w SDK to placeholder — ustaw ją na swoje własne wdrożenie SubSovereign.
  • locale to język klienta, a nie Twojego panelu, i jest ustalany TU: serwer zwraca paywall i treść powiadomienia prawnego w języku, który skonfigurujesz, a SDK formatuje daty dla niego. Weź go z profilu klienta lub urządzenia, a nie jako literał.

Telefony i telewizory

Jeden kod źródłowy dla Google Play może działać na telefonach, tabletach i Android TV; wersja Fire TV jest dystrybuowana przez Amazon. platform informuje SDK, na jakiej platformie działa — "android", "androidtv" lub "firetv" — i decyduje o trzech rzeczach: który paywall serwer wyśle (paywall telewizyjny jest zaprojektowany dla pokoju, a nie dla ręki), na jakiej platformie zostanie złożone anulowanie oraz skąd pochodzą Twoi klienci. detectPlatform(context) określa to w czasie działania: Fire TV po funkcji urządzenia Amazon, Android TV po jego konfiguracji lub Leanback, wszystko inne jako telefon. Nie koduj na sztywno "android" w wersji, która działa również na telewizorach — to spowoduje pobranie paywalla telefonu na telewizorze.

Na Fire TV zarejestruj aplikację jako Fire TV w panelu lub opublikuj dla niej paywall Fire TV. Wersja Fire TV pyta serwer o paywall Fire TV, a serwer, który go nie ma, odpowiada Error zamiast paywalla telefonu (serwer przechodzi z Android TV na paywall telefonu, a z Fire TV na nic). SDK nigdy nie zgaduje platformy: wartość platform, której nie rozpoznaje, jest Error, który ją nazywa, i nic nie jest wysyłane.

Krok 3 — Sprawdź, do czego użytkownik ma dostęp

Szybkie tak/nie to hasAccess(). Działa w trybie fail-closed (zamknięcie awaryjne): w przypadku chwilowego problemu z siecią, błędu serwera lub nie skonfigurowanego SDK zwraca false, więc chwilowa awaria nigdy nie odblokuje płatnej funkcji. Użyj go w miejscu blokowania funkcji:

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

hasAccess("pro") jest prawdziwe tylko wtedy, gdy aktywne uprawnienie zawiera nazwę tego poziomu dostępu; hasAccess() bez nazwy to ogólna decyzja serwera.

Gdy potrzebujesz szczegółów — co dokładnie jest aktywne, kiedy się odnawia, lub aby odróżnić „nie” od „nie można sprawdzić” — wywołaj checkEntitlements(). Zwraca wynik, na podstawie którego podejmujesz decyzję, a właściwą decyzją w przypadku błędu jest zwykle pozostawienie klienta tam, gdzie był:

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

Jeśli Twoja aplikacja ma więcej niż jeden poziom, sprawdź w result.data.entitlements — każde z nich dokładnie informuje, który poziom dostępu jest aktywny. Dopasuj do nazwy, którą nadałeś poziomowi w panelu; accessLevelId to wewnętrzny identyfikator serwera, a nie ta nazwa:

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

Każde uprawnienie zawiera również expiresAt, willRenew oraz store, z którego pochodzi — przydatne do komunikatów typu „Twoja subskrypcja odnawia się dnia…”. fromCache oznacza, że serwer odpowiedział z własnej krótkotrwałej pamięci podręcznej, a nie z bazy danych; to nigdy nie jest odpowiedź offline — niedostępny serwer to Error, a to SDK nie przechowuje kopii.

Krok 4 — Sprzedaj subskrypcję

Pobierz paywall

Pobierz paywall z serwera, zamiast kodować ceny na sztywno w aplikacji. To pozwala na przeprowadzenie promocji lub zmianę długości okresu próbnego bez wydawania nowej wersji:

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 zawiera nagłówek, funkcje, produkty do zaoferowania (każdy z ceną wyświetlaną, okresem, okresem próbnym i opcjonalną odznaką), wezwanie do działania, stopkę, kolory i szablon — wszystko, czego potrzebuje ekran.

Narysuj go

SDK rysuje paywall. PaywallView to kompozycja: podaj mu konfigurację i powiedz, co zrobić, gdy klient wybierze produkt.

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
}

Zajmuje się układem, kolorami, które wybrałeś w konsoli (i zapewnia czytelność tekstu nawet wtedy, gdy gradient mógłby go przyciemnić), przyciskiem zamknięcia, jeśli go dozwoliłeś, oraz — na telewizorze — pierścieniem wskazującym, który element jest zaznaczony na pilocie. Rozpoczynasz przepływ zakupu w sklepie dla produktu, który Ci przekazał. Umieszczenie go na ekranie ma standardowy kształt 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
    }
}

Zakończ zakup, a następnie go zweryfikuj

Przeprowadź zakup przez Google Play Billing dokładnie tak, jak zwykle. Gdy Google zwróci Ci purchaseToken, przekaż go do SubSovereign, aby serwer mógł zweryfikować paragon bezpośrednio ze sklepem i przyznać poziom dostępu. Poziom pochodzi z mapowania produktu na poziom w Twoim panelu, nigdy z aplikacji — zmodyfikowana aplikacja nie może się podnieść — więc accessLevelId jest akceptowany dla kompatybilności i ignorowany:

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

To cały model zaufania: zakup jest realny dopiero wtedy, gdy serwer go potwierdzi.

Na Amazon Fire TV

Aplikacje Fire TV używają Amazon In-App Purchasing zamiast Google Play. To ten sam schemat z innymi danymi wejściowymi: Amazon daje Ci paragon i obiekt danych użytkownika, a paragon jest ważny tylko razem z identyfikatorem użytkownika Amazon — który nie jest Twoim userId. Serwer mapuje SKU na poziom dostępu, więc nie ma accessLevelId do przekazania:

SubSovereign.validateAmazonPurchase(
    receiptId    = receipt.receiptId,   // from Amazon's PurchaseResponse
    amazonUserId = userData.userId,     // from Amazon's UserData — NOT your own userId
    productId    = receipt.sku,
)

Krok 5 — Funkcja odstąpienia UE (Compliance Passport)

Jeśli sprzedajesz subskrypcje konsumentom z UE, Dyrektywa (UE) 2023/2673 wymaga wyraźnie oznaczonej funkcji odstąpienia — przycisku anulowania. SDK dostarcza ją gotową i jest rysowana w dwóch częściach: pobierz ustawienia dla swojej aplikacji, a następnie narysuj kontrolkę z nimi.

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

Nie możesz zmieniać treści. Istnieją dwa etykiety dla funkcji odstąpienia — „Odstąp od umowy tutaj” i „Anuluj tutaj” — a labelKey zawiera tę, która jest ustawiona dla Twojej aplikacji; same słowa pochodzą z serwera w języku klienta. Nie ma możliwości przekazania własnego tekstu: parametr label kiedyś akceptował dowolny tekst, a teraz jest ignorowany z logowanym ostrzeżeniem. Powiadomienie prawne, które można przeformułować, może zostać złagodzone do treści marketingowej, dlatego nie można również przekazać koloru.

🚩 Przekaż strings, inaczej powiadomienie prawne będzie po angielsku dla wszystkich. Słowa są serwowane przez Twój serwer SubSovereign w językach, które obsługuje; język, którego nie ma, jest zwracany po angielsku. Język, jaki otrzymasz, jest decydowany przez locale, które skonfigurowałeś w kroku 2 — wywołanie ustawień go przesyła — więc customerLocale tutaj musi być tą samą wartością; formatuje datę, nie wybiera słów. Kontrolka renderuje to, co otrzyma, i nie pobiera słów sama, ponieważ ta sama umowa musi obowiązywać na każdej platformie, w tym na takiej, na której komponent UI nie może w ogóle wykonywać połączeń sieciowych. Jeśli pobranie nie powiedzie się, kontrolka nadal się pojawi, po angielsku, zamiast się nie pojawić: brak umieszczenia łatwo dostępnej funkcji odstąpienia przed konsumentem jest naruszeniem; pokazanie jej w złym języku — nie. Zaloguj błąd; nigdy nie pokazuj go klientowi.

🚩 Przekaż również locale. Formatuje znacznik czasu w potwierdzeniu. Bez niego SDK używa języka, który skonfigurowałeś; bez obu daty są wyświetlane w formie, której żaden czytelnik nie może źle odczytać. Niemiecki klient na urządzeniu z ustawieniami amerykańskimi nie powinien widzieć 9/3/2026, co odczyta jako 9 marca.

Wygląd — LIGHT, DARK lub AUTO (domyślnie), i nic więcej. Kontrolka rysuje własną kartę i własny tekst, więc jest czytelna na każdym ekranie, jasnym lub ciemnym. AUTO podąża za ustawieniami trybu ciemnego urządzenia na telefonie lub tablecie i jest zawsze ciemna na telewizorze. Celowo nie ma możliwości przekazania koloru.

Pokazuje oznaczony przycisk, potwierdza raz, wysyła idempotentnie (ponowna próba sieciowa nigdy nie utworzy dwóch odstąpień) i wyświetla potwierdzenie z datą. Blokowanie na podstawie enabled należy do Ciebie, jak wyżej; sama kontrolka nie wykonuje żadnych połączeń sieciowych poza wysłaniem.

Krok 6 — Zgoda i prywatność

Wszystko, czego potrzebuje wniosek o prywatność, to jedno wywołanie dla każdej czynności, a wszystkie przechodzą przez Twój własny serwer:

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

purpose w recordConsent to jedno z: analytics, marketing, personalisation lub consumption_data_sharing — wszystko inne to Error; jurisdiction domyślnie to "GDPR", a policyVersion to "1.0", przekaż własne, jeśli się różnią. Każde zwraca SubSovereignResult jak każde inne wywołanie.

Praca z wynikami

Każde wywołanie SDK oprócz hasAccess() zwraca SubSovereignResult, który jest albo Success (z danymi), albo Error (z komunikatem i, jeśli istotny, kodem statusu HTTP code). To celowe: połączenia sieciowe zawodzą, a Twoja aplikacja powinna zdecydować, co zrobić, zamiast się zawiesić. Prosty nawyk:

  • Przy Success — użyj danych.
  • Przy Error — zaloguj go, zachowaj ostatnio znany dostęp użytkownika i spróbuj ponownie później. Nigdy nie blokuj płacącego użytkownika z powodu chwilowej awarii sieci.
  • hasAccess() to wyjątek: zwraca zwykłe Boolean i odpowiada false na każdy błąd, więc używaj go do blokowania i checkEntitlements() do wyjaśniania.

Dobre praktyki

  • Sprawdzaj przy zimnym starcie. Wywołaj checkEntitlements() przy uruchamianiu aplikacji, aby blokowanie było poprawne, zanim użytkownik dotrze do zablokowanej funkcji.
  • Ponownie sprawdzaj po zakupie. Zaraz po udanym wywołaniu validate…, uruchom checkEntitlements() ponownie, aby interfejs odzwierciedlał nowy dostęp natychmiast.
  • Nigdy nie ufaj klientowi. Nie przechowuj „czy jest pro” w aplikacji i nie traktuj tego jako prawdy. Pytaj serwer; serwer zweryfikował paragon.
  • Jeden userId na rzeczywistego użytkownika. Utrzymuj go stabilnym, aby dostęp podążał za użytkownikiem na różnych urządzeniach, i ponownie konfiguruj, gdy zmieni się zalogowany użytkownik.
  • Pozwól SDK wykryć platformę. detectPlatform(context) przy konfiguracji, nigdy literał w wersji, która działa na więcej niż jednym typie urządzenia.
  • Dbaj o porządek w korutynach. To funkcje suspend — wywołuj je z lifecycleScope lub viewModelScope, aby anulowały się wraz z ekranem.

Krótki przewodnik

Chcesz… Wywołaj
Skonfigurować SDK SubSovereign.configure(config) z platform = detectPlatform(context)
Zablokować funkcję, fail-closed (zamknięcie awaryjne) hasAccess(accessLevelName?)Boolean
Zobaczyć, co użytkownik odblokował checkEntitlements()EntitlementResult
Pobrać paywall dla tej platformy getPaywallConfig()PaywallConfig
Narysować paywall PaywallView(config, onDismiss) { productId -> … }
Zweryfikować zakup w Google Play validateGooglePurchase(purchaseToken, productId, accessLevelId)
Zweryfikować zakup w Amazon (Fire TV) validateAmazonPurchase(receiptId, amazonUserId, productId)
Pobrać ustawienia i treść odstąpienia getWithdrawalConfig()WithdrawalConfig
Narysować funkcję odstąpienia UE WithdrawalView(subscriptionId, strings, labelKey, locale, appearance)
Zarejestrować zgodę RODO recordConsent(purpose, granted)
CCPA nie-sprzedawaj · usunięcie · eksport · flagi · atrybucja setDoNotSell · requestErasure · exportMyData · getFeatureFlags · recordAttributionTouch

Następne kroki