Skip to content

Instantly share code, notes, and snippets.

@Bringoff
Created June 13, 2020 13:20
Show Gist options
  • Select an option

  • Save Bringoff/4deb0dab72b5e5dde08d12efd4013225 to your computer and use it in GitHub Desktop.

Select an option

Save Bringoff/4deb0dab72b5e5dde08d12efd4013225 to your computer and use it in GitHub Desktop.
Common interface for Google Play and Galaxy Store subscriptions
public interface BillingClientWithLifecycle extends LifecycleObserver {
Subject<BillingResult> getPurchases();
Subject<List<AvailableProductDetails>> getAvailablePurchasesDetails();
void updatePurchases();
void onPurchasesUpdated(int responseCode, @Nullable List<BillingClientWithLifecycle.OwnedProduct> purchases);
void launchBillingFlow(Activity activity, String productId);
@RequiredArgsConstructor
class BillingResult {
public static final int RESPONSE_OK = 0;
public final int response;
@Nullable
public final List<OwnedProduct> purchaseList;
}
interface AvailableProductDetails {
String getProductId();
/**
* @return Current local price in the local currency of the in-app item (e.g., 7.99)
*/
double getPrice();
/**
* @return Currency code (3 characters) of the local currency (e.g., EUR, GBP, USD)
*/
String getPriceCurrencyCode();
/**
* @return Local currency symbol and price (in the local currency format):
* Currency symbol + price (e.g., £7.99)
* Price + currency symbol (e.g., 66815₫)
*/
String getFormattedPrice();
}
interface OwnedProduct {
String getProductId();
Instant getPurchaseDate();
@Nullable
Instant getExpirationDate();
boolean isAutoRenewing();
}
}
public class GalaxyBillingClientWithLifecycle implements BillingClientWithLifecycle, OnGetOwnedListListener, OnPaymentListener {
private final IapHelper iapHelper;
private final Logger logger;
private BehaviorSubject<BillingResult> purchases = BehaviorSubject.create();
private BehaviorSubject<List<AvailableProductDetails>> availableDetails = BehaviorSubject.create();
public GalaxyBillingClientWithLifecycle(@NonNull Context context, @NonNull Logger logger) {
this.iapHelper = IapHelper.getInstance(context);
this.iapHelper.setOperationMode(GalaxyIapOperationModeProvider.getOperationMode());
this.logger = logger;
updateAvailablePurchasesDetails();
updatePurchases();
}
@Override
public Subject<BillingResult> getPurchases() {
return purchases;
}
@Override
public Subject<List<AvailableProductDetails>> getAvailablePurchasesDetails() {
return availableDetails;
}
@Override
public void updatePurchases() {
iapHelper.getOwnedList("all", this);
}
private void updateAvailablePurchasesDetails() {
iapHelper.getProductsDetails("", (errorVO, rawProductList) -> {
if (errorVO.getErrorCode() == IapHelper.IAP_ERROR_NONE) {
availableDetails.onNext(mapRawDetails(rawProductList));
}
});
}
private List<AvailableProductDetails> mapRawDetails(List<ProductVo> rawDetails) {
return rawDetails == null ? Collections.emptyList()
: rawDetails.stream().map(GalaxyAvailableProductDetails::new).collect(Collectors.toList());
}
@Override
public void onPurchasesUpdated(int responseCode, @Nullable List<OwnedProduct> purchases) {
switch (responseCode) {
case IapHelper.IAP_ERROR_NONE:
logger.info("[GalaxyBillingClientWithLifecycle] Purchase loaded successfully");
handlePurchases(purchases);
break;
case IapHelper.IAP_PAYMENT_IS_CANCELED:
logger.info("[GalaxyBillingClientWithLifecycle] User canceled the purchase");
break;
case IapHelper.IAP_ERROR_ALREADY_PURCHASED:
logger.info("[GalaxyBillingClientWithLifecycle] The user already owns this item");
break;
default:
logger.error("See error code in IapHelper: %d", responseCode);
break;
}
}
private void handlePurchases(List<OwnedProduct> purchaseList) {
logger.info("[GalaxyBillingClientWithLifecycle] handlePurchases: %1$s purchase(s)", Arrays.toString(purchaseList.toArray(new GalaxyOwnedProduct[]{})));
purchases.onNext(new BillingResult(BillingResult.RESPONSE_OK, purchaseList));
}
@Override
public void launchBillingFlow(Activity activity, String productId) {
iapHelper.startPayment(productId, "", true, this);
}
@Override
public void onPayment(ErrorVo errorVO, PurchaseVo purchaseVO) {
if (errorVO.getErrorCode() == IapHelper.IAP_ERROR_NONE) {
this.updatePurchases();
} else {
logger.error("[GalaxyBillingClientWithLifecycle] error while payment flow: %s", errorVO.dump());
}
}
@Override
public void onGetOwnedProducts(ErrorVo errorVO, ArrayList<OwnedProductVo> ownedList) {
List<OwnedProduct> ownedProducts = mapRawOwnedList(ownedList);
onPurchasesUpdated(errorVO.getErrorCode(), ownedProducts);
}
private List<OwnedProduct> mapRawOwnedList(ArrayList<OwnedProductVo> ownedList) {
return ownedList == null ? Collections.emptyList()
: ownedList.stream().map(GalaxyOwnedProduct::new).collect(Collectors.toList());
}
}
public class GPBillingClientWithLifecycle implements BillingClientWithLifecycle {
private final Context context;
private final Logger logger;
private BillingClient billingClient;
private boolean isServiceConnected;
private BehaviorSubject<BillingResult> purchases = BehaviorSubject.create();
private BehaviorSubject<List<AvailableProductDetails>> availablePurchasesDetails = BehaviorSubject.create();
public GPBillingClientWithLifecycle(@NonNull Context context, @NonNull Logger logger) {
this.context = context.getApplicationContext();
this.logger = logger;
}
@Override
public Subject<BillingResult> getPurchases() {
return purchases;
}
@Override
public Subject<List<AvailableProductDetails>> getAvailablePurchasesDetails() {
return availablePurchasesDetails;
}
BillingClient buildBillingClient() {
return BillingClientFactory.create(context, (responseCode, rawPurchases) -> {
List<OwnedProduct> ownedProducts = mapRawPurchases(rawPurchases);
this.onPurchasesUpdated(responseCode, ownedProducts);
});
}
private List<OwnedProduct> mapRawPurchases(List<Purchase> rawPurchases) {
return rawPurchases == null ? null
: rawPurchases.stream().map(GPOwnedProduct::new).collect(Collectors.toList());
}
@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
void create() {
billingClient = buildBillingClient();
logger.info("[GPBillingClientWithLifecycle] billing client created");
isServiceConnected = false;
startClientConnection(() -> {
updateAvailablePurchasesDetails();
updatePurchases();
});
}
private synchronized void startClientConnection(@Nullable Runnable actionOnSuccess) {
if (!isServiceConnected) {
logger.info("[GPBillingClientWithLifecycle] connecting...");
billingClient.startConnection(new BillingClientStateListener() {
@Override
public void onBillingSetupFinished(int responseCode) {
logger.info("[GPBillingClientWithLifecycle] billing setup finished with response %d", responseCode);
if (responseCode == BillingClient.BillingResponse.OK) {
isServiceConnected = true;
if (actionOnSuccess != null) {
actionOnSuccess.run();
}
}
}
@Override
public void onBillingServiceDisconnected() {
isServiceConnected = false;
logger.info("[GPBillingClientWithLifecycle] billing service disconnected");
}
});
} else {
if (actionOnSuccess != null) {
actionOnSuccess.run();
}
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
void destroy() {
if (isServiceConnected) {
billingClient.endConnection();
isServiceConnected = false;
logger.info("[GPBillingClientWithLifecycle] billing client connection ended");
}
}
private void updateAvailablePurchasesDetails() {
Runnable queryAction = () -> {
billingClient.querySkuDetailsAsync(SkuDetailsParams.newBuilder()
.setType(BillingClient.SkuType.SUBS)
.setSkusList(SubscriptionType.getSkuList())
.build(), (responseCode, rawSkuDetailsList) -> {
if (responseCode == BillingClient.BillingResponse.OK && rawSkuDetailsList != null) {
List<AvailableProductDetails> availableProductDetails = mapRawSkuDetails(rawSkuDetailsList);
availablePurchasesDetails.onNext(availableProductDetails);
logger.info("[GPBillingClientWithLifecycle] purchases info loaded: " + rawSkuDetailsList);
} else {
availablePurchasesDetails.onNext(Collections.emptyList());
logger.error("[GPBillingClientWithLifecycle] cannot load purchases info, code " + responseCode);
}
});
};
if (!isServiceConnected) {
logger.error("[GPBillingClientWithLifecycle] billingClient is not ready " +
"to update details, trying to reconnect");
startClientConnection(queryAction);
} else {
queryAction.run();
}
}
@NonNull
private List<AvailableProductDetails> mapRawSkuDetails(List<SkuDetails> rawSkuDetailsList) {
return rawSkuDetailsList.stream().map(GPAvailableProductDetails::new).collect(Collectors.toList());
}
@Override
public void onPurchasesUpdated(int responseCode, @Nullable List<OwnedProduct> purchases) {
logger.debug("[GPBillingClientWithLifecycle] onPurchasesUpdated, response code: %d", responseCode);
handlePurchasesResult(responseCode, purchases);
}
private void handlePurchasesResult(int responseCode, @Nullable List<OwnedProduct> purchases) {
switch (responseCode) {
case BillingClient.BillingResponse.OK:
logger.info("[GPBillingClientWithLifecycle] Purchase loaded successfully");
handlePurchases(purchases);
break;
case BillingClient.BillingResponse.USER_CANCELED:
logger.info("[GPBillingClientWithLifecycle] User canceled the purchase");
break;
case BillingClient.BillingResponse.ITEM_ALREADY_OWNED:
logger.info("[GPBillingClientWithLifecycle] The user already owns this item");
break;
case BillingClient.BillingResponse.DEVELOPER_ERROR:
logger.error("[GPBillingClientWithLifecycle] " +
"Developer error means that Google Play does not recognize the " +
"configuration. If you are just getting started, make sure you have " +
"configured the application correctly in the Google Play Console. " +
"The SKU product ID must match and the APK you are using must be " +
"signed with release keys.");
break;
default:
logger.error("See error code in BillingClient.BillingResponse: %d", responseCode);
break;
}
}
@Override
public void updatePurchases() {
Runnable queryAction = () -> {
Purchase.PurchasesResult result = billingClient.queryPurchases(BillingClient.SkuType.SUBS);
if (result == null) {
logger.info("[GPBillingClientWithLifecycle] update purchase: Null purchase result");
} else {
List<OwnedProduct> purchasesList = mapRawPurchases(result.getPurchasesList());
handlePurchasesResult(result.getResponseCode(), purchasesList);
}
};
if (!isServiceConnected) {
logger.error("[GPBillingClientWithLifecycle] billingClient is not ready to query " +
"for existing purchases, trying to reconnect");
startClientConnection(queryAction);
} else {
queryAction.run();
}
}
private void handlePurchases(@Nullable List<OwnedProduct> purchaseList) {
if (isUnchangedPurchaseList(purchaseList)) {
logger.info("[GPBillingClientWithLifecycle] Same %1" +
"s purchase(s), " +
"no need to post an update to the live data", purchaseList);
} else {
logger.info("[GPBillingClientWithLifecycle]Handling %1$s purchase(s)", purchaseList);
notifyPurchases(purchaseList);
}
}
private boolean isUnchangedPurchaseList(@Nullable List<OwnedProduct> purchaseList) {
// TODO: Optimize to avoid updates with identical data.
return false;
}
private void notifyPurchases(@Nullable List<OwnedProduct> purchaseList) {
logger.info("[GPBillingClientWithLifecycle] notifyPurchases: %1$s purchase(s)", purchaseList);
purchases.onNext(new BillingResult(BillingResult.RESPONSE_OK, purchaseList));
}
@Override
public void launchBillingFlow(Activity activity, String productId) {
Runnable billingFlowAction = () -> {
logger.info("[GPBillingClientWithLifecycle] Launching billing flow " +
"with sku: %1$s", productId);
BillingFlowParams params = BillingFlowParams.newBuilder()
.setType(BillingClient.SkuType.SUBS)
.setSku(productId)
.build();
int responseCode = billingClient.launchBillingFlow(activity, params);
logger.info("[GPBillingClientWithLifecycle] Launch Billing Flow Response Code: %d", responseCode);
};
if (!isServiceConnected) {
logger.error("[GPBillingClientWithLifecycle] BillingClient is not ready to start " +
"billing flow, trying to reconnect");
startClientConnection(billingFlowAction);
} else {
billingFlowAction.run();
}
}
}
@RequiredArgsConstructor
public class GalaxyOwnedProduct implements BillingClientWithLifecycle.OwnedProduct {
private static final DateFormat PURCHASE_TIME_DATE_FORMAT = new SimpleDateFormat("yyyy.mm.dd hh:mm:ss", Locale.US);
private final OwnedProductVo rawProductData;
@Override
public String getProductId() {
return rawProductData.getItemId();
}
@Override
public Instant getPurchaseDate() {
try {
return PURCHASE_TIME_DATE_FORMAT.parse(rawProductData.getPurchaseDate()).toInstant();
} catch (Exception e) {
return Instant.now();
}
}
@Nullable
@Override
public Instant getExpirationDate() {
try {
return PURCHASE_TIME_DATE_FORMAT.parse(rawProductData.getSubscriptionEndDate()).toInstant();
} catch (Exception e) {
return null;
}
}
@Override
public boolean isAutoRenewing() {
return true;
}
@NonNull
public String toString() {
return "GPOwnedProduct{" +
"productId=" + getProductId() +
"purchaseDate=" + getPurchaseDate() +
'}';
}
}
...
@RequiredArgsConstructor
class GPOwnedProduct implements BillingClientWithLifecycle.OwnedProduct {
@NonNull
private final Purchase purchase;
@Override
public String getProductId() {
return purchase.getSku();
}
@Override
public Instant getPurchaseDate() {
return Instant.ofEpochMilli(purchase.getPurchaseTime());
}
@Nullable
@Override
public Instant getExpirationDate() {
return null;
}
@Override
public boolean isAutoRenewing() {
return purchase.isAutoRenewing();
}
@NonNull
@Override
public String toString() {
return "GPOwnedProduct{" +
"productId=" + getProductId() +
"purchaseDate=" + getPurchaseDate() +
'}';
}
}
@RequiredArgsConstructor
public class GalaxyAvailableProductDetails implements BillingClientWithLifecycle.AvailableProductDetails {
private final ProductVo rawProductDetails;
@Override
public String getProductId() {
return rawProductDetails.getItemId();
}
@Override
public double getPrice() {
return rawProductDetails.getItemPrice();
}
@Override
public String getPriceCurrencyCode() {
return rawProductDetails.getCurrencyCode();
}
@Override
public String getFormattedPrice() {
return rawProductDetails.getItemPriceString();
}
}
...
@RequiredArgsConstructor
class GPAvailableProductDetails implements BillingClientWithLifecycle.AvailableProductDetails {
@NonNull
private final SkuDetails skuDetails;
@Override
public String getProductId() {
return skuDetails.getSku();
}
@Override
public double getPrice() {
return skuDetails.getPriceAmountMicros() / 1000000.0;
}
@Override
public String getPriceCurrencyCode() {
return skuDetails.getPriceCurrencyCode();
}
@Override
public String getFormattedPrice() {
return skuDetails.getPrice();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment