Skip to content

Commit 86f0f20

Browse files
feat: Implement purchase handling and status updates
This commit introduces a robust purchase handling mechanism for in-app donations. It ensures that user entitlements are correctly processed, acknowledged, and persisted. Key changes include: - **Purchase Handling:** The `DefaultSupportRepository` now processes purchase updates from the Google Play Billing library, acknowledging new purchases and handling revoked or refunded transactions. - **Entitlement Persistence:** Granted product entitlements are now saved to `SharedPreferences` to maintain state across app sessions. - **Status Updates:** The `SupportActivity` and `SupportViewModel` have been updated to observe purchase status changes (granted, restored, revoked) and display a corresponding message to the user via a `Toast`. - **UseCases and DI:** New use cases (`RefreshPurchasesUseCase`, `SetPurchaseStatusListenerUseCase`) and their tests have been added and integrated into the Dagger dependency graph to manage purchase status listening and refreshing. - **Localized Strings:** New strings for purchase status notifications (`support_purchase_thank_you`, `support_purchase_restored`, `support_purchase_revoked`) have been added and translated across multiple locales. - **Lifecycle Awareness:** The app now refreshes purchases in `onResume` of the `SupportActivity` to ensure the UI reflects the latest entitlement status.
1 parent 262253d commit 86f0f20

File tree

36 files changed

+411
-8
lines changed

36 files changed

+411
-8
lines changed

app/src/main/java/com/d4rk/androidtutorials/java/data/repository/DefaultSupportRepository.java

Lines changed: 142 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,62 @@
11
package com.d4rk.androidtutorials.java.data.repository;
22

33
import android.content.Context;
4+
import android.content.SharedPreferences;
45

56
import androidx.annotation.NonNull;
67

8+
import com.android.billingclient.api.AcknowledgePurchaseParams;
79
import com.android.billingclient.api.BillingClient;
810
import com.android.billingclient.api.BillingClientStateListener;
911
import com.android.billingclient.api.BillingFlowParams;
1012
import com.android.billingclient.api.BillingResult;
1113
import com.android.billingclient.api.PendingPurchasesParams;
1214
import com.android.billingclient.api.ProductDetails;
15+
import com.android.billingclient.api.Purchase;
1316
import com.android.billingclient.api.QueryProductDetailsParams;
17+
import com.android.billingclient.api.QueryPurchasesParams;
1418
import com.d4rk.androidtutorials.java.ads.AdUtils;
1519
import com.google.android.gms.ads.AdRequest;
1620

21+
import org.json.JSONException;
22+
import org.json.JSONObject;
23+
1724
import java.util.ArrayList;
1825
import java.util.Collections;
1926
import java.util.HashMap;
27+
import java.util.HashSet;
2028
import java.util.List;
2129
import java.util.Map;
30+
import java.util.Set;
2231

