Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

詳解定期購入

ymnder
February 07, 2019

 詳解定期購入

Deep dive into subscriptions
DroidKaigi2019, Room2 - 2019/02/07 16:00-16:50
https://droidkaigi.jp/2019/timetable/70670

ymnder

February 07, 2019
Tweet

More Decks by ymnder

Other Decks in Programming

Transcript

  1. 8

  2. 12

  3. 13

  4. 15

  5. 16

  6. BillingClient BillingClient 31 var client = BillingClient.newBuilder(context) .setListener(object : PurchasesUpdatedListener

    { override fun onPurchasesUpdated(responseCode: Int, purchases: MutableList<Purchase>?) { // call if purchase has done } }) .build()
  7. Service startConnection 32 client.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(@BillingClient.BillingResponse

    billingResponseCode: Int) { if (billingResponseCode == BillingClient.BillingResponse.OK) { // The billing client is ready. You can query purchases here. } } override fun onBillingServiceDisconnected() { // Try to restart the connection on the next request to // Google Play by calling the startConnection() method. } })
  8. launchBillingFlow PurchasesUpdatedListener 踏 33 val flowParams = BillingFlowParams.newBuilder() .setSku("skuId") .setType(BillingClient.SkuType.SUBS)

    // SkuType.SUB for subscription .build() val responseCode = client.launchBillingFlow(activity, flowParams)
  9. BillingClient / BillingClientImpl BillingClient PlayStore 35 public static final class

    Builder { private final Context mContext; private PurchasesUpdatedListener mListener; private Builder(Context context) { mContext = context; } @UiThread public Builder setListener(PurchasesUpdatedListener listener) { mListener = listener; return this; } @UiThread public BillingClient build() { if (mContext == null) { throw new IllegalArgumentException("Please provide a valid Context."); } if (mListener == null) { throw new IllegalArgumentException(/…/); } return new BillingClientImpl(mContext, mListener); } }
  10. BillingClient / BillingClientImpl PurchasesUpdatedListener 踏 Client 36 @UiThread public BillingClient

    build() { if (mContext == null) { throw new IllegalArgumentException("Please provide a valid Context."); } if (mListener == null) { throw new IllegalArgumentException( "Please provide a valid listener for" + " purchases updates."); } return new BillingClientImpl(mContext, mListener); }
  11. 39 public @interface ClientState { /** This client was not

    yet connected to billing service or was already disconnected from it. */ int DISCONNECTED = 0; /** This client is currently in process of connecting to billing service. */ int CONNECTING = 1; /** This client is currently connected to billing service. */ int CONNECTED = 2; /** This client was already closed and shouldn't be used again. */ int CLOSED = 3; }
  12. isReady startConnection endConnection 40 @UiThread public abstract boolean isReady(); @UiThread

    public abstract void startConnection(@NonNull final BillingClientStateListener listener); @UiThread public abstract void endConnection();
  13. isReady 41 public boolean isReady() { return mClientState == ClientState.CONNECTED

    && mService != null && mServiceConnection != null; }
  14. startConnection 42 public void startConnection(@NonNull BillingClientStateListener listener) { if (isReady())

    { listener.onBillingSetupFinished(BillingResponse.OK); return; } if (mClientState == ClientState.CONNECTING) { listener.onBillingSetupFinished(BillingResponse.DEVELOPER_ERROR); return; } if (mClientState == ClientState.CLOSED) { listener.onBillingSetupFinished(BillingResponse.DEVELOPER_ERROR); return; } mClientState = ClientState.CONNECTING; //… if connected, set ClientState.CONNECTED in BillingServiceConnection mClientState = ClientState.DISCONNECTED; BillingHelper.logVerbose(TAG, "Billing service unavailable on device."); listener.onBillingSetupFinished(BillingResponse.BILLING_UNAVAILABLE); }
  15. endConnection 44 public void endConnection() { try { LocalBroadcastManager.getInstance(mApplicationContext) .unregisterReceiver(onPurchaseFinishedReceiver);

    mBroadcastManager.destroy(); if (mServiceConnection != null && mService != null) { BillingHelper.logVerbose(TAG, "Unbinding from service."); mApplicationContext.unbindService(mServiceConnection); mServiceConnection = null; } mService = null; if (mExecutorService != null) { mExecutorService.shutdownNow(); mExecutorService = null; } } catch (Exception ex) { BillingHelper.logWarn(TAG, "There was an exception while ending connection: " + ex); } finally { mClientState = ClientState.CLOSED; } }
  16. endConnection 45 public void endConnection() { try { LocalBroadcastManager.getInstance(mApplicationContext) .unregisterReceiver(onPurchaseFinishedReceiver);

    mBroadcastManager.destroy(); if (mServiceConnection != null && mService != null) { BillingHelper.logVerbose(TAG, "Unbinding from service."); mApplicationContext.unbindService(mServiceConnection); mServiceConnection = null; } mService = null; if (mExecutorService != null) { mExecutorService.shutdownNow(); mExecutorService = null; } } catch (Exception ex) { BillingHelper.logWarn(TAG, "There was an exception while ending connection: " + ex); } finally { mClientState = ClientState.CLOSED; } }
  17. isFeatureSupported PlayStore 47 public @BillingResponse int isFeatureSupported(@FeatureType String feature) {

    if (!isReady()) { return BillingResponse.SERVICE_DISCONNECTED; } switch (feature) { case FeatureType.SUBSCRIPTIONS: return mSubscriptionsSupported ? BillingResponse.OK : BillingResponse.FEATURE_NOT_SUPPORTED; case FeatureType.SUBSCRIPTIONS_UPDATE: return mSubscriptionUpdateSupported ? BillingResponse.OK: BillingResponse.FEATURE_NOT_SUPPORTED; case FeatureType.IN_APP_ITEMS_ON_VR: return isBillingSupportedOnVr(SkuType.INAPP); case FeatureType.SUBSCRIPTIONS_ON_VR: return isBillingSupportedOnVr(SkuType.SUBS); default: return BillingResponse.DEVELOPER_ERROR; } }
  18. launchBillingFlow 48 public int launchBillingFlow(Activity activity, BillingFlowParams params) { if

    (!isReady()) { return broadcastFailureAndReturnBillingResponse(BillingResponse.SERVICE_DISCONNECTED); } @SkuType String skuType = params.getSkuType(); String newSku = params.getSku(); if (newSku == null) { BillingHelper.logWarn(TAG, "Please fix the input params. SKU can't be null."); return broadcastFailureAndReturnBillingResponse(BillingResponse.DEVELOPER_ERROR); } try { //… Intent intent = new Intent(activity, ProxyBillingActivity.class); PendingIntent pendingIntent = buyIntentBundle.getParcelable(RESPONSE_BUY_INTENT); intent.putExtra(RESPONSE_BUY_INTENT, pendingIntent); activity.startActivity(intent); } catch (RemoteException e) { //… } return BillingResponse.OK; }
  19. launchBillingFlow 49 public int launchBillingFlow(Activity activity, BillingFlowParams params) { if

    (!isReady()) { return broadcastFailureAndReturnBillingResponse(BillingResponse.SERVICE_DISCONNECTED); } @SkuType String skuType = params.getSkuType(); String newSku = params.getSku(); if (newSku == null) { BillingHelper.logWarn(TAG, "Please fix the input params. SKU can't be null."); return broadcastFailureAndReturnBillingResponse(BillingResponse.DEVELOPER_ERROR); } try { //… Intent intent = new Intent(activity, ProxyBillingActivity.class); PendingIntent pendingIntent = buyIntentBundle.getParcelable(RESPONSE_BUY_INTENT); intent.putExtra(RESPONSE_BUY_INTENT, pendingIntent); activity.startActivity(intent); } catch (RemoteException e) { //… } return BillingResponse.OK; }
  20. launchBillingFlow 52 public int launchBillingFlow(Activity activity, BillingFlowParams params) { if

    (!isReady()) { return broadcastFailureAndReturnBillingResponse(BillingResponse.SERVICE_DISCONNECTED); } @SkuType String skuType = params.getSkuType(); String newSku = params.getSku(); if (newSku == null) { BillingHelper.logWarn(TAG, "Please fix the input params. SKU can't be null."); return broadcastFailureAndReturnBillingResponse(BillingResponse.DEVELOPER_ERROR); } try { //… Intent intent = new Intent(activity, ProxyBillingActivity.class); PendingIntent pendingIntent = buyIntentBundle.getParcelable(RESPONSE_BUY_INTENT); intent.putExtra(RESPONSE_BUY_INTENT, pendingIntent); activity.startActivity(intent); } catch (RemoteException e) { //… } return BillingResponse.OK; }
  21. queryPurchaseHistoryAsync 56 public void queryPurchaseHistoryAsync( final @SkuType String skuType, final

    PurchaseHistoryResponseListener listener) { if (!isReady()) { listener.onPurchaseHistoryResponse( BillingResponse.SERVICE_DISCONNECTED, /* purchasesList */ null); return; } executeAsync( new Runnable() { @Override public void run() { final PurchasesResult result = queryPurchasesInternal(skuType, /* queryHistory */ true); // Post the result to main thread postToUiThread( new Runnable() { @Override public void run() { listener.onPurchaseHistoryResponse( result.getResponseCode(), result.getPurchasesList()); } }); } }); }
  22. queryPurchaseHistoryAsync 57 public void queryPurchaseHistoryAsync( final @SkuType String skuType, final

    PurchaseHistoryResponseListener listener) { if (!isReady()) { listener.onPurchaseHistoryResponse( BillingResponse.SERVICE_DISCONNECTED, /* purchasesList */ null); return; } executeAsync( new Runnable() { @Override public void run() { final PurchasesResult result = queryPurchasesInternal(skuType, /* queryHistory */ true); // Post the result to main thread postToUiThread( new Runnable() { @Override public void run() { listener.onPurchaseHistoryResponse( result.getResponseCode(), result.getPurchasesList()); } }); } }); }
  23. queryPurchases 60 public PurchasesResult queryPurchases(@SkuType String skuType) { if (!isReady())

    { return new PurchasesResult(BillingResponse.SERVICE_DISCONNECTED, /* purchasesList */ null); } // Checking for the mandatory argument if (TextUtils.isEmpty(skuType)) { BillingHelper.logWarn(TAG, "Please provide a valid SKU type."); return new PurchasesResult(BillingResponse.DEVELOPER_ERROR, /* purchasesList */ null); } return queryPurchasesInternal(skuType, false /* queryHistory */); }
  24. 63

  25. launchBillingFlow 66 { "autoRenewing": true, "orderId": "GPA.0000-1111-2222-33444", "packageName": "com.nikkei.newspaper", "productId":

    "skuId", "purchaseState": 0, "purchaseTime": 1549417790186, "purchaseToken": “…” }
  26. queryPurchases 68 { "autoRenewing": true, "orderId": "GPA.0000-1111-2222-33444", "packageName": "com.nikkei.newspaper", "productId":

    "skuId", "purchaseState": 0, "purchaseTime": 1549417790186, "purchaseToken": “…” }
  27. queryPurchases 69 { "autoRenewing": false, "orderId": "GPA.0000-1111-2222-33444", "packageName": "com.nikkei.newspaper", "productId":

    “skuId", "purchaseState": 0, "purchaseTime": 1549417790186, "purchaseToken": “…” }
  28. BillingResponse 72 public @interface BillingResponse { int FEATURE_NOT_SUPPORTED = -2;

    int SERVICE_DISCONNECTED = -1; /** Success */ int OK = 0; /** User pressed back or canceled a dialog */ int USER_CANCELED = 1; /** Network connection is down */ int SERVICE_UNAVAILABLE = 2; /** Billing API version is not supported for the type requested */ int BILLING_UNAVAILABLE = 3; /** Requested product is not available for purchase */ int ITEM_UNAVAILABLE = 4; int DEVELOPER_ERROR = 5; /** Fatal error during the API action */ int ERROR = 6; /** Failure to purchase since item is already owned */ int ITEM_ALREADY_OWNED = 7; /** Failure to consume since item is not owned */ int ITEM_NOT_OWNED = 8; }
  29. BillingResponse startConnection 73 return when (resultCode) { BillingClient.BillingResponse.FEATURE_NOT_SUPPORTED -> "FEATURE_NOT_SUPPORTED"

    BillingClient.BillingResponse.SERVICE_DISCONNECTED -> "SERVICE_DISCONNECTED" BillingClient.BillingResponse.OK -> "OK" BillingClient.BillingResponse.USER_CANCELED -> "USER_CANCELED" BillingClient.BillingResponse.SERVICE_UNAVAILABLE -> "SERVICE_UNAVAILABLE" BillingClient.BillingResponse.BILLING_UNAVAILABLE -> "BILLING_UNAVAILABLE" BillingClient.BillingResponse.ITEM_UNAVAILABLE -> "ITEM_UNAVAILABLE" BillingClient.BillingResponse.DEVELOPER_ERROR -> "DEVELOPER_ERROR" BillingClient.BillingResponse.ERROR -> "ERROR" BillingClient.BillingResponse.ITEM_ALREADY_OWNED -> "ITEM_ALREADY_OWNED" BillingClient.BillingResponse.ITEM_NOT_OWNED -> "ITEM_NOT_OWNED" else -> "UNDEFINED:$resultCode" }
  30. 77

  31. 79

  32. 83

  33. 87

  34. BillingActivity Activity 踏 踏 Activity BillingClient Activity Activity issue ref:

    https://github.com/googlesamples/android-play-billing/ issues/95 101
  35. launchBilling ow PublishSubject Subject Subscriber Observable Listener subscribe onNext instance

    callback PublishSubject callback onNext 踏 Rx Remote Single Wrap Repository 108
  36. BillingManager 109 interface PlayBillingManager { fun observePurchasesUpdated(): PublishSubject<PurchasesUpdatedResponse> fun isReady():

    Boolean fun startConnection(listener: BillingClientStateListener) fun launchBillingFlow(activity: Activity, params: BillingFlowParams): Int fun queryPurchases(): Purchase.PurchasesResult fun endConnection() fun queryPurchaseHistoryAsync(skuId: String): Single<PurchasesUpdatedResponse> }
  37. BillingApiManager 110 @Singleton class PlayBillingManagerImpl @Inject constructor( private val context:

    Context ) : PlayBillingManager, PurchasesUpdatedListener { private val publishSubject = PublishSubject.create<PurchasesUpdatedResponse>() override fun onPurchasesUpdated(responseCode: Int, purchases: MutableList<Purchase>?) { if (responseCode == BillingClient.BillingResponse.OK) { publishSubject.onNext(PurchasesUpdatedResponse(responseCode, purchases)) } else { publishSubject.onNext(PurchasesUpdatedResponse(responseCode)) } } override fun observePurchasesUpdated(): PublishSubject<PurchasesUpdatedResponse> { return publishSubject } override fun launchBillingFlow(activity: Activity, params: BillingFlowParams): Int { return billingClient.launchBillingFlow(activity, params) } }
  38. BillingApiManager 111 @Singleton class PlayBillingManagerImpl @Inject constructor( private val context:

    Context ) : PlayBillingManager, PurchasesUpdatedListener { private val publishSubject = PublishSubject.create<PurchasesUpdatedResponse>() override fun onPurchasesUpdated(responseCode: Int, purchases: MutableList<Purchase>?) { if (responseCode == BillingClient.BillingResponse.OK) { publishSubject.onNext(PurchasesUpdatedResponse(responseCode, purchases)) } else { publishSubject.onNext(PurchasesUpdatedResponse(responseCode)) } } override fun observePurchasesUpdated(): PublishSubject<PurchasesUpdatedResponse> { return publishSubject } override fun launchBillingFlow(activity: Activity, params: BillingFlowParams): Int { return billingClient.launchBillingFlow(activity, params) } }
  39. Remote 112 @Singleton class RemotePlayBillingImpl @Inject constructor( private val billingClient:

    PlayBillingManager ) : RemotePlayBilling { override fun launchBillingFlow(activity: Activity, skuId: String): Single<PurchasePaperResponse> { return Single.create { val flowParams = BillingFlowParams.newBuilder() .setSku(skuId) .setType(TYPE_SUBSCRIPTION) .build() val responseCode = billingClient.launchBillingFlow(activity, flowParams) if (responseCode == BillingClient.BillingResponse.OK) { it.onSuccess(PurchasePaperResponse.PurchaseSuccess(responseCode)) } else { it.onError(PurchaseException(responseCode, "ߪಡΤϥʔ")) } } } override fun observePurchasesUpdated(): Observable<PurchasesUpdatedResponse> { return billingClient.observePurchasesUpdated() } }
  40. Remote 113 @Singleton class RemotePlayBillingImpl @Inject constructor( private val billingClient:

    PlayBillingManager ) : RemotePlayBilling { override fun launchBillingFlow(activity: Activity, skuId: String): Single<PurchasePaperResponse> { return Single.create { val flowParams = BillingFlowParams.newBuilder() .setSku(skuId) .setType(TYPE_SUBSCRIPTION) .build() val responseCode = billingClient.launchBillingFlow(activity, flowParams) if (responseCode == BillingClient.BillingResponse.OK) { it.onSuccess(PurchasePaperResponse.PurchaseSuccess(responseCode)) } else { it.onError(PurchaseException(responseCode, "ߪಡΤϥʔ")) } } } override fun observePurchasesUpdated(): Observable<PurchasesUpdatedResponse> { return billingClient.observePurchasesUpdated() } }
  41. Repository 115 class PlayBillingRepositoryImpl @Inject constructor( private val remote: RemotePlayBilling

    ) : PlayBillingRepository { override fun startSubscription(activity: Activity, skuId: String): Observable<PurchaseResponse> { return remote.connect() .flatMapObservable { remote.launchBillingFlow(activity, skuId) .flatMapObservable { remote.observePurchasesUpdated() .flatMap { if (it.result != BillingClient.BillingResponse.OK) { return@flatMap Observable.error<PurchasesUpdatedResponse>(…) } return@flatMap Observable.just(it) } //… } } } }
  42. Repository 116 class PlayBillingRepositoryImpl @Inject constructor( private val remote: RemotePlayBilling

    ) : PlayBillingRepository { override fun startSubscription(activity: Activity, skuId: String): Observable<PurchaseResponse> { return remote.connect() .flatMapObservable { remote.launchBillingFlow(activity, skuId) .flatMapObservable { remote.observePurchasesUpdated() .flatMap { if (it.result != BillingClient.BillingResponse.OK) { return@flatMap Observable.error<PurchasesUpdatedResponse>(…) } return@flatMap Observable.just(it) } //… } } } }
  43. RemoteTest 117 @Before fun setUp() { manager = mock(PlayBillingManager::class.java) remote

    = RemotePlayBillingImpl(manager) } 
 @Test fun connect_isReady() { `when`(manager.isReady()).thenReturn(true) val response = remote.connect() .test() .await() .values()[0] assertThat(response).isNotNull() assertThat(response.result).isEqualTo(BillingClient.BillingResponse.OK) }
  44. RemoteTest 118 @Test fun observePurchasesUpdated_error() { val subject = spy(PublishSubject.create<PurchasesUpdatedResponse>())

    `when`(remote.observePurchasesUpdated()).thenReturn(subject) val latch = CountDownLatch(2) var responseCode = 0 remote.observePurchasesUpdated() .subscribe({ responseCode += 1 latch.countDown() }, { latch.countDown() }) subject.onNext(PurchasesUpdatedResponse(0, null)) subject.onError(PurchaseException(BillingClient.BillingResponse.ERROR, "ΤϥʔͰ͢ʂʂ")) subject.onNext(PurchasesUpdatedResponse(1, null)) subject.onError(PurchaseException(BillingClient.BillingResponse.ERROR, "ΤϥʔͰ͢ʂʂ")) latch.await() assertThat(responseCode).isEqualTo(1) verify(subject, times(2)).onNext(any()) verify(subject, times(2)).onError(any()) }
  45. 122

  46. 126

  47. 127