Keeping and revoking a one-time remove-ads purchase on iOS and Android

Jaemyeong Jin···10 min read
한국어

I added a product that removes ads permanently to the iOS and Android versions of DailySudoku, a Sudoku app. At first it looked as if storing whether the purchase succeeded would be enough. But the result right after the purchase turned out to be a poor basis for judging the state later. The app can be reinstalled, a payment can stay pending, and a purchase can be refunded.

The source baseline is commit 9c999305 on the develop branch as of August 6, 2026. That is separate from anything confirmed in a release on the stores. Below, the behavior of that source, what the Apple and Google documentation says, and what the automated tests cover are kept apart. That is as far as this post goes. Records of paying and refunding on real devices, and run logs, are not in it.

What is missed when only the purchase result is stored

A purchase notification is only one of the inputs. Ownership is refreshed by reading the current list of entitlements. Storing only the purchase result misses the following.

  • The user canceled the payment.
  • A pending payment completed after the app was closed.
  • The purchase was made on another device.
  • The data was reset or the app was newly installed.
  • A refund, a cancellation, or a revoke happened.
  • The connection dropped before the result arrived.

So three inputs are merged into one entitlement state.

구매 이벤트 ───────┐
앱 시작·복귀 조회 ─┼─> 검증된 현재 권한 ─> 로컬 캐시 ─> 광고 게이트
스토어 외부 변경 ──┘

From top to bottom, the three inputs on the left are the purchase event, the query at app start and return, and a change made outside the app in the store. They lead to the verified current entitlement, then the local cache, then the ad gate.

Each input has its own job. The purchase notification is used to tell the user right away. The query at app start and on return to the foreground recovers missed changes and changes from other devices. Updates carry changes while the app is running. All three end up sharing the same adFree value.

The local value is a cache

The Boolean stored on the device is a cache for drawing the screen, not the source of truth for ownership. Both the snapshot and the updates write true and false as they are. After a refund, false goes in and ads are allowed again.

There is one ambiguity here. adFree == false can mean “not owned,” and it can also mean “not queried yet.” If the two cases are not told apart, a user who bought the product can see an ad right after launch. So whether the query has finished is marked separately with entitlementsSynced.

The condition that allows ads is computed from three values.

adsEnabled = entitlementsSynced && !adFree && consentAllowsAds

While entitlementsSynced == false, no ad is requested. A user who has not bought enters the ad path after the first restore finishes, and a user who has bought stays blocked because adFree == true. The settings, home, difficulty selection, game, and statistics screens share this decision.

How the ad objects are handled differs by platform. When blocked, the Android side does not create the Compose subtree at all and releases the native view that was attached. On iOS, the banner is collapsed and further loads are suppressed. Whether an already loaded provider is torn down on iOS is not verified by automated tests. The ad disappearing from the screen is not enough to say that the SDK has stopped its background work.

iOS: what is read from StoreKit 2

A purchase is handled according to the result of Product.purchase().

  • For .success(.verified(transaction)), the entitlement is granted and the transaction is finished.
  • For .success(.unverified(...)), the entitlement is not granted.
  • For .pending and .userCancelled, it is not granted either.

Changes while running are observed through Transaction.updates. Ask to Buy, a purchase on another device, and a revoke arrive there. At app start and on return to the foreground, Transaction.currentEntitlements is checked for the product ID of the remove-ads product and for a verified result. According to the documentation, this list holds the latest entitlement for a non-consumable, and items that were refunded or revoked are left out. If the query finishes with no matching product, false is stored.

On a reinstall or a new device, the latest transaction is provided automatically, so in normal use the currentEntitlements query is enough. A manual restore runs only when the user chooses restore in settings. At that point AppStore.sync() is called and the entitlements are queried again. That call can show an authentication prompt, so it is not run automatically at app start.

The price is refreshed separately. The ownership flow and the consent flow do not need to wait for the price response.

Android: what is read from Play Billing

The product type on Android is INAPP. One-time products have no separate API for non-consumables, so the purchase is acknowledged and not consumed.

The flow follows the Play Billing integration guide. queryProductDetailsAsync returns the products that can be sold to that user and the localized price, and launchBillingFlow is called with the returned ProductDetails and the offer token. The purchase arrives through PurchasesUpdatedListener or is found by a later queryPurchasesAsync.

The entitlement is granted only when the product ID and the purchase state are checked and the state is PURCHASED, and since the purchase is not consumed, it is acknowledged. PENDING is not a confirmed failure. It is a state waiting for completion, such as a cash payment or an additional approval. Google’s guidance is not to grant the benefit while the state is PENDING and to handle it after it turns PURCHASED. The state can change while the app is closed, so queryPurchasesAsync() in onResume() recovers missed changes.