2332
public class DefaultSupportRepository implements SupportRepository {
2433

34+
private static final String PREFS_NAME = "support_billing";
35+
private static final String KEY_GRANTED_PRODUCTS = "granted_products";
36+
2537
private final Context context;
2638
private final Map<String, ProductDetails> productDetailsMap = new HashMap<>();
39+
private final SharedPreferences billingPrefs;
40+
private final Set<String> grantedEntitlements;
2741
private BillingClient billingClient;
42+
private PurchaseStatusListener purchaseStatusListener;
2843

2944
public DefaultSupportRepository(Context context) {
3045
this.context = context.getApplicationContext();
46+
this.billingPrefs = this.context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
47+
Set<String> granted = billingPrefs.getStringSet(KEY_GRANTED_PRODUCTS, Collections.emptySet());
48+
this.grantedEntitlements = (granted != null) ? new HashSet<>(granted) : new HashSet<>();
3149
}
3250

3351
/**
3452
* Initialize the billing client and start the connection.
3553
*
3654
* @param onConnected Callback once the billing service is connected.
3755
*/
56+
@Override
3857
public void initBillingClient(Runnable onConnected) {
3958
billingClient = BillingClient.newBuilder(context)
40-
.setListener((billingResult, purchases) -> {
41-
// To be implemented in a later release
42-
})
59+
.setListener(this::handlePurchaseUpdates)
4360
.enablePendingPurchases(
4461
PendingPurchasesParams.newBuilder()
4562
.enableOneTimeProducts()
@@ -53,6 +70,7 @@ public void initBillingClient(Runnable onConnected) {
5370
public void onBillingSetupFinished(@NonNull BillingResult billingResult) {
5471
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
5572
// The BillingClient is ready. You can query purchases here.
73+
refreshPurchases();
5674
if (onConnected != null) {
5775
onConnected.run();
5876
}
@@ -72,6 +90,7 @@ public void onBillingServiceDisconnected() {
7290
* Query your product details for in-app items.
7391
* Typically called after billing client is connected.
7492
*/
93+
@Override
7594
public void queryProductDetails(List<String> productIds, OnProductDetailsListener listener) {
7695
if (billingClient == null || !billingClient.isReady()) {
7796
return;
@@ -114,10 +133,10 @@ public void queryProductDetails(List<String> productIds, OnProductDetailsListene
114133
});
115134
}
116135

117-
118136
/**
119137
* Launch the billing flow for a particular product.
120138
*/
139+
@Override
121140
public BillingFlowLauncher initiatePurchase(String productId) {
122141
ProductDetails details = productDetailsMap.get(productId);
123142
if (details != null && billingClient != null) {
@@ -146,14 +165,131 @@ public BillingFlowLauncher initiatePurchase(String productId) {
146165
return null;
147166
}
148167

149-
150168
/**
151169
* Initialize Mobile Ads (usually done once in your app, but
152170
* can be done here if needed for the support screen).
153171
*/
172+
@Override
154173
public AdRequest initMobileAds() {
155174
AdUtils.initialize(context);
156175
return new AdRequest.Builder().build();
157176
}
158177

159-
}
178+
@Override
179+
public void setPurchaseStatusListener(PurchaseStatusListener listener) {
180+
this.purchaseStatusListener = listener;
181+
if (listener != null) {
182+
for (String productId : grantedEntitlements) {
183+
listener.onPurchaseAcknowledged(productId, false);
184+
}
185+
}
186+
}
187+
188+
@Override
189+
public void refreshPurchases() {
190+
if (billingClient == null || !billingClient.isReady()) {
191+
return;
192+
}
193+
194+
QueryPurchasesParams params = QueryPurchasesParams.newBuilder()
195+
.setProductType(BillingClient.ProductType.INAPP)
196+
.build();
197+
198+
billingClient.queryPurchasesAsync(params, (billingResult, purchasesList) -> {
199+
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
200+
handlePurchaseUpdates(billingResult, purchasesList);
201+
}
202+
});
203+
}
204+
205+
private void handlePurchaseUpdates(BillingResult billingResult, List<Purchase> purchases) {
206+
if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK || purchases == null) {
207+
return;
208+
}
209+
210+
Set<String> activeEntitlements = new HashSet<>();
211+
boolean shouldPersist = false;
212+
213+
for (Purchase purchase : purchases) {
214+
List<String> productIds = purchase.getProducts();
215+
if (productIds.isEmpty()) {
216+
continue;
217+
}
218+
219+
int rawState = getRawPurchaseState(purchase);
220+
if (rawState == Purchase.PurchaseState.PURCHASED) {
221+
if (!purchase.isAcknowledged()) {
222+
acknowledgePurchase(purchase);
223+
}
224+
for (String productId : productIds) {
225+
activeEntitlements.add(productId);
226+
if (grantedEntitlements.add(productId)) {
227+
shouldPersist = true;
228+
notifyPurchaseAcknowledged(productId, true);
229+
}
230+
}
231+
} else {
232+
for (String productId : productIds) {
233+
if (grantedEntitlements.remove(productId)) {
234+
shouldPersist = true;
235+
notifyPurchaseRevoked(productId);
236+
}
237+
}
238+
}
239+
}
240+
241+
Set<String> revokedProducts = new HashSet<>(grantedEntitlements);
242+
revokedProducts.removeAll(activeEntitlements);
243+
if (!revokedProducts.isEmpty()) {
244+
shouldPersist = true;
245+
}
246+
for (String productId : revokedProducts) {
247+
if (grantedEntitlements.remove(productId)) {
248+
notifyPurchaseRevoked(productId);
249+
}
250+
}
251+
252+
if (shouldPersist) {
253+
persistGrantedEntitlements();
254+
}
255+
}
256+
257+
private void acknowledgePurchase(Purchase purchase) {
258+
if (billingClient == null) {
259+
return;
260+
}
261+
AcknowledgePurchaseParams params = AcknowledgePurchaseParams.newBuilder()
262+
.setPurchaseToken(purchase.getPurchaseToken())
263+
.build();
264+
billingClient.acknowledgePurchase(params, billingResult -> {
265+
// No-op: handled by entitlement updates once Google confirms the acknowledgement.
266+
});
267+
}
268+
269+
private void notifyPurchaseAcknowledged(String productId, boolean isNewPurchase) {
270+
if (purchaseStatusListener != null) {
271+
purchaseStatusListener.onPurchaseAcknowledged(productId, isNewPurchase);
272+
}
273+
}
274+
275+
private void notifyPurchaseRevoked(String productId) {
276+
if (purchaseStatusListener != null) {
277+
purchaseStatusListener.onPurchaseRevoked(productId);
278+
}
279+
}
280+
281+
private int getRawPurchaseState(Purchase purchase) {
282+
try {
283+
JSONObject jsonObject = new JSONObject(purchase.getOriginalJson());
284+
return jsonObject.optInt("purchaseState", Purchase.PurchaseState.UNSPECIFIED_STATE);
285+
} catch (JSONException exception) {
286+
return purchase.getPurchaseState();
287+
}
288+
}
289+
290+
private void persistGrantedEntitlements() {
291+
billingPrefs.edit()
292+
.putStringSet(KEY_GRANTED_PRODUCTS, new HashSet<>(grantedEntitlements))
293+
.apply();
294+
}
295+
}

app/src/main/java/com/d4rk/androidtutorials/java/data/repository/SupportRepository.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,21 @@ public interface SupportRepository {
1616

1717
AdRequest initMobileAds();
1818

19+
void setPurchaseStatusListener(PurchaseStatusListener listener);
20+
21+
void refreshPurchases();
22+
1923
interface OnProductDetailsListener {
2024
void onProductDetailsRetrieved(List<ProductDetails> productDetailsList);
2125
}
2226

2327
interface BillingFlowLauncher {
2428
void launch(Activity activity);
2529
}
30+
31+
interface PurchaseStatusListener {
32+
void onPurchaseAcknowledged(String productId, boolean isNewPurchase);
33+
34+
void onPurchaseRevoked(String productId);
35+
}
2636
}

app/src/main/java/com/d4rk/androidtutorials/java/di/AppModule.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
import com.d4rk.androidtutorials.java.domain.support.InitMobileAdsUseCase;
4444
import com.d4rk.androidtutorials.java.domain.support.InitiatePurchaseUseCase;
4545
import com.d4rk.androidtutorials.java.domain.support.QueryProductDetailsUseCase;
46+
import com.d4rk.androidtutorials.java.domain.support.RefreshPurchasesUseCase;
47+
import com.d4rk.androidtutorials.java.domain.support.SetPurchaseStatusListenerUseCase;
4648
import com.d4rk.androidtutorials.java.ui.screens.about.repository.AboutRepository;
4749
import com.d4rk.androidtutorials.java.ui.screens.help.repository.HelpRepository;
4850
import com.d4rk.androidtutorials.java.ui.screens.settings.repository.SettingsRepository;
@@ -249,6 +251,16 @@ public InitMobileAdsUseCase provideInitMobileAdsUseCase(SupportRepository reposi
249251
return new InitMobileAdsUseCase(repository);
250252
}
251253

254+
@Provides
255+
public RefreshPurchasesUseCase provideRefreshPurchasesUseCase(SupportRepository repository) {
256+
return new RefreshPurchasesUseCase(repository);
257+
}
258+
259+
@Provides
260+
public SetPurchaseStatusListenerUseCase provideSetPurchaseStatusListenerUseCase(SupportRepository repository) {
261+
return new SetPurchaseStatusListenerUseCase(repository);
262+
}
263+
252264
@Provides
253265
@Singleton
254266
public HelpRepository provideHelpRepository(Application application) {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.d4rk.androidtutorials.java.domain.support;
2+
3+
import com.d4rk.androidtutorials.java.data.repository.SupportRepository;
4+
5+
/**
6+
* Forces a refresh of Google Play Billing purchases to ensure entitlements are up-to-date.
7+
*/
8+
public class RefreshPurchasesUseCase {
9+
10+
private final SupportRepository repository;
11+
12+
public RefreshPurchasesUseCase(SupportRepository repository) {
13+
this.repository = repository;
14+
}
15+
16+
public void invoke() {
17+
repository.refreshPurchases();
18+
}
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.d4rk.androidtutorials.java.domain.support;
2+
3+
import com.d4rk.androidtutorials.java.data.repository.SupportRepository;
4+
5+
/**
6+
* Registers a listener that receives entitlement changes.
7+
*/
8+
public class SetPurchaseStatusListenerUseCase {
9+
10+
private final SupportRepository repository;
11+
12+
public SetPurchaseStatusListenerUseCase(SupportRepository repository) {
13+
this.repository = repository;
14+
}
15+
16+
public void invoke(SupportRepository.PurchaseStatusListener listener) {
17+
repository.setPurchaseStatusListener(listener);
18+
}
19+
}

app/src/main/java/com/d4rk/androidtutorials/java/ui/screens/support/SupportActivity.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ protected void onCreate(Bundle savedInstanceState) {
3232
setContentView(binding.getRoot());
3333
EdgeToEdgeHelper.applyEdgeToEdge(getWindow(), binding.getRoot());
3434
supportViewModel = new ViewModelProvider(this).get(SupportViewModel.class);
35+
supportViewModel.registerPurchaseStatusListener();
36+
supportViewModel.getPurchaseStatus().observe(this, this::handlePurchaseStatus);
3537

3638
AdRequest adRequest = supportViewModel.initMobileAds();
3739
binding.supportNativeAd.loadAd(adRequest);
@@ -47,6 +49,12 @@ protected void onCreate(Bundle savedInstanceState) {
4749
binding.buttonExtremeDonation.setOnClickListener(v -> initiatePurchase("extreme_donation"));
4850
}
4951

52+
@Override
53+
protected void onResume() {
54+
super.onResume();
55+
supportViewModel.refreshPurchases();
56+
}
57+
5058
private void queryProductDetails() {
5159
List<String> productIds = List.of("low_donation", "normal_donation", "high_donation", "extreme_donation");
5260
supportViewModel.queryProductDetails(productIds, productDetailsList -> {
@@ -81,4 +89,21 @@ private void openSupportLink() {
8189
}
8290

8391
// Up navigation handled by BaseActivity
92+
93+
private void handlePurchaseStatus(SupportPurchaseStatus status) {
94+
if (status == null) {
95+
return;
96+
}
97+
98+
int messageRes;
99+
if (status.state() == SupportPurchaseStatus.State.GRANTED) {
100+
messageRes = status.newPurchase()
101+
? R.string.support_purchase_thank_you
102+
: R.string.support_purchase_restored;
103+
} else {
104+
messageRes = R.string.support_purchase_revoked;
105+
}
106+
107+
Toast.makeText(this, messageRes, Toast.LENGTH_LONG).show();
108+
}
84109
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.d4rk.androidtutorials.java.ui.screens.support;
2+
3+
/**
4+
* UI-facing model that describes entitlement updates for support purchases.
5+
*/
6+
public record SupportPurchaseStatus(String productId,
7+
com.d4rk.androidtutorials.java.ui.screens.support.SupportPurchaseStatus.State state,
8+
boolean newPurchase) {
9+
10+
public enum State {
11+
GRANTED,
12+
REVOKED
13+
}
14+
15+
}

0 commit comments

Comments
 (0)