diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e4b6c63c0..4d31b69ec 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,7 @@ androidx-xr-scenecore = "1.0.0-alpha10" androidxHiltNavigationCompose = "1.3.0" appcompat = "1.7.1" arcorePlayServices = "1.0.0-alpha09" +playbilling = "8.3.0" coil = "2.7.0" # @keep compileSdk = "36" @@ -98,6 +99,7 @@ wearToolingPreview = "1.0.0" webkit = "1.14.0" wearPhoneInteractions = "1.1.0" wearRemoteInteractions = "1.1.0" +runtime = "1.10.0" [libraries] accompanist-adaptive = "com.google.accompanist:accompanist-adaptive:0.37.3" @@ -202,6 +204,7 @@ androidx-xr-arcore = { module = "androidx.xr.arcore:arcore", version.ref = "andr androidx-xr-compose = { module = "androidx.xr.compose:compose", version.ref = "androidx-xr-compose" } androidx-xr-scenecore = { module = "androidx.xr.scenecore:scenecore", version.ref = "androidx-xr-scenecore" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +billing = { module = "com.android.billingclient:billing", version.ref = "playbilling" } coil-kt-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "wearComposeFoundation" } compose-ui-tooling = { module = "androidx.wear.compose:compose-ui-tooling", version.ref = "composeUiTooling" } @@ -241,6 +244,7 @@ wear-compose-material = { module = "androidx.wear.compose:compose-material", ver wear-compose-material3 = { module = "androidx.wear.compose:compose-material3", version.ref = "wearComposeMaterial3" } androidx-wear-phone-interactions = { group = "androidx.wear", name = "wear-phone-interactions", version.ref = "wearPhoneInteractions" } androidx-wear-remote-interactions = { group = "androidx.wear", name = "wear-remote-interactions", version.ref = "wearRemoteInteractions" } +androidx-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } diff --git a/playbilling/.gitignore b/playbilling/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/playbilling/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/playbilling/build.gradle b/playbilling/build.gradle new file mode 100644 index 000000000..27cba0bd7 --- /dev/null +++ b/playbilling/build.gradle @@ -0,0 +1,44 @@ +plugins { + alias(libs.plugins.android.application) +} + +android { + namespace 'com.example.pbl' + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId "com.example.pbl" + minSdk 24 + targetSdk 36 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} + +dependencies { + implementation libs.guava + implementation libs.billing + implementation libs.androidx.appcompat + implementation libs.google.android.material + implementation libs.androidx.runtime + testImplementation libs.junit + androidTestImplementation libs.androidx.test.ext.junit + androidTestImplementation libs.androidx.test.espresso.core +} \ No newline at end of file diff --git a/playbilling/proguard-rules.pro b/playbilling/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/playbilling/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/playbilling/src/androidTest/java/com/example/pbl/ExampleInstrumentedTest.java b/playbilling/src/androidTest/java/com/example/pbl/ExampleInstrumentedTest.java new file mode 100644 index 000000000..14f1893fc --- /dev/null +++ b/playbilling/src/androidTest/java/com/example/pbl/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.example.pbl; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("com.example.pbl", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/playbilling/src/main/AndroidManifest.xml b/playbilling/src/main/AndroidManifest.xml new file mode 100644 index 000000000..0d77fa244 --- /dev/null +++ b/playbilling/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/playbilling/src/main/java/com/example/pbl/BillingClientWrapper.java b/playbilling/src/main/java/com/example/pbl/BillingClientWrapper.java new file mode 100644 index 000000000..cb7a8d81b --- /dev/null +++ b/playbilling/src/main/java/com/example/pbl/BillingClientWrapper.java @@ -0,0 +1,273 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.pbl; + +import android.app.Activity; +import android.content.Context; + +import com.android.billingclient.api.AcknowledgePurchaseParams; +import com.android.billingclient.api.BillingClient; +import com.android.billingclient.api.BillingClient.BillingResponseCode; +import com.android.billingclient.api.BillingClient.ProductType; +import com.android.billingclient.api.BillingClientStateListener; +import com.android.billingclient.api.BillingFlowParams; +import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams; +import com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams; +import com.android.billingclient.api.BillingResult; +import com.android.billingclient.api.ConsumeParams; +import com.android.billingclient.api.ConsumeResponseListener; +import com.android.billingclient.api.GetBillingConfigParams; +import com.android.billingclient.api.InAppMessageParams; +import com.android.billingclient.api.InAppMessageResult; +import com.android.billingclient.api.PendingPurchasesParams; +import com.android.billingclient.api.ProductDetails; +import com.android.billingclient.api.Purchase; +import com.android.billingclient.api.PurchasesUpdatedListener; +import com.android.billingclient.api.QueryProductDetailsParams; +import com.google.common.collect.ImmutableList; + +import java.util.List; + +/** + * A wrapper for the Google Play Billing Library that handles all the billing logic. + */ +class BillingClientWrapper { + + private final Context context; + private final Activity activity; + private final BillingClient billingClient; + + private List productDetailsList; + private ProductDetails productDetails; + + public BillingClientWrapper(Context context, Activity activity) { + this.context = context; + this.activity = activity; + + // [START android_playbilling_initialize_java] + // 1. Initialize the BillingClient. + PurchasesUpdatedListener purchasesUpdatedListener = + (billingResult, purchases) -> { + // To be implemented in a later section. + if (billingResult.getResponseCode() == BillingResponseCode.OK && purchases != null) { + for (Purchase purchase : (List) purchases) { + // Process the purchase. + } + } else if (billingResult.getResponseCode() == BillingResponseCode.USER_CANCELED) { + // Handle an error caused by a user canceling the purchase flow. + } else { + // Handle any other error codes. + } + }; + + this.billingClient = + BillingClient.newBuilder(context) + .setListener(purchasesUpdatedListener) + // [START android_playbilling_enableautoreconnect_java] + .enablePendingPurchases(PendingPurchasesParams.newBuilder() + .enableOneTimeProducts() + .build()) + // [END android_playbilling_enableautoreconnect_java] + .build(); + // [END android_playbilling_initialize_java] + + startConnection(); + } + + public void startConnection() { + // [START android_playbilling_startconnection_java] + billingClient.startConnection( + new BillingClientStateListener() { + @Override + public void onBillingSetupFinished(BillingResult billingResult) { + if (billingResult.getResponseCode() == BillingResponseCode.OK) { + // The BillingClient is ready. You can query purchases here. + // It's a good practice to query products after the connection is established. + queryProductDetails(); + } + } + + @Override + public void onBillingServiceDisconnected() { + // Try to restart the connection on the next request to + // Google Play by calling the startConnection() method. + // This is automatically handled by the library when you call a method that requires a connection. + } + }); + // [END android_playbilling_startconnection_java] + } + + public void queryProductDetails() { + // [START android_playbilling_queryproductdetails_java] + QueryProductDetailsParams queryProductDetailsParams = + QueryProductDetailsParams.newBuilder() + .setProductList( + ImmutableList.of( + QueryProductDetailsParams.Product.newBuilder() + .setProductId("product_id_example") + .setProductType(ProductType.SUBS) + .build())) + .build(); + + billingClient.queryProductDetailsAsync( + queryProductDetailsParams, + (billingResult, fetchedProductDetailsList) -> { + if (billingResult.getResponseCode() == BillingResponseCode.OK && fetchedProductDetailsList != null) { + this.productDetailsList = fetchedProductDetailsList.getProductDetailsList(); + // Now that the list is populated, you can use it. + // For example, find a specific product. + if (!this.productDetailsList.isEmpty()) { + this.productDetails = this.productDetailsList.get(0); + // Any methods that require productDetails should be called from here. + } + } + }); + // [END android_playbilling_queryproductdetails_java] + } + + public void consumeProduct(Purchase purchase) { + // [START android_playbilling_consumeproduct_java] + ConsumeParams consumeParams = + ConsumeParams.newBuilder().setPurchaseToken(purchase.getPurchaseToken()).build(); + + ConsumeResponseListener listener = + (billingResult, purchaseToken) -> { + if (billingResult.getResponseCode() == BillingResponseCode.OK) { + // Handle the success of the consume operation. + } + }; + + billingClient.consumeAsync(consumeParams, listener); + // [END android_playbilling_consumeproduct_java] + } + + public void acknowledgePurchase(Purchase purchase) { + // [START android_playbilling_acknowledge_java] + if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) { + if (!purchase.isAcknowledged()) { + AcknowledgePurchaseParams acknowledgePurchaseParams = + AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchase.getPurchaseToken()) + .build(); + billingClient.acknowledgePurchase(acknowledgePurchaseParams, (billingResult) -> { + // Acknowledgment handled. + }); + } + } + // [END android_playbilling_acknowledge_java] + } + + public void getBillingConfigAsync() { + // [START android_playbilling_getbillingconfig_java] + GetBillingConfigParams getBillingConfigParams = GetBillingConfigParams.newBuilder().build(); + billingClient.getBillingConfigAsync( + getBillingConfigParams, + (billingResult, billingConfig) -> { + if (billingResult.getResponseCode() == BillingResponseCode.OK && billingConfig != null) { + String countryCode = billingConfig.getCountryCode(); + } else { + // TODO: Handle errors + } + }); + // [END android_playbilling_getbillingconfig_java] + } + + public void changeSubscriptionPlan() { + if (productDetails == null) { + // Can't launch the flow if product details aren't loaded yet. + // You could initiate a query here or show an error message. + return; + } + String purchaseTokenOfExistingSubscription = "purchase_token"; + // [START android_playbilling_subscription_replace_java] + ProductDetailsParams productDetailsParams = + ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .build(); + + BillingFlowParams billingFlowParams = + BillingFlowParams.newBuilder() + .setSubscriptionUpdateParams( + SubscriptionUpdateParams.newBuilder() + .setOldPurchaseToken(purchaseTokenOfExistingSubscription) + .build()) + .setProductDetailsParamsList(ImmutableList.of(productDetailsParams)) + .build(); + + billingClient.launchBillingFlow(activity, billingFlowParams); + // [END android_playbilling_subscription_replace_java] + } + + public void changeSubscriptionPlanDeprecated() { + if (productDetails == null || productDetails.getSubscriptionOfferDetails() == null || productDetails.getSubscriptionOfferDetails().isEmpty()) { + // Can't launch the flow if product details or offers aren't loaded yet. + return; + } + + int selectedOfferIndex = 0; + // [START android_playbilling_subscription_update_deprecated_java] + String offerToken = + productDetails.getSubscriptionOfferDetails().get(selectedOfferIndex).getOfferToken(); + + BillingFlowParams billingFlowParams = + BillingFlowParams.newBuilder() + .setProductDetailsParamsList( + ImmutableList.of( + ProductDetailsParams.newBuilder() + // fetched via queryProductDetailsAsync + .setProductDetails(productDetails) + // offerToken can be found in + // ProductDetails=>SubscriptionOfferDetails + .setOfferToken(offerToken) + .build())) + .setSubscriptionUpdateParams( + SubscriptionUpdateParams.newBuilder() + // purchaseToken can be found in Purchase#getPurchaseToken + .setOldPurchaseToken("old_purchase_token") + .setSubscriptionReplacementMode( + SubscriptionUpdateParams.ReplacementMode.CHARGE_FULL_PRICE) + .build()) + .build(); + + BillingResult billingResult = billingClient.launchBillingFlow(activity, billingFlowParams); + // ... + // [END android_playbilling_subscription_update_deprecated_java] + } + + public void inAppMessaging() { + // [START android_playbilling_inappmessaging_java] + InAppMessageParams inAppMessageParams = + InAppMessageParams.newBuilder() + .addInAppMessageCategoryToShow( + InAppMessageParams.InAppMessageCategoryId.TRANSACTIONAL) + .build(); + billingClient.showInAppMessages( + activity, + inAppMessageParams, + (inAppMessageResult) -> { + if (inAppMessageResult.getResponseCode() + == InAppMessageResult.InAppMessageResponseCode.NO_ACTION_NEEDED) { + // an in-app message was already displayed within the last day + } + }); + // [END android_playbilling_inappmessaging_java] + } + + // Unused methods from the original file have been removed for clarity + // (e.g., onPurchasesUpdated, handlePurchaseRecap) as their logic is + // now integrated into the PurchasesUpdatedListener in the constructor. +} diff --git a/playbilling/src/main/res/drawable/ic_launcher_background.xml b/playbilling/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..da4cfc866 --- /dev/null +++ b/playbilling/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/playbilling/src/main/res/drawable/ic_launcher_foreground.xml b/playbilling/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..9e14c8458 --- /dev/null +++ b/playbilling/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + diff --git a/playbilling/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/playbilling/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..d72381561 --- /dev/null +++ b/playbilling/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/playbilling/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/playbilling/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..d72381561 --- /dev/null +++ b/playbilling/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/playbilling/src/main/res/mipmap-hdpi/ic_launcher.webp b/playbilling/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 000000000..c209e78ec Binary files /dev/null and b/playbilling/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/playbilling/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/playbilling/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 000000000..b2dfe3d1b Binary files /dev/null and b/playbilling/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/playbilling/src/main/res/mipmap-mdpi/ic_launcher.webp b/playbilling/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 000000000..4f0f1d64e Binary files /dev/null and b/playbilling/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/playbilling/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/playbilling/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 000000000..62b611da0 Binary files /dev/null and b/playbilling/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/playbilling/src/main/res/mipmap-xhdpi/ic_launcher.webp b/playbilling/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 000000000..948a3070f Binary files /dev/null and b/playbilling/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/playbilling/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/playbilling/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..1b9a6956b Binary files /dev/null and b/playbilling/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/playbilling/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/playbilling/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 000000000..28d4b77f9 Binary files /dev/null and b/playbilling/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/playbilling/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/playbilling/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9287f5083 Binary files /dev/null and b/playbilling/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/playbilling/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/playbilling/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 000000000..aa7d6427e Binary files /dev/null and b/playbilling/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/playbilling/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/playbilling/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9126ae37c Binary files /dev/null and b/playbilling/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/playbilling/src/main/res/values-night/themes.xml b/playbilling/src/main/res/values-night/themes.xml new file mode 100644 index 000000000..ef64b4724 --- /dev/null +++ b/playbilling/src/main/res/values-night/themes.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/playbilling/src/main/res/values/colors.xml b/playbilling/src/main/res/values/colors.xml new file mode 100644 index 000000000..068b7a9e6 --- /dev/null +++ b/playbilling/src/main/res/values/colors.xml @@ -0,0 +1,25 @@ + + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + diff --git a/playbilling/src/main/res/values/strings.xml b/playbilling/src/main/res/values/strings.xml new file mode 100644 index 000000000..004019a48 --- /dev/null +++ b/playbilling/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ + + + + playbillinglibrary + diff --git a/playbilling/src/main/res/values/themes.xml b/playbilling/src/main/res/values/themes.xml new file mode 100644 index 000000000..e3311ad4b --- /dev/null +++ b/playbilling/src/main/res/values/themes.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/playbilling/src/test/java/com/example/pbl/ExampleUnitTest.java b/playbilling/src/test/java/com/example/pbl/ExampleUnitTest.java new file mode 100644 index 000000000..b267ed242 --- /dev/null +++ b/playbilling/src/test/java/com/example/pbl/ExampleUnitTest.java @@ -0,0 +1,17 @@ +package com.example.pbl; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + @Test + public void addition_isCorrect() { + assertEquals(4, 2 + 2); + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 7474e22fd..5df954c5b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,3 +45,4 @@ include( ":kmp:androidApp", ":kmp:shared" ) +include(":playbilling")