詳解定期購入

934a9e49edc3174d09ab2e09daed5062?s=47 ymnder
February 07, 2019

 詳解定期購入

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

934a9e49edc3174d09ab2e09daed5062?s=128

ymnder

February 07, 2019
Tweet

Transcript

  1. Deep dive into subscriptions DroidKaigi2019, Room2 - 2019/02/07 16:00-16:50

  2. whoami twitter:@ymnd, github:@ymnder Application Engineer Android Android https://s.nikkei.com/s_android Day2: Room

    4 16:50-17:20 WebView+ViewGroup AOSP by ogapants 2
  3. Today s menu 3

  4. RealTime Developer Noti cation iOS IAP 4

  5. Caution 


  6. Caution https://developer.android.com/google/play/billing/ billing_best_practices

  7. None
  8. 8

  9. 9 αϒεΫϦϓγϣϯ͸ʮߪೖ͍ͯ͠Δظؒ಺͚ͩʯ ౰֘αʔϏεΛ࢖͑ΔΑ͏ʹͳΔ࢓૊Έ

  10. 10 ՝ۚ ՝ۚ ՝ۚ … ղ໿ ࢧ෷͍ෆঝೝ

  11. or 11

  12. 12

  13. 13

  14. PlayStore 14

  15. 15

  16. 16

  17. None
  18. ID ID ID 18

  19. GooglePlay ID ID ID 19

  20. GooglePlay 30% 15% ref: https://support.google.com/googleplay/android-developer/ answer/112622?hl=ja KPI KPI 20

  21. GooglePlay 21

  22. PlayStore Web PlayStore 22

  23. Google Play 23

  24. 20 30 ID Play 24

  25. PlayStore 25

  26. None
  27. None
  28. Google Play Billing Library GooglePlayStoreApp StoreApp Android 署 API wrap

    28
  29. Google Play Billing Library https://developer.android.com/reference/com/android/ billingclient/classes v1.1 v1.2 released (2018-10-18)

    29
  30. app/build.gradle 30 dependencies { implementation “com.android.billingclient:billing:1.1” }

  31. 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()
  32. 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. } })
  33. launchBillingFlow PurchasesUpdatedListener 踏 33 val flowParams = BillingFlowParams.newBuilder() .setSku("skuId") .setType(BillingClient.SkuType.SUBS)

    // SkuType.SUB for subscription .build() val responseCode = client.launchBillingFlow(activity, flowParams)
  34. None
  35. 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); } }
  36. 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); }
  37. PurchasesUpdatedListener purchases null 37 public interface PurchasesUpdatedListener { void onPurchasesUpdated(@BillingResponse

    int responseCode, @Nullable List<Purchase> purchases); }
  38. BillingClient Service start / ready / end start InAppBillingService isReady

    start end Service unbind null end Client 38
  39. 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; }
  40. isReady startConnection endConnection 40 @UiThread public abstract boolean isReady(); @UiThread

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

    && mService != null && mServiceConnection != null; }
  42. 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); }
  43. startConnection / onServiceConnected IABv6 43

  44. 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; } }
  45. 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; } }
  46. endConnection Activity Client onDestroy Application I/O 2018 ref: https://github.com/googlesamples/android-play-billing/ blob/master/TrivialDriveKotlin/app/src/main/java/com/

    kotlin/trivialdrive/billingrepo/BillingRepository.kt#L210 46
  47. 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; } }
  48. 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; }
  49. 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; }
  50. BillingFlowParams ID Type 50 BillingFlowParams.newBuilder() .setSku("skuId") .setType(BillingClient.SkuType.SUBS) .build()

  51. launchBillingFlow Activity BillingFlowParams Activity Client 踏 51

  52. 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; }
  53. launchBillingFlow Activity BillingFlowParams Activity Client 踏 53

  54. launchBillingFlow Activity Params 54

  55. queryPurchaseHistoryAsync skuId 55

  56. 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()); } }); } }); }
  57. 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()); } }); } }); }
  58. queryPurchaseHistoryAsync API API 58

  59. queryPurchases Play Store App Cache 59

  60. 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 */); }
  61. PlayStore 61

  62. launchBillingFlow onPurchasesUpdated(int responseCode, List<Purchase> purchases) queryPurchaseHistoryAsync onPurchaseHistoryResponse(int responseCode, List<Purchase> purchasesList)

    queryPurchases Purchase.PurchasesResult 62
  63. 63

  64. autoRenewing orderId ID packageName productId ID purchaseState purchaseTime purchaseToken https://developer.android.com/google/play/billing/billing_reference?

    hl=ja#getBuyIntent 64
  65. autoRenewing orderId ID packageName productId ID purchaseState purchaseTime purchaseToken https://developer.android.com/google/play/billing/billing_reference?

    hl=ja#getBuyIntent 65
  66. launchBillingFlow 66 { "autoRenewing": true, "orderId": "GPA.0000-1111-2222-33444", "packageName": "com.nikkei.newspaper", "productId":

    "skuId", "purchaseState": 0, "purchaseTime": 1549417790186, "purchaseToken": “…” }
  67. queryPurchaseHistoryAsync 67 { “productId":"skuId", “purchaseToken”:”…", “purchaseTime":1549417790186, “developerPayload”:null }

  68. queryPurchases 68 { "autoRenewing": true, "orderId": "GPA.0000-1111-2222-33444", "packageName": "com.nikkei.newspaper", "productId":

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

    “skuId", "purchaseState": 0, "purchaseTime": 1549417790186, "purchaseToken": “…” }
  70. queryPurchaseHistoryAsync launchBillingFlow queryPurchases 70

  71. queryPurchaseHistoryAsync queryPurchases autoRenewing 71

  72. 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; }
  73. 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" }
  74. querySkuDetailsAsync v1.2 launchPriceChangeCon rmationFlow loadRewardedSku setChildDirected 74

  75. None
  76. UI https://note.mu/telq/n/n836e139e0a6b 76

  77. 77

  78. ID 78

  79. 79

  80. None
  81. ref: https://developer.android.com/google/play/billing/ billing_admin.html?hl=ja 81

  82. GooglePlayConsole > 82

  83. 83

  84. ID skuId productId, ID 84

  85. > 85

  86. com.android.vending.BILLING implementation com.android.billingclient:billing:1.1' 86

  87. 87

  88. TestCard TestCard Google Play https://support.google.com/googleplay/android-developer/answer/6062777? hl=ja 88

  89. > 
 or URL 89

  90. > > Gmail 90

  91. 4,200/5min GPay TestCard 91

  92. / 1 / 5min, 1 / 5min 
 https://developer.android.com/google/play/billing/ billing_testing#testing-renewals

    92
  93. PlayStore PlayStore https://github.com/googlesamples/android-play-billing/issues/2 PlayStore 93

  94. PlayStore 94

  95. ID ID PlayStoreApp App Console 95

  96. applicationID ID avor 96

  97. tips ref: https://techbookfest.org/event/tbf04/circle/14710011 store ID 97

  98. tips 98

  99. None
  100. skuId PlayStore API skuId querySkuDetailsAsync 100

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

    https://github.com/googlesamples/android-play-billing/ issues/95 101
  102. RxJava Rx Kotlin ref: https://github.com/googlesamples/android-play-billing/ blob/master/TrivialDriveKotlin/app/src/main/java/com/ kotlin/trivialdrive/billingrepo/BillingRepository.kt#L210 102

  103. 103 SFNPUF MPDBM SFQPTJUPSZ VTFDBTF QSFTFOUFS WJFX

  104. Remote Local Repository interface RxJava Single Completable Remote BillingClient BillingLibrary

    Manager Listener 104
  105. launchBilling ow launchBilling ow BillingClient Listener Callback 105

  106. launchBilling ow launchBilling ow PurchasesUpdatedListener 踏 RxJava PublishSubject 106

  107. launchBilling ow 107

  108. launchBilling ow PublishSubject Subject Subscriber Observable Listener subscribe onNext instance

    callback PublishSubject callback onNext 踏 Rx Remote Single Wrap Repository 108
  109. 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> }
  110. 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) } }
  111. 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) } }
  112. 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() } }
  113. 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() } }
  114. startConnection launchBilling ow PurchasesUpdatedListener 114

  115. 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) } //… } } } }
  116. 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) } //… } } } }
  117. 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) }
  118. 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()) }
  119. 踏 119

  120. None
  121. PlayStoreConsole 121

  122. 122

  123. > 123

  124. > 124

  125. > > 125

  126. 126

  127. 127

  128. 30 1 2 9:51 2 2 9:51 3 2 9:51

    128
  129. 3 129

  130. v1.1 > : ID ID v1.2 launchPriceChangeCon rmationFlow 130

  131. https://github.com/googlesamples/android-play-billing https://developer.android.com/google/play/billing/billing_overview Library PlayConsole 131

  132. Hello Subscription :)

  133. None
  134. PlayStore https://play.google.com/store/account/subscriptions? sku=XXX&package=YYY 134

  135. PlayStore app music video 135

  136. Google I/O https://www.youtube.com/watch?v=x1AYelepG6o https://www.youtube.com/watch?v=oib_gHJA_- https://www.youtube.com/watch?v=6IT689K3kOo https://developer.android.com/google/play/billing/ https://support.google.com/googleplay/android-developer/answer/140504?hl=ja https://github.com/googlesamples/android-play-billing RxJava https://techlife.cookpad.com/entry/2018/03/14/090000

    https://github.com/bu erapp/ReactivePlayBilling https://github.com/vberezkin/billing-android 136
  137. Account Hold https://techbookfest.org/event/tbf04/circle/16610003 v1.0 https://medium.com/exploring-android/exploring-the-play- billing-library-for-android-55321f282929 v1.1 https://speakerdeck.com/ymnder/whats-new-in-google-play- billing 137