This how-to guide demonstrates my development journey of Huawei in-app subscription IAP integration that is based on the Huawei In-App Purchases kit.
This guide should be especially useful for those who perform the migration of their existing application from Play Market to Huawei AppGallery and is in need to integrate HMS IAP Services.
My Raloco Notes application relies on a subscription model to charge users on yearly basis for additional fonts and colors. As the subscription state can change any time or even be canceled I had to implement additional code scenarios to handle these logics.
It also turned out that Google and Huawei handle subscriptions differently.
Prerequisites
With all my desire to have one project for both marketplaces, I had to launch a separate development branch for Huawei, mainly due to numerous adjustments in project configurations, Gradle files, etc.
Here is my workflow and it can be separated into the following steps:
- Create new copy of Android Studio project.
- Configure project and Gradle scripts to work with Huawei SDK.
- Configure subscription and other dependencies under AppGallery Connect portal.
- Make first test compilation.
- Integrate new coding logics.
- Connect test user to check subscription in action.
1. Create new copy of Android Studio project
This step is very straightforward so I am skipping the description and moving directly to the next project configuration.
2. Configure project and Gradle scripts to work with Huawei SDK
As Huawei development environment is subject to frequent changes it is recommended to follow their original documentation. Start from Getting Started guide if your application is not yet published to Huawei AppGallery.
Alternatively, if your application is already successfully published under AppGallery start with the Integrating HMS Core SDK guide.
Below I am attaching my Gradle script files Play Market (Before version) and AppGallery (After version) to reflect changes in a more transparent way.
Play Market – gradle project before
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
jcenter()
maven {
url 'https://maven.google.com/'
name 'Google'
}
google()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.1.2'
}
}
allprojects {
repositories {
jcenter()
maven {
url 'https://maven.google.com/'
name 'Google'
}
}
}
AppGallery – gradle project after
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
jcenter()
maven {url 'https://developer.huawei.com/repo/'}
}
dependencies {
classpath 'com.android.tools.build:gradle:4.1.2'
classpath 'com.huawei.agconnect:agcp:1.5.0.300'
}
}
allprojects {
repositories {
google()
jcenter()
maven {url 'https://developer.huawei.com/repo/'}
}
}
Play Market – gradle module before
apply plugin: 'com.android.application'
android {
signingConfigs {
signRelease {
storeFile file('/Users/pavellukasenko/Documents/Apps/kenzap.keystore')
storePassword 'xxxxx'
keyAlias 'com.kenzap.notes'
keyPassword 'xxxxx'
}
}
compileSdkVersion 29
buildToolsVersion "29.0.0"
defaultConfig {
applicationId "com.kenzap.notes"
minSdkVersion 26
targetSdkVersion 29
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
signingConfig signingConfigs.signRelease
}
release_debug {
debuggable true
jniDebuggable true
signingConfig signingConfigs.signRelease
renderscriptDebuggable false
minifyEnabled false
zipAlignEnabled true
}
}
}
repositories {
mavenCentral()
}
dependencies {
implementation "com.android.billingclient:billing:3.0.3"
implementation 'androidx.multidex:multidex:2.0.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.browser:browser:1.3.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.0.0'
implementation 'androidx.cardview:cardview:1.0.0'
}
AppGallery – gradle module after
apply plugin: 'com.android.application'
android {
signingConfigs {
signRelease {
storeFile file('/Users/pavellukasenko/Documents/Apps/kenzap.keystore')
storePassword 'xxxxx'
keyAlias 'com.kenzap.notes'
keyPassword 'xxxxx'
}
}
compileSdkVersion 29
buildToolsVersion "29.0.0"
defaultConfig {
applicationId "com.kenzap.notes"
minSdkVersion 26
targetSdkVersion 29
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.signRelease
}
release_debug {
debuggable true
jniDebuggable true
signingConfig signingConfigs.signRelease
renderscriptDebuggable false
minifyEnabled false
zipAlignEnabled true
}
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'com.huawei.hms:hwid:4.0.1.300'
implementation 'com.huawei.hms:iap:4.0.0.300'
implementation 'androidx.multidex:multidex:2.0.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.browser:browser:1.3.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.0.0'
implementation 'androidx.cardview:cardview:1.0.0'
}
apply plugin: 'com.huawei.agconnect'
3. Configure subscription and other dependencies under AppGallery Connect portal
You need to register your subscription with the help of Huawei Developer console. Follow adding a product guide to achieve this step.
4. Make first test compilation
Before code integration, it is important to check if the project compiles. You will need to remove all old billing-related code dependencies as Google Billing SDK is no longer present.
Quite many things may go wrong at this stage so I am not providing any additional feedback here.
5. Integrate new coding logics
All my subscription and billing related activities are stored under single StoreActivity class. In a similar way as before I am providing two code samples Play Market before version and AppGallery after version.
Play Market StoreActivity class – before version
package com.kenzap.store;
/**
* Created by Pavels Lukasenko on 05/12/21.
*/
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.view.View;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import com.android.billingclient.api.AcknowledgePurchaseParams;
import com.android.billingclient.api.AcknowledgePurchaseResponseListener;
import com.android.billingclient.api.BillingClient;
import com.android.billingclient.api.BillingClientStateListener;
import com.android.billingclient.api.BillingFlowParams;
import com.android.billingclient.api.BillingResult;
import com.android.billingclient.api.Purchase;
import com.android.billingclient.api.PurchasesUpdatedListener;
import com.android.billingclient.api.SkuDetails;
import com.android.billingclient.api.SkuDetailsParams;
import com.android.billingclient.api.SkuDetailsResponseListener;
import com.kenzap.notes.C;
import com.kenzap.notes.HttpUtils;
import com.kenzap.notes.R;
import java.util.ArrayList;
import java.util.List;
public class StoreActivity extends Activity {
private static final String TAG = "StoreActivity";
Context c;
SharedPreferences pref;
IntentFilter filter = new IntentFilter();
TextView centerTextList3;
Button sub_btn;
AlertDialog dialog = null;
private boolean justPurchased = false;
// billing variables
private static BillingClient billingClient;
private PurchasesUpdatedListener purchasesUpdatedListener;
// status variables
private boolean billingLoaded = false;
private SkuDetails sku;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_store);
getActionBar().hide();
c = this;
pref = PreferenceManager.getDefaultSharedPreferences(this);
billingLoaded = false; // live billing status billing variable
initStore();
// REGISTER ASINHRONOUS BROADCAST RECEIVER - USED FOR HTTP REQUESTS
filter.addAction("Async");
// button binder
sub_btn = (Button) findViewById(R.id.sub_btn);
centerTextList3 = (TextView) findViewById(R.id.centerTextList3);
// close listener
ImageView c_btn = (ImageView) findViewById(R.id.closeStore);
c_btn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
finish();
}
});
}
public static void verifySubscription(final Context context, final SharedPreferences pref) {
if (!HttpUtils.isNetAvailable(context))
return;
C.log("verifySubscription");
PurchasesUpdatedListener purchasesUpdatedListener = new PurchasesUpdatedListener() {
@Override
public void onPurchasesUpdated(BillingResult billingResult, List<Purchase> purchases) {
}
};
billingClient = BillingClient.newBuilder(context)
.setListener(purchasesUpdatedListener)
.enablePendingPurchases()
.build();
billingClient.startConnection(new BillingClientStateListener() {
@Override
public void onBillingSetupFinished(BillingResult billingResult) {
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
// The BillingClient is ready - query purchases.
C.log("Get active subscriptions");
// {"orderId":"GPA.3342-0466-6668-34182","packageName":"com.kenzap.notes","productId":"notes_subscription","purchaseTime":1615557934948,"purchaseState":0,"purchaseToken":"lphlcipkjaheaallkkglbcdo.AO-J1Ow6-pC_pTLm2qzFvaeOoiYDtvla46_HVvzoj_f6bhvElncpuwZA6oGut9mFpLl7JokCdbMMDg4B0AZJffrsykPoRhj2mw","autoRenewing":true,"acknowledged":false}
Purchase.PurchasesResult pr = billingClient.queryPurchases(BillingClient.SkuType.SUBS);
List<Purchase> pList = pr.getPurchasesList();
// something totally wrong
if(pList==null) return;
// no active subscriptions
if(pList.size() == 0){
C.log("No active subscriptions");
StoreActivity.subscription(false, pref);
}
for (Purchase purchase : pList) {
// purchased but not acknowledged do now instead
if(purchase.getSku().equals(C.products[0])) {
// make sure subscription is operational
if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) {
if (!purchase.isAcknowledged()) {
AcknowledgePurchaseParams acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.getPurchaseToken())
.build();
billingClient.acknowledgePurchase(acknowledgePurchaseParams, new AcknowledgePurchaseResponseListener() {
@Override
public void onAcknowledgePurchaseResponse(BillingResult billingResult) {
StoreActivity.subscription(true, pref);
}
});
}
// cancel subscription
}else{
StoreActivity.subscription(false, pref);
}
}
// TODO store subscription data on the backend for marketing purposes
C.log(purchase.getOriginalJson());
}
}
}
@Override
public void onBillingServiceDisconnected() {
// Try to restart the connection on the next request to
// Google Play by calling the startConnection() method.
}
});
billingClient.queryPurchases(BillingClient.SkuType.SUBS);
}
// trigger subscription status
public static void subscription(Boolean status, SharedPreferences pref){
// enable subscription
if(status){
C.log("Enable subscription");
SharedPreferences.Editor editor = pref.edit();
editor.putBoolean("upgrade1", true);
editor.putLong("upgrade1_time", (System.currentTimeMillis() / 1000));
editor.commit();
// disable subscription
}else{
C.log("Cancel subscription");
SharedPreferences.Editor editor = pref.edit();
editor.putBoolean("upgrade1", false);
editor.putLong("upgrade1_time", (System.currentTimeMillis() / 1000));
editor.commit();
}
}
public void initStore(){
purchasesUpdatedListener = new PurchasesUpdatedListener() {
@Override
public void onPurchasesUpdated(BillingResult billingResult, List<Purchase> purchases) {
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK && purchases != null) {
for (Purchase purchase : purchases) {
// acknowledge purchase. Google requirement
handlePurchase(purchase);
}
} else if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.USER_CANCELED) {
} else {
String msg = "";
switch (billingResult.getResponseCode()){
case BillingClient.BillingResponseCode.BILLING_UNAVAILABLE: msg = "Billing API version is not supported for the type requested"; break;
case BillingClient.BillingResponseCode.DEVELOPER_ERROR: msg = "Invalid arguments provided to the API. This error can also indicate that the application was not correctly signed or properly set up for In-app Billing in Google Play, or does not have the necessary permissions in its manifest."; break;
case BillingClient.BillingResponseCode.ERROR: msg = "Fatal error during the API action."; break;
case BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED: msg = "Requested feature is not supported by Play Store on the current device."; break;
case BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED: msg = "Failure to purchase since item is already owned."; finishUpgrade(); break;
case BillingClient.BillingResponseCode.ITEM_NOT_OWNED: msg = "Failure to consume since item is not owned."; break;
case BillingClient.BillingResponseCode.ITEM_UNAVAILABLE: msg = "Requested product is not available for purchase."; break;
case BillingClient.BillingResponseCode.SERVICE_DISCONNECTED: msg = "Requested product is not available for purchase."; billingLoaded = false; break;
case BillingClient.BillingResponseCode.SERVICE_TIMEOUT: msg = getResources().getString(R.string.i1); break;
case BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE: msg = getResources().getString(R.string.i1); break;
}
// notify user
if(!msg.isEmpty()) Toast.makeText(c, msg, Toast.LENGTH_SHORT).show();
}
}
};
billingClient = BillingClient.newBuilder(getApplicationContext())
.setListener(purchasesUpdatedListener)
.enablePendingPurchases()
.build();
billingClient.startConnection(new BillingClientStateListener() {
@Override
public void onBillingSetupFinished(BillingResult billingResult) {
billingLoaded = true;
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
// The BillingClient is ready. Lets get available purchases
getProducts();
}
}
@Override
public void onBillingServiceDisconnected() {
billingLoaded = false;
}
});
}
// consume the purchase otherwise Google will refund it.
void handlePurchase(Purchase purchase) {
C.log("handlePurchase: "+purchase.getOriginalJson());
if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) {
if (!purchase.isAcknowledged()) {
AcknowledgePurchaseParams acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.getPurchaseToken())
.build();
billingClient.acknowledgePurchase(acknowledgePurchaseParams, new AcknowledgePurchaseResponseListener() {
@Override
public void onAcknowledgePurchaseResponse(BillingResult billingResult) {
finishUpgrade();
}
});
}
}
}
public void getProducts() {
C.log("StoreActivity getProducts");
List<String> skuList = new ArrayList<>();
skuList.add(C.products[0]);
SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder();
params.setSkusList(skuList).setType(BillingClient.SkuType.SUBS);
billingClient.querySkuDetailsAsync(params.build(),
new SkuDetailsResponseListener() {
@Override
public void onSkuDetailsResponse(BillingResult billingResult, List<SkuDetails> skuDetailsList) {
C.log("StoreActivity " + billingResult.getResponseCode());
if (skuDetailsList == null) {
Toast.makeText(c, getResources().getString(R.string.loading), Toast.LENGTH_SHORT).show();
return;
}
if (skuDetailsList.size() == 0) {
Toast.makeText(c, getResources().getString(R.string.loading), Toast.LENGTH_SHORT).show();
return;
}
sku = skuDetailsList.get(0);
centerTextList3.setText(sku.getPrice() + "/" + getResources().getString(R.string.store8));
}
});
}
@Override
public void onResume(){
super.onResume();
// if by nay chanche user opens store while already subscribed close it immediately
if(pref.getBoolean("upgrade1",false)) finishUpgrade();
// prevent store from opening when internet is down
if(!HttpUtils.isNetAvailable(c)) {
Toast.makeText(c, getResources().getString(R.string.i1), Toast.LENGTH_LONG).show();
finish();
}
}
@Override
protected void onPause() {
super.onPause();
}
@Override
public void onDestroy() {
super.onDestroy();
}
private void subscribe(){
// current time
long now = System.currentTimeMillis()/1000;
// prevent double clicks 5s safe interval
if(now - pref.getLong("last_subscribe_time", 0) < 5) { return; }
pref.edit().putLong("last_subscribe_time", now).apply();
// already subscribed
if(pref.getBoolean("upgrade1", false)){ Toast.makeText(c, getResources().getString(R.string.upgrade_success), Toast.LENGTH_LONG).show(); return; }
// check internet connection
if(!HttpUtils.isNetAvailable(c)) { Toast.makeText(c, getResources().getString(R.string.i1), Toast.LENGTH_LONG).show(); return; }
// billing not loaded
if(!billingLoaded || billingClient==null){ Toast.makeText(c, getResources().getString(R.string.i2), Toast.LENGTH_LONG).show(); return; }
C.log("subscribing"+sku.getDescription());
BillingFlowParams billingFlowParams = BillingFlowParams.newBuilder()
.setSkuDetails(sku)
.build();
int responseCode = billingClient.launchBillingFlow(this, billingFlowParams).getResponseCode();
C.log(""+responseCode);
}
// button frontend listener
public void upgrade1(View v){
// do subscription
subscribe();
}
// notify of successful upgrade, cache subscription as enabled and close store activity
private void finishUpgrade(){
Toast.makeText(c, getResources().getString(R.string.upgrade_success), Toast.LENGTH_LONG).show();
StoreActivity.subscription(true, pref);
finish();
}
}
AppGallery StoreActivity class – after version
package com.kenzap.store;
/**
* Created by Pavels Lukasenko on 05/12/21.
*/
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.IntentSender;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.text.TextUtils;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import com.huawei.hmf.tasks.OnFailureListener;
import com.huawei.hmf.tasks.OnSuccessListener;
import com.huawei.hmf.tasks.Task;
import com.huawei.hms.iap.Iap;
import com.huawei.hms.iap.IapApiException;
import com.huawei.hms.iap.IapClient;
import com.huawei.hms.iap.entity.InAppPurchaseData;
import com.huawei.hms.iap.entity.IsEnvReadyResult;
import com.huawei.hms.iap.entity.OrderStatusCode;
import com.huawei.hms.iap.entity.OwnedPurchasesReq;
import com.huawei.hms.iap.entity.OwnedPurchasesResult;
import com.huawei.hms.iap.entity.ProductInfo;
import com.huawei.hms.iap.entity.ProductInfoReq;
import com.huawei.hms.iap.entity.ProductInfoResult;
import com.huawei.hms.iap.entity.PurchaseIntentReq;
import com.huawei.hms.iap.entity.PurchaseIntentResult;
import com.huawei.hms.iap.entity.PurchaseResultInfo;
import com.huawei.hms.support.api.client.Status;
import com.kenzap.notes.C;
import com.kenzap.notes.HttpUtils;
import com.kenzap.notes.R;
import org.json.JSONException;
import java.util.ArrayList;
import java.util.List;
public class StoreActivity extends Activity {
private static final String TAG = "StoreActivity";
Context c;
SharedPreferences pref;
IntentFilter filter = new IntentFilter();
TextView centerTextList3;
Button sub_btn;
private boolean billingLoaded = false;
// obtain this under AppGallery Connect > Project Settings > Earn > In-app Purchases > Configuration
private static final String publicKey = "MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAl4cOrmx6iCIGfALLH74NK8f+crISGcZ5qsOz05qfC1iQuxnWVrdrigOeLtN11TXZDbp9H+kBx94J1kmB5Np3WXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=";
public static final Integer REQ_CODE_BUY_CONSUMABLE = 4002;
public static final Integer REQ_CODE_BUY_NON_CONSUMABLE = 4003;
public static final Integer REQ_CODE_BUY_SUPSCRIPTION = 4004;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_store);
getActionBar().hide();
c = this;
pref = PreferenceManager.getDefaultSharedPreferences(this);
billingLoaded = false; // live billing status billing variable
initStore();
// REGISTER ASINHRONOUS BROADCAST RECEIVER - USED FOR HTTP REQUESTS
filter.addAction("Async");
// button binder
sub_btn = (Button) findViewById(R.id.sub_btn);
centerTextList3 = (TextView) findViewById(R.id.centerTextList3);
// close listener
ImageView c_btn = (ImageView) findViewById(R.id.closeStore);
c_btn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
finish();
}
});
}
/*
This method is used from apps main activity to verify current subscription state without entering this store activity
User may choose to cancel or pause subscription any time thus additional verification is needed.
Quering Huawei of all active subscriptions the raw response of each separate subscription may look as following:
{"autoRenewing":false,"subIsvalid":true,"orderId":"1615581759140.301DBC76.5715","lastOrderId":"L1615581759140.301DBC76.5715","packageName":"com.kenzap.notes","applicationId":102479425,"productId":"notes_subscription","kind":2,"productName":"Fonts & Colors","productGroup":"C8749D5AEE6640B794598FAF1CB765B0","purchaseTime":1615581934071,"oriPurchaseTime":1615581934071,"purchaseState":1,"purchaseToken":"00000178282d46a4abaa006cfb6578abb3552f0d0c353e323d6be1ee947d7b22156218eda4ea3c66x5347.5.5715","purchaseType":0,"currency":"SGD","price":315,"country":"SG","subscriptionId":"1615581759140.3F419BAC.5715","quantity":1,"daysLasted":0,"numOfPeriods":1,"numOfDiscount":0,"expirationDate":1615585534071,"retryFlag":1,"introductoryFlag":0,"trialFlag":0,"renewStatus":0,"renewPrice":315,"cancelledSubKeepDays":30,"payOrderId":"SandBox_1615581759140.301DBC76.5715","payType":"0","confirmed"
If response is successful but nothing is returned assuming that subscription does not exists any long and we cancel additional functionality
*/
public static void verifySubscription(final Context context, final SharedPreferences pref) {
if (!HttpUtils.isNetAvailable(context))
return;
C.log("verifySubscription");
// Constructs a OwnedPurchasesReq object.
OwnedPurchasesReq req = new OwnedPurchasesReq();
// 0: consumable; 1: non-consumable; 2: auto-renewable subscription
req.setPriceType(IapClient.PriceType.IN_APP_SUBSCRIPTION);
// to call the obtainOwnedPurchaseRecord API.
// To get the Activity instance that calls this API.
// Activity activity = context;
Task<OwnedPurchasesResult> task = Iap.getIapClient(context).obtainOwnedPurchaseRecord(req);
task.addOnSuccessListener(new OnSuccessListener<OwnedPurchasesResult>() {
@Override
public void onSuccess(OwnedPurchasesResult result) {
// no active subscriptions cancel current if not yet
if(result.getInAppPurchaseDataList().size()==0) StoreActivity.subscription(false, pref);
for (int i = 0; i < result.getInAppPurchaseDataList().size(); i++) {
String inAppPurchaseData = result.getInAppPurchaseDataList().get(i);
String InAppSignature = result.getInAppSignature().get(i);
C.log("inAppPurchaseData"+inAppPurchaseData);
// use the payment public key to verify the signature of the inAppPurchaseData.
// if success.
try {
InAppPurchaseData inAppPurchaseDataBean = new InAppPurchaseData(inAppPurchaseData);
int purchaseState = inAppPurchaseDataBean.getPurchaseState();
// find subscription by its ID
C.log(inAppPurchaseDataBean.getProductId());
if(inAppPurchaseDataBean.getProductId().equals(C.products[0])) {
// subscription is valid
if (inAppPurchaseDataBean.isSubValid()) {
StoreActivity.subscription(true, pref);
// cancel loop on first successful subscription found as response may contain multiple records with canceled subscriptions too.
return;
} else {
StoreActivity.subscription(false, pref);
}
}
} catch (JSONException e) {
C.log("error:"+e);
}
}
}
}).addOnFailureListener(new OnFailureListener() {
@Override
public void onFailure(Exception e) {
if (e instanceof IapApiException) {
IapApiException apiException = (IapApiException)e;
int returnCode = apiException.getStatusCode();
} else {
// Other external errors
}
}
});
}
// trigger subscription status
public static void subscription(Boolean status, SharedPreferences pref){
// enable subscription
if(status){
C.log("Enable subscription");
SharedPreferences.Editor editor = pref.edit();
editor.putBoolean("upgrade1", true);
editor.putLong("upgrade1_time", (System.currentTimeMillis() / 1000));
editor.commit();
// disable subscription
}else{
C.log("Cancel subscription");
SharedPreferences.Editor editor = pref.edit();
editor.putBoolean("upgrade1", false);
editor.putLong("upgrade1_time", (System.currentTimeMillis() / 1000));
editor.commit();
}
}
// retrieve list of availbale products to purchase. Notes subscription in our case
public void initStore(){
C.log("initStore");
ProductInfoReq productInfoReq = new ProductInfoReq();
productInfoReq.setPriceType(IapClient.PriceType.IN_APP_SUBSCRIPTION);
List<String> skuList = new ArrayList<>();
skuList.add(C.products[0]);
productInfoReq.setProductIds(skuList);
Task<ProductInfoResult> task = Iap.getIapClient(this).obtainProductInfo(productInfoReq);
task.addOnSuccessListener(new OnSuccessListener<ProductInfoResult>() {
@Override
public void onSuccess(ProductInfoResult result) {
// Obtain the result
C.log("initStore result: "+result);
List<ProductInfo> productList = result.getProductInfoList();
for(ProductInfo sku : productList){
C.log("Product: "+sku.getProductId());
if(sku.getProductId().equals(C.products[0])) {
centerTextList3.setText(sku.getPrice() + "/" + getResources().getString(R.string.store8));
}
C.log("Price: "+sku.getPrice());
}
}
}).addOnFailureListener(new OnFailureListener() {
@Override
public void onFailure(Exception e) {
// C.log("Error: "+e);
if (e instanceof IapApiException) {
IapApiException apiException = (IapApiException)e;
int returnCode = apiException.getStatusCode();
Toast.makeText(c, apiException.getMessage(), Toast.LENGTH_SHORT).show();
C.log("Error: "+e);
} else {
// Other external errors
}
}
});
}
private void deliverProduct(final String inAppPurchaseData, final String inAppPurchaseDataSignature) {
C.log("deliverProduct");
final String TAG = "deliverProduct";
if (TextUtils.isEmpty(inAppPurchaseData) || TextUtils.isEmpty(inAppPurchaseDataSignature)) {
return;
}
if (!SecurityUtil.doCheck(inAppPurchaseData, inAppPurchaseDataSignature, publicKey)) {
C.log("verify_signature_fail");
Toast.makeText(c, "Can not verify purchase signature.", Toast.LENGTH_SHORT).show();
return;
}
finishUpgrade();
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
final String TAG = "onActivityResult";
super.onActivityResult(requestCode, resultCode, data);
if (data == null){
return;
}
C.log(requestCode + " onActivityResultr:"+resultCode);
PurchaseResultInfo purchaseResultInfo = Iap.getIapClient(this).parsePurchaseResultInfoFromIntent(data);
if (purchaseResultInfo == null) {
C.log("purchaseResultInfo can't be null");
return;
}
// iterate through response codes
switch(purchaseResultInfo.getReturnCode()){
case OrderStatusCode.ORDER_STATE_NET_ERROR: Toast.makeText(c, "Network connection error.", Toast.LENGTH_SHORT).show(); break;
case OrderStatusCode.ORDER_HWID_NOT_LOGIN: Toast.makeText(c, "The HUAWEI ID is not signed in.", Toast.LENGTH_SHORT).show(); break;
case OrderStatusCode.ORDER_PRODUCT_OWNED: finishUpgrade(); return;
case OrderStatusCode.ORDER_PRODUCT_CONSUMED: Toast.makeText(c, "The product has been consumed and cannot be consumed again.", Toast.LENGTH_SHORT).show(); break;
case OrderStatusCode.ORDER_ACCOUNT_AREA_NOT_SUPPORTED: Toast.makeText(c, "The country or region of the signed-in HUAWEI ID does not support HUAWEI IAP.", Toast.LENGTH_SHORT).show(); break;
case 60056: Toast.makeText(c, "Can not continue. Transaction is not secure.", Toast.LENGTH_SHORT).show(); break;
}
if(requestCode == REQ_CODE_BUY_SUPSCRIPTION){
if(purchaseResultInfo.getReturnCode() == OrderStatusCode.ORDER_STATE_SUCCESS) {
String inAppPurchaseDataSignature = purchaseResultInfo.getInAppDataSignature();
String inAppPurchaseData = purchaseResultInfo.getInAppPurchaseData();
deliverProduct(inAppPurchaseData, inAppPurchaseDataSignature);
return;
}
}
}
@Override
public void onResume(){
super.onResume();
// if by nay chanche user opens store while already subscribed close it immediately
if(pref.getBoolean("upgrade1",false)) finishUpgrade();
// prevent store from opening when internet is down
if(!HttpUtils.isNetAvailable(c)) {
Toast.makeText(c, getResources().getString(R.string.i1), Toast.LENGTH_LONG).show();
finish();
}
}
@Override
protected void onPause() {
super.onPause();
}
@Override
public void onDestroy() {
super.onDestroy();
}
// check if user is signed in to huawei etc
private void isEnvReadyResult(){
Task<IsEnvReadyResult> task = Iap.getIapClient(StoreActivity.this).isEnvReady();
task.addOnSuccessListener(new OnSuccessListener<IsEnvReadyResult>() {
@Override
public void onSuccess(IsEnvReadyResult result) { }
}).addOnFailureListener(new OnFailureListener() {
@Override
public void onFailure(Exception e) {
if (e instanceof IapApiException) {
IapApiException apiException = (IapApiException) e;
Status status = apiException.getStatus();
if (status.getStatusCode() == OrderStatusCode.ORDER_HWID_NOT_LOGIN) {
if (status.hasResolution()) {
try {
status.startResolutionForResult(StoreActivity.this, 4444);
} catch (IntentSender.SendIntentException ex) {
ex.printStackTrace();
}
}
} else if (status.getStatusCode() == OrderStatusCode.ORDER_ACCOUNT_AREA_NOT_SUPPORTED) {
Toast.makeText(getApplicationContext(),"This is unavailable in your country/region.", Toast.LENGTH_LONG).show();
}
}
}
});
}
private void subscribe(){
// current time
long now = System.currentTimeMillis()/1000;
// prevent double clicks 5s safe interval
if(now - pref.getLong("last_subscribe_time", 0) < 5) { return; }
pref.edit().putLong("last_subscribe_time", now).apply();
// already subscribed
if(pref.getBoolean("upgrade1", false)){ Toast.makeText(c, getResources().getString(R.string.upgrade_success), Toast.LENGTH_LONG).show(); return; }
// check internet connection
if(!HttpUtils.isNetAvailable(c)) { Toast.makeText(c, getResources().getString(R.string.i1), Toast.LENGTH_LONG).show(); return; }
final String tag = "purchaseStar";
isEnvReadyResult();
PurchaseIntentReq request = new PurchaseIntentReq();
request.setPriceType(IapClient.PriceType.IN_APP_SUBSCRIPTION);
request.setProductId("notes_subscription");
Task<PurchaseIntentResult> task = Iap.getIapClient(StoreActivity.this).createPurchaseIntent(request);
task.addOnSuccessListener(new OnSuccessListener<PurchaseIntentResult>() {
@Override
public void onSuccess(PurchaseIntentResult result) {
Status status = result.getStatus();
C.log("status: "+status);
try {
if (status.hasResolution()) {
status.startResolutionForResult(StoreActivity.this, REQ_CODE_BUY_SUPSCRIPTION);
}
} catch (IntentSender.SendIntentException e) {
Log.e(tag, e.getMessage());
}
}
}).addOnFailureListener(new OnFailureListener() {
@Override
public void onFailure(Exception e) {
if (e instanceof IapApiException) {
IapApiException apiException = (IapApiException) e;
Status status = apiException.getStatus();
int returnCode = apiException.getStatusCode();
Log.e(tag, "status" + status + "error code " + returnCode);
}
Log.e(tag, "onFailure: " + e.getMessage());
}
});
}
// button frontend listener
public void upgrade1(View v){
// do subscription
subscribe();
}
// notify of successful upgrade, cache subscription as enabled and close store activity
private void finishUpgrade(){
Toast.makeText(c, getResources().getString(R.string.upgrade_success), Toast.LENGTH_LONG).show();
StoreActivity.subscription(true, pref);
finish();
}
}
Note that simply copy pasting these code samples will raise errors because some dependencies are missing such as internal logging method C.log or localized string declarations.
However you can easily tweak this code to adjust it according to your needs as it a production working solution.
One method from the code snippets above is declared as static, example: public static void verifySubscription.. It runs independently from other methods in this class.
This is done on purpose and allows other activities to query subscription state. The verifySubscription method is called from app’s main activity every time it is created. This ensures that user gets the latest subscription state should it was paused, resumed or canceled outside of the application.
Store activity visual part
The image above demonstrates visual representation of Raloco Notes store activity. When user opens this activity the code initiates a call to the Play Market or Huawei SDK respectively to query subscription prices and period as specified in the portal.
The code below is the XML version of the store activity.
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:src="@mipmap/ic_launcher"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/screen_wait"
android:layout_gravity="center"
android:contentDescription="@string/app_name"
android:visibility="gone" />
<ScrollView
android:id="@+id/main_cont"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/screen_main"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:alpha="0.95"
android:gravity="top"
android:orientation="vertical"
android:paddingLeft="20dp"
android:paddingRight="20dp">
<ImageView
android:id="@+id/closeStore"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="right"
android:layout_marginTop="12dp"
android:layout_weight="2"
android:src="@drawable/ic_close" />
<TextView
android:id="@+id/centerText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="0dp"
android:layout_marginBottom="25dp"
android:layout_weight="1"
android:gravity="center"
android:keepScreenOn="true"
android:text="@string/store7"
android:textAlignment="center"
android:textColor="@color/text_blue"
android:textSize="24sp"
android:textStyle="bold" />
<ImageView
android:id="@+id/imageView"
android:layout_width="match_parent"
android:layout_height="120dp"
android:layout_gravity="center"
android:layout_marginTop="0dp"
android:layout_weight="1"
android:src="@drawable/logo" />
<TextView
android:id="@+id/centerTextList1"
android:layout_weight="1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:keepScreenOn="true"
android:text="@string/store1"
android:textColor="@color/gray"
android:textSize="24sp"
android:layout_marginTop="20dp"
android:layout_marginBottom="10dp"
android:textStyle="italic" />
<TextView
android:id="@+id/centerTextList2"
android:layout_weight="1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:keepScreenOn="true"
android:text="@string/store2"
android:textColor="@color/gray"
android:textSize="24sp"
android:layout_marginTop="10dp"
android:layout_marginBottom="15dp"
android:textStyle="italic" />
<TextView
android:id="@+id/centerTextList3"
android:layout_weight="1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:keepScreenOn="true"
android:text=""
android:textColor="@color/holo_blue"
android:textSize="18sp"
android:layout_marginTop="10dp"
android:layout_marginBottom="5dp"
android:textStyle="bold" />
<!--<TextView-->
<!--android:id="@+id/centerTextList3"-->
<!--android:layout_weight="1"-->
<!--android:layout_width="wrap_content"-->
<!--android:layout_height="wrap_content"-->
<!--android:layout_gravity="center"-->
<!--android:gravity="center"-->
<!--android:keepScreenOn="true"-->
<!--android:text="@string/store3"-->
<!--android:textColor="@color/gray"-->
<!--android:textSize="18sp"-->
<!--android:layout_marginTop="15dp"-->
<!--android:layout_marginBottom="25dp"-->
<!--android:textStyle="italic" />-->
<!--<TextView-->
<!--android:id="@+id/centerTextList4"-->
<!--android:layout_weight="1"-->
<!--android:layout_width="wrap_content"-->
<!--android:layout_height="wrap_content"-->
<!--android:layout_gravity="center"-->
<!--android:gravity="center"-->
<!--android:keepScreenOn="true"-->
<!--android:text="@string/store4"-->
<!--android:textColor="@color/gray"-->
<!--android:textSize="18sp"-->
<!--android:layout_marginTop="15dp"-->
<!--android:layout_marginBottom="25dp"-->
<!--android:textStyle="italic" />-->
<ImageView
android:id="@+id/store_textb3"
android:layout_width="100dp"
android:layout_height="100dp"
android:contentDescription="@string/store5"
android:scaleType="centerCrop" />
</LinearLayout>
</ScrollView>
<TextView
android:id="@+id/noteText"
android:gravity="left"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:keepScreenOn="true"
android:text="@string/store5"
android:textColor="@color/gray"
android:textSize="12sp"
android:layout_marginTop="15dp"
android:layout_marginLeft="5dp"
android:layout_marginBottom="70dp"
android:textStyle="normal" />
<Button
android:id="@+id/sub_btn"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:background="@color/holo_blue"
android:layout_gravity="bottom"
android:text="SUBSCRIBE"
android:textSize="20sp"
android:textStyle="bold"
android:padding="16dp"
android:layout_margin="2dp"
android:layout_marginBottom="1dp"
android:textColor="@color/white"
android:onClick="upgrade1"
/>
</FrameLayout>
6. Connect test user to check subscription in action
Before moving to production subscription workflow has to be properly tested. AppGallery supports test user registration.
Every connected test user should have a valid Huawei ID account. It took me around 15 minutes before my old Huawei ID account transformed into a test user account. During the first test transaction, it still asks for valid card details, however, under the test mode, the card is not being charged.
Follow the Managing Tester Accounts guide to add a test user to your application.
Some parts of the code were inspired by this How to Use HMS In-App Purchase tutorial.
Thank you for getting through this article, hope it was useful for you. Just in case you want to reach me directly please refer to this page.