Verification and acknowledgement are different jobs. Verification checks that the purchase is legitimate and has not been handled already. Acknowledgement tells Play that the benefit was delivered. According to the documentation, a normal purchase can be refunded automatically if it is not acknowledged within 3 days after turning PURCHASED, and for a purchase by a license tester that window is 3 minutes.

What the current implementation trusts is narrow. It checks PURCHASED and the product ID as reported by BillingClient, and acknowledges on the client. It does not verify the purchase token on a server. Google’s billing security guide recommends verifying with the Google Play Developer API on a secure backend before granting the benefit, and acknowledging on the server as well. For now the setup accepts trusting the client, and it cannot be described as having completed server verification.

Restore, refunds, and failed queries

The purpose of a restore is not to find out whether something was bought in the past. It is to make the cache match the current snapshot. At start and on return, the Android side queries with queryPurchasesAsync(INAPP), and changes while running arrive through the listener or are recovered by the query on the next resume. A manual restore also queries the current inventory again.

The moment a refund takes effect differs by platform. On iOS it is reflected in an update or a later snapshot. On Android the value becomes false when the next inventory query succeeds.

An empty result and an error have to be told apart. The product not being there and the store being unreachable are different events. Right now, when the connection or the inventory query fails on Android, adFree is left as it is and entitlementsSynced = true is set. This policy has a cost in both directions. A buyer who reinstalled has a cache of false and can see ads until the state is recovered. A user who got a refund can stay blocked from ads until a query succeeds.

The iOS model has a gap too. With no verified matching item the value becomes false, but an unverified case and a temporarily empty false negative are not classified separately. The failure policies of the two platforms differ, and neither is a guarantee of safety.

The improvement I have in mind is to split the state into unknown, owned, and notOwned, or to tell an error apart from a successful empty result. On top of that, each product decides between fail-open and fail-closed. This is still a proposal, not a fix that has been made.

Restores that run at the same time need checking as well. Five asynchronous paths write to the cache: purchase, update, start, resume, and manual restore. The issue is the order in which the start query and the foreground query finish, and locking the connection alone does not serialize the whole reconcile. A past snapshot that arrives late can overwrite a newer value.

When the price cannot be fetched

The price shown is the localized value from the store, and no fixed price is written on the screen. On both platforms, the settings screen hides the remove-ads row when the price is nil. But the manual restore control is on the screen opened from that row.

As a result, when the price query fails, the entry point for retrying by hand disappears. Automatic recovery at start and resume keeps running. The screen also cannot tell a product that is not registered from a temporary network failure.

The improvement is to disable only the buy button and show restore separately, and to tell an empty product response from an exception in retries and in monitoring. This too is a proposal.

Ways to test and expected results

On the Apple side, StoreKit configuration in Xcode allows local tests of transactions, interrupted purchases, refunds, and revokes. Flows that involve an account are checked in Sandbox or TestFlight.

With a license tester, the real purchase flow and test payment methods can be exercised on Android. If the package name and the tester account conditions are met, a debug-signed sideloaded build can be tested too, and the test tracks are for checking before release. It is not correct to say flatly that a direct ADB install always fails or that only an install from Play works.

Below are the results I intend to confirm. They are not a record of runs that passed.

  • New purchase: ad requests stop, the state holds after a relaunch, and the product cannot be bought again.
  • User cancellation: no entitlement and no success message, and the purchase can be tried again.
  • Approval after pending: no benefit while pending, and one grant after PURCHASED.
  • Cancellation after pending: no benefit is granted.
  • Already owned: ownership is recovered on a new device, after a reinstall, and by manual restore.
  • Completed outside the app: recovered by an update or the next resume.
  • Lost connection: a successful empty response and a query error are told apart.
  • Failed acknowledgement: retried and completed within the allowed window.
  • Refund and revoke: the cache becomes false and ads are allowed again.
  • Fast app switching: repeated background and foreground does not let a past query overwrite the value.

The automated tests cover little. On iOS they cover storing false on a restore with nothing owned, and the price refresh. A hosted unit test cannot cover the UI path of a real successful purchase. The purchase and restore in PlayBillingProvider, the pending notice, a failed acknowledgement, the Settings row, and the full truth table of the ad gate are missing from the automated tests on the Android side. Passing local tests is not enough to say that real store payments were confirmed.

What remains is server verification on Android, the screen for the pending state, the error model, concurrent reconcile, the restore entry point, and checking the store scenarios on real devices. The 3-day and 3-minute acknowledgement windows, the offer token condition, and the eligibility of a debug-signed sideload are what the documentation said on the baseline date, so they have to be checked again in the official documentation when a policy or a library changes.

광고Coupang Partners

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.