Skip to content

IAP Prototype. #10906

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dependencies.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ ext.libs = [
material : "com.google.android.material:material:${versions.material}",
moshiKotlinCodegen : "com.squareup.moshi:moshi-kotlin-codegen:${versions.moshi}",
nimbusJwt : "com.nimbusds:nimbus-jose-jwt:${versions.nimbusJwt}",
okhttp : "com.squareup.okhttp3:okhttp:${versions.okhttp}",
okio : "com.squareup.okio:okio:${versions.okio}",
payButtonCompose : "com.google.pay.button:compose-pay-button:${versions.payButtonCompose}",
places : "com.google.android.libraries.places:places:${versions.places}",
Expand Down
37 changes: 37 additions & 0 deletions iap-example/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
apply from: configs.androidApplication

apply plugin: 'org.jetbrains.kotlin.plugin.serialization'
apply plugin: 'org.jetbrains.kotlin.plugin.compose'

android {
defaultConfig {
applicationId "com.stripe.android.iap.example"
versionCode 20

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

buildFeatures {
compose true
}
}

dependencies {
implementation project(':iap')

implementation libs.accompanist.materialThemeAdapter
implementation libs.androidx.appCompat
implementation libs.androidx.coreKtx
implementation libs.androidx.lifecycleCompose
implementation libs.compose.activity
implementation libs.compose.material
implementation libs.compose.materialIcons
implementation libs.compose.ui
implementation libs.compose.viewModels
implementation libs.kotlin.serialization
implementation libs.material

implementation libs.okhttp
implementation libs.retrofit
implementation libs.retrofitKotlinSerializationConverter
}
1 change: 1 addition & 0 deletions iap-example/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
STRIPE_ANDROID_NAMESPACE=com.stripe.android.iapexample
Empty file.
23 changes: 23 additions & 0 deletions iap-example/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

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

<application
android:allowBackup="true"
android:fullBackupOnly="true"
android:label="IAP Example"
android:theme="@style/Theme.Material3.DayNight"
android:usesCleartextTraffic="true"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.stripe.android.iapexample

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.POST

internal interface BackendService {
@POST("create_checkout_session_url")
suspend fun createCheckoutSessionUrl(
@Body body: CreateRequest
): CheckoutSessionCreateResponse

@POST("create_iap_customer_session")
suspend fun createIapCustomerSession(
@Body body: CreateRequest
): CustomerSessionCreateResponse
}

@Serializable
internal data class CheckoutSessionCreateResponse(
@SerialName("url")
val url: String,
)

@Serializable
internal data class CustomerSessionCreateResponse(
@SerialName("customerSessionClientSecret") val customerSessionClientSecret: String,
)

@Serializable
internal data class CreateRequest(
@SerialName("price_id") val priceId: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.stripe.android.iapexample

import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.material.Button
import androidx.compose.material.Text
import com.stripe.android.iap.InAppPurchase
import com.stripe.android.iap.InAppPurchasePluginLookupType

internal class MainActivity : AppCompatActivity(), InAppPurchase.ResultCallback {

companion object {
private const val EXAMPLE_PRICE_ID = "price_1OMWl7Lu5o3P18ZpdSyfgjmd"
}

private val viewModel by viewModels<MainActivityViewModel>()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val inAppPurchase = InAppPurchase(
publishableKey = "pk_test_123",
activity = this,
resultCallback = this,
plugins = viewModel.plugins,
)

setContent {
Column {
Button(
onClick = { inAppPurchase.purchase(EXAMPLE_PRICE_ID) }
) {
Text("Default")
}

Button(
onClick = {
inAppPurchase.purchase(EXAMPLE_PRICE_ID, lookupType = InAppPurchasePluginLookupType.Checkout())
}
) {
Text("Checkout")
}

Button(
onClick = {
inAppPurchase.purchase(EXAMPLE_PRICE_ID, lookupType = InAppPurchasePluginLookupType.GooglePlay())
}
) {
Text("Google Play")
}
}
}
}

override fun onResult(result: InAppPurchase.Result) {
when (result) {
is InAppPurchase.Result.Canceled -> {
Toast.makeText(this, "Canceled", Toast.LENGTH_LONG).show()
}
is InAppPurchase.Result.Completed -> {
// Just call your server, given the authenticated customer, and look up subscription
Toast.makeText(this, "Completed", Toast.LENGTH_LONG).show()
}
is InAppPurchase.Result.Failed -> {
Toast.makeText(this, "Failed", Toast.LENGTH_LONG).show()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.stripe.android.iapexample

import androidx.lifecycle.ViewModel
import com.stripe.android.iap.InAppPurchasePlugin
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory

internal class MainActivityViewModel : ViewModel() {
private val backendService: BackendService = Retrofit.Builder()
// .baseUrl("https://stp-mobile-playground-backend-v7.stripedemos.com/")
.baseUrl("http://10.0.2.2:8081/")
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
.create(BackendService::class.java)

val plugins = listOf(
InAppPurchasePlugin.stripeCheckout { priceId ->
backendService.createCheckoutSessionUrl(CreateRequest(priceId)).url
},
InAppPurchasePlugin.googlePlay { priceId ->
backendService.createIapCustomerSession(CreateRequest(priceId)).customerSessionClientSecret
},
)
}
44 changes: 44 additions & 0 deletions iap/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
apply from: configs.androidLibrary

apply plugin: 'org.jetbrains.kotlin.plugin.parcelize'
apply plugin: 'org.jetbrains.kotlin.plugin.serialization'
apply plugin: 'com.google.devtools.ksp'
apply plugin: 'dev.drewhamilton.poko'

dependencies {
implementation libs.androidx.browser
api libs.kotlin.coroutines
implementation libs.kotlin.serialization
api libs.androidx.activity
implementation libs.androidx.lifecycle
implementation libs.androidx.appCompat

implementation "com.android.billingclient:billing-ktx:7.1.1"

ksp libs.daggerCompiler

testImplementation testLibs.junit
testImplementation testLibs.robolectric
testImplementation testLibs.truth
}

android {
testOptions {
unitTests {
includeAndroidResources = true
all {
maxHeapSize = "1024m"
}
}
// Make sure animations are off when we run espresso tests
animationsDisabled = true
}
}

ext {
artifactId = "iap"
artifactName = "iap"
artifactDescrption = "Stripe In App Purchase Android SDK"
}

//apply from: "${rootDir}/deploy/deploy.gradle"
Empty file added iap/consumer-rules.txt
Empty file.
1 change: 1 addition & 0 deletions iap/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
STRIPE_ANDROID_NAMESPACE=com.stripe.android.iap
13 changes: 13 additions & 0 deletions iap/res/values/themes.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme used by Activities that do not show any UI, so the activity underneath them is still fully visible -->
<style name="StripeIapTransparentTheme" parent="@style/Theme.AppCompat.NoActionBar">
<item name="android:windowSoftInputMode">adjustResize</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:backgroundDimEnabled">false</item>
</style>
</resources>
31 changes: 31 additions & 0 deletions iap/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>
<activity
android:name=".CheckoutForegroundActivity"
android:autoRemoveFromRecents="true"
android:configChanges="orientation|keyboard|keyboardHidden|screenLayout|screenSize|smallestScreenSize"
android:launchMode="singleTop"
android:theme="@style/StripeIapTransparentTheme" />

<activity
android:name=".CheckoutRedirectHandlerActivity"
android:theme="@style/StripeIapTransparentTheme"
android:autoRemoveFromRecents="true"
android:launchMode="singleInstance"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data
android:scheme="stripesdk"
android:host="iap_success_url"
android:path="/${applicationId}" />
</intent-filter>
</activity>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.stripe.android.iap

import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.net.toUri

internal class CheckoutForegroundActivity : AppCompatActivity() {
companion object {
const val ACTION_REDIRECT = "CheckoutForegroundActivity.redirect"
const val EXTRA_CHECKOUT_URL = "CheckoutUrl"
const val EXTRA_FAILURE = "CheckoutFailure"
const val RESULT_COMPLETE = 49871
const val RESULT_FAILURE = 91367

private const val SAVED_STATE_HAS_LAUNCHED_URL = "CheckoutForegroundActivity_LaunchedUrl"

fun createIntent(context: Context, checkoutUrl: String): Intent {
return Intent(context, CheckoutForegroundActivity::class.java)
.putExtra(EXTRA_CHECKOUT_URL, checkoutUrl)
}

fun redirectIntent(context: Context, uri: Uri?): Intent {
val intent = Intent(context, CheckoutForegroundActivity::class.java)
intent.action = ACTION_REDIRECT
intent.data = uri
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
return intent
}
}

private var hasLaunchedUrl: Boolean = false

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

hasLaunchedUrl = savedInstanceState?.getBoolean(SAVED_STATE_HAS_LAUNCHED_URL) ?: false

handleRedirectIfAvailable(intent)
}

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(SAVED_STATE_HAS_LAUNCHED_URL, hasLaunchedUrl)
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleRedirectIfAvailable(intent)
}

override fun onResume() {
super.onResume()

if (!isFinishing) {
if (hasLaunchedUrl) {
setResult(RESULT_CANCELED)
finish()
} else {
launchCheckoutUrl()
}
}
}

private fun handleRedirectIfAvailable(intent: Intent) {
if (intent.action == ACTION_REDIRECT) {
setResult(RESULT_COMPLETE, intent)
finish()
}
}

private fun launchCheckoutUrl() {
hasLaunchedUrl = true

val checkoutUri = intent.extras?.getString(EXTRA_CHECKOUT_URL)?.toUri()
if (checkoutUri == null) {
setResult(RESULT_CANCELED)
finish()
return
}
try {
CustomTabsIntent.Builder()
.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
.build()
.launchUrl(this, checkoutUri)
} catch (e: ActivityNotFoundException) {
setResult(RESULT_FAILURE, Intent().putExtra(EXTRA_FAILURE, e))
finish()
}
}
}
Loading
Loading