Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ copy; `LoadedDocument` adds rendered parts and core capabilities.

`LandingFragment` shows recents and settings. `DocumentFragment` displays documents
in `PageView`, with `DocumentActions` above the page. Document actions belong there;
app settings, ad removal, and consent belong on the landing screen.
app settings and consent belong on the landing screen. "Support the app" is on both.

## Build and editions

Expand All @@ -47,6 +47,13 @@ Add methods to both implementations. Use `Features`, never `BuildConfig.FLAVOR`.
`MainActivity.initializeManagers` may run again after Play Services resolution.
Analytics and crash reporting only log locally.

Lite sells one auto-renewing yearly pass (`Entitlement.PASS_PRODUCT`) that removes ads
and unlocks Pro features. `MainActivity.offerPass` shows it with a link to Pro, also from
the gate. Pro and Foss go to GitHub Sponsors; only Lite and Foss promote support.
`BillingManager` asks Play at each launch. Old lifetime purchases give Pro; never revoke
them. An ended pass takes effect at the next launch. `Features.advancedEditing` reads the
entitlement, so a gain renders the open document again for the editing scope.

The app compiles no native code. Use the single `odr-core-android` AAR for matching
Java bindings and JNI libraries. Keep bindings compatible with API 26.
`CoreLoader.initializeCore` sets `TMPDIR` before the first native call.
Expand Down Expand Up @@ -82,7 +89,7 @@ use the unsupported callback, reopen offer, and contact dialog.

- Render with editor support once. Entering edit mode calls `odr.editing.enable()`.
- Use `HtmlConfig.hostMessageHandler` for page callbacks; do not inject replacements.
- Lite uses paragraph scope. `outOfScope` offers Pro.
- Lite uses paragraph scope until it holds Pro. `outOfScope` offers the pass.
- Gate individual tools, not the entire edit mode. Highlighting and sheet fill remain free.
- Undo, redo, and save belong to the action bar; formatting belongs to `EditingTools`.
- Let the page arm PDF tools through `odr.annotation.press` and `markOnSelection`.
Expand Down
90 changes: 0 additions & 90 deletions app/src/ads/java/app/opendocument/droid/nonfree/OwnedPurchases.kt

This file was deleted.

255 changes: 255 additions & 0 deletions app/src/ads/java/app/opendocument/droid/nonfree/PlayBilling.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
package app.opendocument.droid.nonfree

import android.app.Activity
import android.content.Context
import android.os.Handler
import android.os.Looper
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams

/**
* Lite's connection to play: what the account owns, and the purchase of the yearly pass. One per
* activity, and [end] goes with it. Every callback runs on the main thread.
*/
class PlayBilling(
context: Context,
private val crashManager: CrashManager,
private val onOwned: (entitlement: Entitlement, purchased: Boolean) -> Unit,
) {

private val mainThread = Handler(Looper.getMainLooper())

private val client =
BillingClient.newBuilder(context.applicationContext)
.setListener { result, _ -> onMain { onPurchasesUpdated(result) } }
.enablePendingPurchases(
PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()
)
.build()

private var connected = false

/** Set by [end]. Answers that arrive after it are dropped. */
private var ended = false

/** What waits for the connection: each is told whether it came up. */
private val waiting = ArrayList<(Boolean) -> Unit>()

/** The pass as [queryPassPrice] found it, which is what [buyPass] needs to start the flow. */
private var passDetails: ProductDetails? = null
private var passOfferToken: String? = null

/**
* Reports what the account owns through `onOwned`, and acknowledges what is not acknowledged
* yet: play refunds a purchase that is not within three days. Says nothing if play did not
* answer, so a launch without a network takes nothing away. [purchased] is passed on: true
* after the purchase sheet, false for what play restores.
*/
fun queryOwned(purchased: Boolean = false) {
whenConnected { ok ->
if (!ok) {
return@whenConnected
}

val types = listOf(BillingClient.ProductType.INAPP, BillingClient.ProductType.SUBS)
val owned = HashMap<String, List<Purchase>>()
var pending = types.size
var failed = false

for (type in types) {
val params = QueryPurchasesParams.newBuilder().setProductType(type).build()

client.queryPurchasesAsync(params) { result, purchases ->
onMain {
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
owned[type] = purchases.filter {
it.purchaseState == Purchase.PurchaseState.PURCHASED
}
} else {
crashManager.log(
"billing query failed: " + type + "/" + result.responseCode
)

failed = true
}

pending--
if (pending == 0 && !failed) {
owned.values.flatten().forEach(::acknowledge)

onOwned(
Entitlement.of(
owned.getValue(BillingClient.ProductType.INAPP).flatMap {
it.products
},
owned.getValue(BillingClient.ProductType.SUBS).flatMap {
it.products
},
),
purchased,
)
}
}
}
}
}
}

