admanagekit-mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/content/README.md +851 -0
- package/content/docs/AD_FREQUENCY_CONTROL.md +450 -0
- package/content/docs/AD_LOADING_STRATEGIES.md +369 -0
- package/content/docs/API_REFERENCE.md +734 -0
- package/content/docs/APP_PURCHASE_GUIDE.md +704 -0
- package/content/docs/BANNER_AD_IMPROVEMENTS.md +325 -0
- package/content/docs/COMPOSE_INTEGRATION.md +477 -0
- package/content/docs/CONFIGURATION_USAGE.md +168 -0
- package/content/docs/INTERSTITIAL_BUILDER_GUIDE.md +518 -0
- package/content/docs/JAVA_USAGE_GUIDE.md +785 -0
- package/content/docs/LOADING_STRATEGY_EXAMPLES.md +463 -0
- package/content/docs/NATIVE_AD_MANAGER_ENHANCEMENTS.md +347 -0
- package/content/docs/NATIVE_AD_PRELOADING.md +467 -0
- package/content/docs/NATIVE_TEMPLATE_VIEW.md +413 -0
- package/content/docs/app-open-ads.md +412 -0
- package/content/docs/interstitial-ads.md +269 -0
- package/content/docs/native-ads-caching.md +182 -0
- package/content/docs/release-notes/RELEASE_NOTES_v2.1.0.md +156 -0
- package/content/docs/release-notes/RELEASE_NOTES_v2.2.0.md +248 -0
- package/content/docs/release-notes/RELEASE_NOTES_v2.3.0.md +280 -0
- package/content/docs/release-notes/RELEASE_NOTES_v2.5.0.md +73 -0
- package/content/docs/release-notes/RELEASE_NOTES_v2.6.0.md +265 -0
- package/content/docs/release-notes/RELEASE_NOTES_v2.7.0.md +168 -0
- package/content/docs/release-notes/RELEASE_NOTES_v2.8.0.md +153 -0
- package/content/docs/release-notes/RELEASE_NOTES_v2.9.0.md +479 -0
- package/content/docs/release-notes/RELEASE_NOTES_v3.0.0.md +343 -0
- package/content/docs/release-notes/RELEASE_NOTES_v3.1.0.md +131 -0
- package/content/docs/release-notes/RELEASE_NOTES_v3.3.0.md +252 -0
- package/content/docs/release-notes/RELEASE_NOTES_v3.3.2.md +215 -0
- package/content/docs/release-notes/RELEASE_NOTES_v3.3.3.md +116 -0
- package/content/docs/release-notes/RELEASE_NOTES_v3.3.4.md +142 -0
- package/content/docs/release-notes/RELEASE_NOTES_v3.3.5.md +94 -0
- package/content/docs/rewarded-ads.md +623 -0
- package/content/wiki/Ad-Loading-Strategies.md +209 -0
- package/content/wiki/App-Open-Ads.md +189 -0
- package/content/wiki/Banner-Ads.md +133 -0
- package/content/wiki/Billing-Integration.md +69 -0
- package/content/wiki/Configuration.md +252 -0
- package/content/wiki/Consumable-Products.md +166 -0
- package/content/wiki/Home.md +144 -0
- package/content/wiki/Interstitial-Ads.md +313 -0
- package/content/wiki/Jetpack-Compose.md +270 -0
- package/content/wiki/NativeAdManager.md +220 -0
- package/content/wiki/Purchase-Categories.md +94 -0
- package/content/wiki/Rewarded-Ads.md +420 -0
- package/content/wiki/Subscription-Upgrades.md +278 -0
- package/content/wiki/Subscriptions.md +368 -0
- package/content/wiki/_Footer.md +3 -0
- package/content/wiki/_Sidebar.md +18 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +18 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/code-generation.d.ts +2 -0
- package/dist/tools/code-generation.js +264 -0
- package/dist/tools/code-generation.js.map +1 -0
- package/dist/tools/documentation.d.ts +2 -0
- package/dist/tools/documentation.js +202 -0
- package/dist/tools/documentation.js.map +1 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.js +83 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/doc-loader.d.ts +17 -0
- package/dist/utils/doc-loader.js +245 -0
- package/dist/utils/doc-loader.js.map +1 -0
- package/dist/utils/search.d.ts +2 -0
- package/dist/utils/search.js +62 -0
- package/dist/utils/search.js.map +1 -0
- package/dist/utils/templates.d.ts +60 -0
- package/dist/utils/templates.js +793 -0
- package/dist/utils/templates.js.map +1 -0
- package/package.json +51 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
# AppPurchase - Complete Billing Integration Guide
|
|
2
|
+
|
|
3
|
+
`AppPurchase` is a comprehensive wrapper for Google Play Billing Library v8, providing simplified APIs for in-app purchases, subscriptions, and purchase management.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Setup](#setup)
|
|
8
|
+
- [Product Configuration](#product-configuration)
|
|
9
|
+
- [Making Purchases](#making-purchases)
|
|
10
|
+
- [Consumable Products](#consumable-products)
|
|
11
|
+
- [Subscriptions](#subscriptions)
|
|
12
|
+
- [Subscription Upgrade/Downgrade](#subscription-upgradedowngrade)
|
|
13
|
+
- [Purchase State Checking](#purchase-state-checking)
|
|
14
|
+
- [Purchase History Tracking](#purchase-history-tracking)
|
|
15
|
+
- [Server-Side Verification](#server-side-verification)
|
|
16
|
+
- [Best Practices](#best-practices)
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Setup
|
|
21
|
+
|
|
22
|
+
### 1. Add Dependencies
|
|
23
|
+
|
|
24
|
+
```groovy
|
|
25
|
+
implementation 'com.github.i2hammad.AdManageKit:ad-manage-kit-billing:v2.9.0'
|
|
26
|
+
implementation 'com.github.i2hammad.AdManageKit:ad-manage-kit-core:v2.9.0'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### 2. Initialize in Application Class
|
|
30
|
+
|
|
31
|
+
```kotlin
|
|
32
|
+
class MyApp : Application() {
|
|
33
|
+
override fun onCreate() {
|
|
34
|
+
super.onCreate()
|
|
35
|
+
|
|
36
|
+
// Define your products
|
|
37
|
+
val purchaseItems = listOf(
|
|
38
|
+
// Consumables
|
|
39
|
+
PurchaseItem("coins_100", TYPE_IAP.PURCHASE, PurchaseCategory.CONSUMABLE),
|
|
40
|
+
PurchaseItem("coins_500", TYPE_IAP.PURCHASE, PurchaseCategory.CONSUMABLE),
|
|
41
|
+
|
|
42
|
+
// One-time purchases
|
|
43
|
+
PurchaseItem("remove_ads", TYPE_IAP.PURCHASE, PurchaseCategory.REMOVE_ADS),
|
|
44
|
+
PurchaseItem("lifetime_premium", TYPE_IAP.PURCHASE, PurchaseCategory.LIFETIME_PREMIUM),
|
|
45
|
+
PurchaseItem("unlock_levels", TYPE_IAP.PURCHASE, PurchaseCategory.FEATURE_UNLOCK),
|
|
46
|
+
|
|
47
|
+
// Subscriptions (with optional trial offer ID)
|
|
48
|
+
PurchaseItem("premium_monthly", "free_trial_7d", TYPE_IAP.SUBSCRIPTION),
|
|
49
|
+
PurchaseItem("premium_yearly", "free_trial_14d", TYPE_IAP.SUBSCRIPTION)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
// Initialize billing
|
|
53
|
+
AppPurchase.getInstance().initBilling(this, purchaseItems)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Product Configuration
|
|
61
|
+
|
|
62
|
+
### Purchase Categories
|
|
63
|
+
|
|
64
|
+
| Category | Description | Disables Ads | Can Re-purchase |
|
|
65
|
+
|----------|-------------|--------------|-----------------|
|
|
66
|
+
| `CONSUMABLE` | Coins, gems, credits | No | Yes (after consume) |
|
|
67
|
+
| `FEATURE_UNLOCK` | Level packs, themes | No | No |
|
|
68
|
+
| `LIFETIME_PREMIUM` | Permanent premium | Yes | No |
|
|
69
|
+
| `REMOVE_ADS` | Ad removal only | Yes | No |
|
|
70
|
+
|
|
71
|
+
### Creating PurchaseItems
|
|
72
|
+
|
|
73
|
+
```kotlin
|
|
74
|
+
// Consumable product
|
|
75
|
+
PurchaseItem("coins_100", TYPE_IAP.PURCHASE, PurchaseCategory.CONSUMABLE)
|
|
76
|
+
|
|
77
|
+
// Remove ads
|
|
78
|
+
PurchaseItem("remove_ads", TYPE_IAP.PURCHASE, PurchaseCategory.REMOVE_ADS)
|
|
79
|
+
|
|
80
|
+
// Lifetime premium
|
|
81
|
+
PurchaseItem("lifetime_premium", TYPE_IAP.PURCHASE, PurchaseCategory.LIFETIME_PREMIUM)
|
|
82
|
+
|
|
83
|
+
// Feature unlock (doesn't disable ads)
|
|
84
|
+
PurchaseItem("unlock_themes", TYPE_IAP.PURCHASE, PurchaseCategory.FEATURE_UNLOCK)
|
|
85
|
+
|
|
86
|
+
// Subscription with free trial offer
|
|
87
|
+
PurchaseItem("premium_monthly", "free_trial_offer", TYPE_IAP.SUBSCRIPTION)
|
|
88
|
+
|
|
89
|
+
// Subscription without trial
|
|
90
|
+
PurchaseItem("premium_yearly", null, TYPE_IAP.SUBSCRIPTION)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Making Purchases
|
|
96
|
+
|
|
97
|
+
### In-App Products
|
|
98
|
+
|
|
99
|
+
```kotlin
|
|
100
|
+
// Set purchase listener
|
|
101
|
+
AppPurchase.getInstance().setPurchaseListener(object : PurchaseListener {
|
|
102
|
+
override fun onProductPurchased(orderId: String?, originalJson: String?) {
|
|
103
|
+
// Purchase successful
|
|
104
|
+
Log.d("Billing", "Purchased! Order: $orderId")
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
override fun displayErrorMessage(errorMessage: String?) {
|
|
108
|
+
Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
override fun onUserCancelBilling() {
|
|
112
|
+
Log.d("Billing", "User cancelled")
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// Make purchase
|
|
117
|
+
val result = AppPurchase.getInstance().purchase(activity, "remove_ads")
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Subscriptions
|
|
121
|
+
|
|
122
|
+
```kotlin
|
|
123
|
+
// Subscribe
|
|
124
|
+
val result = AppPurchase.getInstance().subscribe(activity, "premium_monthly")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Get Product Prices
|
|
128
|
+
|
|
129
|
+
```kotlin
|
|
130
|
+
// Get formatted price
|
|
131
|
+
val price = AppPurchase.getInstance().getPrice("remove_ads") // "$2.99"
|
|
132
|
+
val subPrice = AppPurchase.getInstance().getPriceSub("premium_monthly") // "$9.99/month"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Consumable Products
|
|
138
|
+
|
|
139
|
+
Consumable products require manual consumption after granting items to the user.
|
|
140
|
+
|
|
141
|
+
### Flow
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
Purchase → Acknowledge → onNewPurchase() → Grant Items → consumePurchase() → onPurchaseConsumed()
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Implementation
|
|
148
|
+
|
|
149
|
+
```kotlin
|
|
150
|
+
AppPurchase.getInstance().setPurchaseHistoryListener(object : PurchaseHistoryListener {
|
|
151
|
+
override fun onNewPurchase(productId: String, purchase: PurchaseResult) {
|
|
152
|
+
when (productId) {
|
|
153
|
+
"coins_100" -> {
|
|
154
|
+
// Grant coins to user
|
|
155
|
+
userBalance += 100 * purchase.quantity
|
|
156
|
+
saveUserBalance()
|
|
157
|
+
|
|
158
|
+
// Now consume so they can buy again
|
|
159
|
+
AppPurchase.getInstance().consumePurchase(productId)
|
|
160
|
+
}
|
|
161
|
+
"coins_500" -> {
|
|
162
|
+
userBalance += 500 * purchase.quantity
|
|
163
|
+
saveUserBalance()
|
|
164
|
+
AppPurchase.getInstance().consumePurchase(productId)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
override fun onPurchaseConsumed(productId: String, purchase: PurchaseResult) {
|
|
170
|
+
// Optional: Track in analytics
|
|
171
|
+
analytics.logEvent("purchase_consumed", mapOf(
|
|
172
|
+
"product_id" to productId,
|
|
173
|
+
"order_id" to purchase.orderId,
|
|
174
|
+
"quantity" to purchase.quantity
|
|
175
|
+
))
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Why Manual Consumption?
|
|
181
|
+
|
|
182
|
+
1. **Server verification**: Verify purchase on your server before granting items
|
|
183
|
+
2. **Error handling**: If granting fails, you can retry without losing the purchase
|
|
184
|
+
3. **Analytics**: Track exactly when items are granted vs purchased
|
|
185
|
+
4. **Multi-quantity**: Handle purchases with quantity > 1 correctly
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Subscriptions
|
|
190
|
+
|
|
191
|
+
### Checking Subscription Status
|
|
192
|
+
|
|
193
|
+
```kotlin
|
|
194
|
+
// Check if any subscription is active
|
|
195
|
+
if (AppPurchase.getInstance().isSubscribed()) {
|
|
196
|
+
showPremiumContent()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Check specific subscription
|
|
200
|
+
if (AppPurchase.getInstance().isSubscribed("premium_yearly")) {
|
|
201
|
+
showYearlyBenefits()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Get subscription state
|
|
205
|
+
val state = AppPurchase.getInstance().getSubscriptionState("premium_monthly")
|
|
206
|
+
when (state) {
|
|
207
|
+
SubscriptionState.ACTIVE -> {
|
|
208
|
+
// Active and will renew
|
|
209
|
+
showPremiumUI()
|
|
210
|
+
}
|
|
211
|
+
SubscriptionState.CANCELLED -> {
|
|
212
|
+
// Cancelled but still has access until expiration
|
|
213
|
+
showRenewalPrompt()
|
|
214
|
+
}
|
|
215
|
+
SubscriptionState.EXPIRED -> {
|
|
216
|
+
// No longer subscribed
|
|
217
|
+
showSubscribeButton()
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Subscription State Details
|
|
223
|
+
|
|
224
|
+
| State | Has Access | Will Renew | Detection |
|
|
225
|
+
|-------|------------|------------|-----------|
|
|
226
|
+
| ACTIVE | Yes | Yes | Client-side |
|
|
227
|
+
| CANCELLED | Yes | No | Client-side |
|
|
228
|
+
| GRACE_PERIOD | Yes | Pending | Server-side only |
|
|
229
|
+
| ON_HOLD | No | Pending | Server-side only |
|
|
230
|
+
| PAUSED | No | When resumed | Server-side only |
|
|
231
|
+
| EXPIRED | No | No | Client-side |
|
|
232
|
+
|
|
233
|
+
### Getting Subscription Details
|
|
234
|
+
|
|
235
|
+
```kotlin
|
|
236
|
+
val subscription = AppPurchase.getInstance().getSubscription("premium_monthly")
|
|
237
|
+
if (subscription != null) {
|
|
238
|
+
println("Order ID: ${subscription.orderId}")
|
|
239
|
+
println("Purchase Time: ${subscription.getPurchaseTimeFormatted()}")
|
|
240
|
+
println("Will Renew: ${subscription.willSubscriptionRenew()}")
|
|
241
|
+
println("State: ${subscription.getSubscriptionStateString()}")
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Get all active subscriptions
|
|
245
|
+
val allSubs = AppPurchase.getInstance().getActiveSubscriptions()
|
|
246
|
+
for (sub in allSubs) {
|
|
247
|
+
println("${sub.getFirstProductId()}: ${sub.getSubscriptionStateString()}")
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Subscription Upgrade/Downgrade
|
|
254
|
+
|
|
255
|
+
### Simple Methods
|
|
256
|
+
|
|
257
|
+
```kotlin
|
|
258
|
+
// Upgrade to higher tier (charges price difference immediately)
|
|
259
|
+
AppPurchase.getInstance().upgradeSubscription(activity, "premium_yearly")
|
|
260
|
+
|
|
261
|
+
// Downgrade to lower tier (takes effect at next renewal)
|
|
262
|
+
AppPurchase.getInstance().downgradeSubscription(activity, "premium_basic")
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### Full Control
|
|
266
|
+
|
|
267
|
+
```kotlin
|
|
268
|
+
// Specify source and target subscriptions with proration mode
|
|
269
|
+
AppPurchase.getInstance().changeSubscription(
|
|
270
|
+
activity,
|
|
271
|
+
"premium_monthly", // Current subscription ID
|
|
272
|
+
"premium_yearly", // New subscription ID
|
|
273
|
+
SubscriptionReplacementMode.CHARGE_PRORATED_PRICE
|
|
274
|
+
)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Replacement Modes
|
|
278
|
+
|
|
279
|
+
| Mode | Best For | Effect |
|
|
280
|
+
|------|----------|--------|
|
|
281
|
+
| `CHARGE_PRORATED_PRICE` | Upgrades | User pays difference immediately |
|
|
282
|
+
| `DEFERRED` | Downgrades | Change at next renewal date |
|
|
283
|
+
| `WITH_TIME_PRORATION` | Either | Immediate, remaining value credited |
|
|
284
|
+
| `CHARGE_FULL_PRICE` | Either | Immediate, full price charged |
|
|
285
|
+
| `WITHOUT_PRORATION` | Either | Immediate, no price change |
|
|
286
|
+
|
|
287
|
+
### Example: Subscription Tier UI
|
|
288
|
+
|
|
289
|
+
```kotlin
|
|
290
|
+
class SubscriptionActivity : AppCompatActivity() {
|
|
291
|
+
|
|
292
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
293
|
+
super.onCreate(savedInstanceState)
|
|
294
|
+
|
|
295
|
+
val currentSub = AppPurchase.getInstance().getActiveSubscriptions().firstOrNull()
|
|
296
|
+
|
|
297
|
+
if (currentSub == null) {
|
|
298
|
+
// No subscription - show all options
|
|
299
|
+
showAllSubscriptionOptions()
|
|
300
|
+
} else {
|
|
301
|
+
// Has subscription - show upgrade/downgrade options
|
|
302
|
+
val currentId = currentSub.getFirstProductId()
|
|
303
|
+
when (currentId) {
|
|
304
|
+
"premium_basic" -> showUpgradeOptions()
|
|
305
|
+
"premium_yearly" -> showDowngradeOptions()
|
|
306
|
+
else -> showAllOptions()
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
fun onUpgradeClick() {
|
|
312
|
+
AppPurchase.getInstance().changeSubscription(
|
|
313
|
+
this,
|
|
314
|
+
"premium_basic",
|
|
315
|
+
"premium_yearly",
|
|
316
|
+
SubscriptionReplacementMode.CHARGE_PRORATED_PRICE
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
fun onDowngradeClick() {
|
|
321
|
+
AppPurchase.getInstance().changeSubscription(
|
|
322
|
+
this,
|
|
323
|
+
"premium_yearly",
|
|
324
|
+
"premium_basic",
|
|
325
|
+
SubscriptionReplacementMode.DEFERRED
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
## Purchase State Checking
|
|
334
|
+
|
|
335
|
+
### Check If Ads Should Be Disabled
|
|
336
|
+
|
|
337
|
+
```kotlin
|
|
338
|
+
// Returns true for subscriptions, lifetime premium, or remove ads
|
|
339
|
+
if (AppPurchase.getInstance().isPurchased()) {
|
|
340
|
+
hideAds()
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Same as isPurchased()
|
|
344
|
+
if (AppPurchase.getInstance().shouldDisableAds()) {
|
|
345
|
+
hideAds()
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Get Active Purchase Type
|
|
350
|
+
|
|
351
|
+
```kotlin
|
|
352
|
+
when (AppPurchase.getInstance().getActivePurchaseType()) {
|
|
353
|
+
PurchaseType.SUBSCRIPTION -> {
|
|
354
|
+
showBadge("Subscriber")
|
|
355
|
+
}
|
|
356
|
+
PurchaseType.LIFETIME_PREMIUM -> {
|
|
357
|
+
showBadge("Lifetime Member")
|
|
358
|
+
}
|
|
359
|
+
PurchaseType.REMOVE_ADS -> {
|
|
360
|
+
// Just ad removal, no premium features
|
|
361
|
+
}
|
|
362
|
+
PurchaseType.NONE -> {
|
|
363
|
+
showPurchasePrompt()
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### Check Specific Products
|
|
369
|
+
|
|
370
|
+
```kotlin
|
|
371
|
+
// Check if product is owned
|
|
372
|
+
if (AppPurchase.getInstance().isProductOwned("unlock_themes")) {
|
|
373
|
+
enableThemes()
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Check lifetime purchases
|
|
377
|
+
if (AppPurchase.getInstance().hasLifetimePremium()) {
|
|
378
|
+
enableAllFeatures()
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (AppPurchase.getInstance().hasRemoveAdsPurchase()) {
|
|
382
|
+
hideAds()
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
## Purchase History Tracking
|
|
389
|
+
|
|
390
|
+
### Setting Up the Listener
|
|
391
|
+
|
|
392
|
+
```kotlin
|
|
393
|
+
AppPurchase.getInstance().setPurchaseHistoryListener(object : PurchaseHistoryListener {
|
|
394
|
+
override fun onNewPurchase(productId: String, purchase: PurchaseResult) {
|
|
395
|
+
// Called when any purchase is acknowledged
|
|
396
|
+
trackPurchase(productId, purchase)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
override fun onPurchaseConsumed(productId: String, purchase: PurchaseResult) {
|
|
400
|
+
// Called when consumePurchase() completes
|
|
401
|
+
trackConsumption(productId, purchase)
|
|
402
|
+
}
|
|
403
|
+
})
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### PurchaseResult Data Available
|
|
407
|
+
|
|
408
|
+
```kotlin
|
|
409
|
+
override fun onNewPurchase(productId: String, purchase: PurchaseResult) {
|
|
410
|
+
// Basic info
|
|
411
|
+
val orderId = purchase.orderId
|
|
412
|
+
val quantity = purchase.quantity
|
|
413
|
+
val purchaseTime = purchase.purchaseTime
|
|
414
|
+
|
|
415
|
+
// Formatted helpers
|
|
416
|
+
val dateStr = purchase.getPurchaseTimeFormatted("yyyy-MM-dd")
|
|
417
|
+
val stateStr = purchase.getPurchaseStateString()
|
|
418
|
+
|
|
419
|
+
// Verification data (for server)
|
|
420
|
+
val json = purchase.originalJson
|
|
421
|
+
val signature = purchase.signature
|
|
422
|
+
|
|
423
|
+
// Account identifiers
|
|
424
|
+
val accountId = purchase.obfuscatedAccountId
|
|
425
|
+
val profileId = purchase.obfuscatedProfileId
|
|
426
|
+
|
|
427
|
+
// Product type
|
|
428
|
+
val isSubscription = purchase.isSubscription()
|
|
429
|
+
val isInApp = purchase.isInApp()
|
|
430
|
+
}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### Persisting Purchase History
|
|
434
|
+
|
|
435
|
+
```kotlin
|
|
436
|
+
class MyPurchaseTracker : PurchaseHistoryListener {
|
|
437
|
+
|
|
438
|
+
private val prefs: SharedPreferences
|
|
439
|
+
|
|
440
|
+
override fun onNewPurchase(productId: String, purchase: PurchaseResult) {
|
|
441
|
+
val history = getPurchaseHistory(productId).toMutableList()
|
|
442
|
+
history.add(PurchaseRecord(
|
|
443
|
+
orderId = purchase.orderId ?: "",
|
|
444
|
+
quantity = purchase.quantity,
|
|
445
|
+
purchaseTime = purchase.purchaseTime,
|
|
446
|
+
consumed = false
|
|
447
|
+
))
|
|
448
|
+
savePurchaseHistory(productId, history)
|
|
449
|
+
|
|
450
|
+
// Track total purchases
|
|
451
|
+
val totalPurchases = prefs.getInt("total_$productId", 0)
|
|
452
|
+
prefs.edit().putInt("total_$productId", totalPurchases + purchase.quantity).apply()
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
override fun onPurchaseConsumed(productId: String, purchase: PurchaseResult) {
|
|
456
|
+
val history = getPurchaseHistory(productId).toMutableList()
|
|
457
|
+
history.find { it.orderId == purchase.orderId }?.consumed = true
|
|
458
|
+
savePurchaseHistory(productId, history)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
fun getTotalPurchaseCount(productId: String): Int {
|
|
462
|
+
return prefs.getInt("total_$productId", 0)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
---
|
|
468
|
+
|
|
469
|
+
## Server-Side Verification
|
|
470
|
+
|
|
471
|
+
### Get Verification Data
|
|
472
|
+
|
|
473
|
+
```kotlin
|
|
474
|
+
AppPurchase.getInstance().setPurchaseHistoryListener(object : PurchaseHistoryListener {
|
|
475
|
+
override fun onNewPurchase(productId: String, purchase: PurchaseResult) {
|
|
476
|
+
if (purchase.hasVerificationData()) {
|
|
477
|
+
// Send to your server
|
|
478
|
+
verifyOnServer(
|
|
479
|
+
productId = productId,
|
|
480
|
+
purchaseToken = purchase.purchaseToken,
|
|
481
|
+
originalJson = purchase.originalJson!!,
|
|
482
|
+
signature = purchase.signature!!
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
})
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
### Server Verification Flow
|
|
490
|
+
|
|
491
|
+
```kotlin
|
|
492
|
+
suspend fun verifyOnServer(
|
|
493
|
+
productId: String,
|
|
494
|
+
purchaseToken: String,
|
|
495
|
+
originalJson: String,
|
|
496
|
+
signature: String
|
|
497
|
+
) {
|
|
498
|
+
val result = api.verifyPurchase(
|
|
499
|
+
productId = productId,
|
|
500
|
+
token = purchaseToken,
|
|
501
|
+
receipt = originalJson,
|
|
502
|
+
signature = signature
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
if (result.isValid) {
|
|
506
|
+
// Grant entitlement
|
|
507
|
+
grantProduct(productId)
|
|
508
|
+
|
|
509
|
+
// Consume if needed
|
|
510
|
+
if (isConsumable(productId)) {
|
|
511
|
+
AppPurchase.getInstance().consumePurchase(productId)
|
|
512
|
+
}
|
|
513
|
+
} else {
|
|
514
|
+
// Handle invalid purchase
|
|
515
|
+
showError("Purchase verification failed")
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
---
|
|
521
|
+
|
|
522
|
+
## Subscription Expiry Verification
|
|
523
|
+
|
|
524
|
+
Google Play Billing Library does NOT provide subscription expiry dates client-side. Use server-side verification to get accurate expiry information.
|
|
525
|
+
|
|
526
|
+
### 1. Set Up Verification Callback
|
|
527
|
+
|
|
528
|
+
```kotlin
|
|
529
|
+
// In Application.onCreate()
|
|
530
|
+
AppPurchase.getInstance().setSubscriptionVerificationCallback { packageName, subscriptionId, purchaseToken, listener ->
|
|
531
|
+
// Call your backend API
|
|
532
|
+
yourApi.verifySubscription(packageName, subscriptionId, purchaseToken,
|
|
533
|
+
onSuccess = { expiryTimeMillis ->
|
|
534
|
+
val details = SubscriptionVerificationCallback.SubscriptionDetails.Builder()
|
|
535
|
+
.setExpiryTimeMillis(expiryTimeMillis)
|
|
536
|
+
.setAutoRenewing(true)
|
|
537
|
+
.build()
|
|
538
|
+
listener.onVerified(details)
|
|
539
|
+
},
|
|
540
|
+
onError = { error ->
|
|
541
|
+
listener.onVerificationFailed(error)
|
|
542
|
+
}
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
### 2. Verify Subscription
|
|
548
|
+
|
|
549
|
+
```kotlin
|
|
550
|
+
AppPurchase.getInstance().verifySubscription("premium_monthly",
|
|
551
|
+
object : AppPurchase.SubscriptionVerificationListener {
|
|
552
|
+
override fun onVerified(subscription: PurchaseResult) {
|
|
553
|
+
// Expiry data now available
|
|
554
|
+
val expiryDate = subscription.getExpiryTimeFormatted("dd MMM yyyy")
|
|
555
|
+
val daysLeft = subscription.getRemainingDays()
|
|
556
|
+
val isExpired = subscription.isExpired()
|
|
557
|
+
|
|
558
|
+
Log.d("Subscription", "Expires: $expiryDate, Days left: $daysLeft")
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
override fun onVerificationFailed(errorMessage: String?) {
|
|
562
|
+
Log.e("Subscription", "Failed: $errorMessage")
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
)
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
### 3. Access Expiry Data
|
|
569
|
+
|
|
570
|
+
```kotlin
|
|
571
|
+
// After verification, data is cached
|
|
572
|
+
val expiryMillis = AppPurchase.getInstance().getSubscriptionExpiryTime("premium_monthly")
|
|
573
|
+
val expiryFormatted = AppPurchase.getInstance().getSubscriptionExpiryTimeFormatted("premium_monthly")
|
|
574
|
+
val daysLeft = AppPurchase.getInstance().getSubscriptionRemainingDays("premium_monthly")
|
|
575
|
+
val isExpired = AppPurchase.getInstance().isSubscriptionExpired("premium_monthly")
|
|
576
|
+
|
|
577
|
+
// Or use PurchaseResult directly
|
|
578
|
+
val subscription = AppPurchase.getInstance().getSubscription("premium_monthly")
|
|
579
|
+
if (subscription?.isExpiryVerified() == true) {
|
|
580
|
+
val expiryDate = subscription.getExpiryDate()
|
|
581
|
+
val remaining = subscription.getRemainingTime()
|
|
582
|
+
}
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
### Backend API
|
|
586
|
+
|
|
587
|
+
Your server should call Google Play Developer API:
|
|
588
|
+
|
|
589
|
+
```
|
|
590
|
+
GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{purchaseToken}
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
Response includes `expiryTime`, `subscriptionState`, and more
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
---
|
|
597
|
+
|
|
598
|
+
## Best Practices
|
|
599
|
+
|
|
600
|
+
### 1. Always Handle Edge Cases
|
|
601
|
+
|
|
602
|
+
```kotlin
|
|
603
|
+
AppPurchase.getInstance().setPurchaseListener(object : PurchaseListener {
|
|
604
|
+
override fun onProductPurchased(orderId: String?, originalJson: String?) {
|
|
605
|
+
// Success
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
override fun displayErrorMessage(errorMessage: String?) {
|
|
609
|
+
when {
|
|
610
|
+
errorMessage?.contains("ITEM_ALREADY_OWNED") == true -> {
|
|
611
|
+
// Restore purchase
|
|
612
|
+
AppPurchase.getInstance().verifyPurchased(true)
|
|
613
|
+
}
|
|
614
|
+
errorMessage?.contains("SERVICE_UNAVAILABLE") == true -> {
|
|
615
|
+
// Retry later
|
|
616
|
+
showRetryDialog()
|
|
617
|
+
}
|
|
618
|
+
else -> {
|
|
619
|
+
showError(errorMessage)
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
override fun onUserCancelBilling() {
|
|
625
|
+
// User cancelled - don't show error
|
|
626
|
+
}
|
|
627
|
+
})
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
### 2. Restore Purchases on App Start
|
|
631
|
+
|
|
632
|
+
```kotlin
|
|
633
|
+
class MainActivity : AppCompatActivity() {
|
|
634
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
635
|
+
super.onCreate(savedInstanceState)
|
|
636
|
+
|
|
637
|
+
// Verify purchases to restore state
|
|
638
|
+
AppPurchase.getInstance().verifyPurchased(true)
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
### 3. Use Correct Categories
|
|
644
|
+
|
|
645
|
+
```kotlin
|
|
646
|
+
// DON'T: Use wrong category
|
|
647
|
+
PurchaseItem("coins", TYPE_IAP.PURCHASE) // Defaults to LIFETIME_PREMIUM!
|
|
648
|
+
|
|
649
|
+
// DO: Explicitly set category
|
|
650
|
+
PurchaseItem("coins", TYPE_IAP.PURCHASE, PurchaseCategory.CONSUMABLE)
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
### 4. Handle Pending Purchases
|
|
654
|
+
|
|
655
|
+
```kotlin
|
|
656
|
+
override fun onNewPurchase(productId: String, purchase: PurchaseResult) {
|
|
657
|
+
if (purchase.isPending()) {
|
|
658
|
+
// Don't grant yet - payment is pending
|
|
659
|
+
showPendingMessage("Your purchase is being processed")
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (purchase.isPurchased()) {
|
|
664
|
+
// Safe to grant
|
|
665
|
+
grantProduct(productId)
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
### 5. Test with Google Play Console
|
|
671
|
+
|
|
672
|
+
Use test accounts and test products:
|
|
673
|
+
- License testers can make test purchases
|
|
674
|
+
- Use static response product IDs for specific scenarios
|
|
675
|
+
- Test all proration modes for subscriptions
|
|
676
|
+
|
|
677
|
+
---
|
|
678
|
+
|
|
679
|
+
## Troubleshooting
|
|
680
|
+
|
|
681
|
+
### "You already own this item"
|
|
682
|
+
|
|
683
|
+
The product wasn't consumed. Call `consumePurchase(productId)` after granting items.
|
|
684
|
+
|
|
685
|
+
### Subscription not showing as active
|
|
686
|
+
|
|
687
|
+
Call `verifyPurchased(true)` to refresh purchase state from Google.
|
|
688
|
+
|
|
689
|
+
### Purchase callback not firing
|
|
690
|
+
|
|
691
|
+
Ensure `setPurchaseListener()` is called before `purchase()`.
|
|
692
|
+
|
|
693
|
+
### Price not available
|
|
694
|
+
|
|
695
|
+
Products may not be loaded yet. Check `getPrice()` returns non-empty string before showing UI.
|
|
696
|
+
|
|
697
|
+
```kotlin
|
|
698
|
+
val price = AppPurchase.getInstance().getPrice("product_id")
|
|
699
|
+
if (price.isNotEmpty()) {
|
|
700
|
+
button.text = "Buy for $price"
|
|
701
|
+
} else {
|
|
702
|
+
button.text = "Buy"
|
|
703
|
+
}
|
|
704
|
+
```
|