Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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)

AltStyle によって変換されたページ (->オリジナル) /