/** Calls [callback] with the yearly price in the local currency, or null if play has none. */
fun queryPassPrice(callback: (String?) -> Unit) {
whenConnected { ok ->
if (!ok) {
callback(null)

return@whenConnected
}

val product =
QueryProductDetailsParams.Product.newBuilder()
.setProductId(Entitlement.PASS_PRODUCT)
.setProductType(BillingClient.ProductType.SUBS)
.build()
val params =
QueryProductDetailsParams.newBuilder().setProductList(listOf(product)).build()

client.queryProductDetailsAsync(params) { result, details ->
onMain {
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
crashManager.log("pass details failed: " + result.responseCode)
}

val pass = details.productDetailsList.firstOrNull()
// the base plan, not a promotion on top of it: that is the one with no offer id
val basePlan =
pass?.subscriptionOfferDetails?.firstOrNull {
it.basePlanId == Entitlement.PASS_BASE_PLAN && it.offerId == null
}

passDetails = pass
passOfferToken = basePlan?.offerToken

callback(
basePlan?.pricingPhases?.pricingPhaseList?.lastOrNull()?.formattedPrice
)
}
}
}
}

/** Starts play's purchase sheet. Only after [queryPassPrice] found a price. */
fun buyPass(activity: Activity) {
val details = passDetails ?: return
val offerToken = passOfferToken ?: return

val product =
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details)
.setOfferToken(offerToken)
.build()
val params =
BillingFlowParams.newBuilder().setProductDetailsParamsList(listOf(product)).build()

val result = client.launchBillingFlow(activity, params)
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
crashManager.log("pass flow failed: " + result.responseCode)
}
}

fun end() {
ended = true
waiting.clear()

client.endConnection()
}

private fun onPurchasesUpdated(result: BillingResult) {
when (result.responseCode) {
// asked again rather than read from the update: one answer, whichever way it came
BillingClient.BillingResponseCode.OK -> queryOwned(purchased = true)

BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> queryOwned()

BillingClient.BillingResponseCode.USER_CANCELED -> {}

else -> crashManager.log("purchase failed: " + result.responseCode)
}
}

private fun onMain(action: () -> Unit) {
mainThread.post {
if (!ended) {
action()
}
}
}

private fun acknowledge(purchase: Purchase) {
if (purchase.isAcknowledged) {
return
}

val params =
AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()

client.acknowledgePurchase(params) { result ->
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
// the next launch tries again, well inside the three days
crashManager.log("acknowledge failed: " + result.responseCode)
}
}
}

private fun whenConnected(action: (Boolean) -> Unit) {
if (connected) {
action(true)

return
}

waiting.add(action)

// the first one to wait has already asked for the connection
if (waiting.size > 1) {
return
}

client.startConnection(
object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
onMain {
connected = result.responseCode == BillingClient.BillingResponseCode.OK
if (!connected) {
crashManager.log("billing setup failed: " + result.responseCode)
}

val actions = waiting.toList()
waiting.clear()

actions.forEach { it(connected) }
}
}

override fun onBillingServiceDisconnected() {
// the next call connects again. a drop before the setup finished would
// otherwise leave the waiting calls, and every later one, unanswered
onMain {
connected = false

val actions = waiting.toList()
waiting.clear()

actions.forEach { it(false) }
}
}
}
)
}
}
Loading
Loading