Skip to content
Open
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: 7 additions & 4 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ class Developer {
}

ext {
androidBuildToolsVersion = '33.0.0'
androidCompileSdkVersion = 33
androidTargetSdkVersion = 33
androidBuildToolsVersion = "36.0.0"
androidCompileSdkVersion = 35
androidTargetSdkVersion = 35
assertJVersion = '1.7.1'
gdxVersion = '1.9.8'
gdxVersion = '1.13.1'
robolectricVersion = '4.3_r2-robolectric-0'

developers = [
Expand All @@ -35,6 +35,7 @@ ext {
robovm_rt : "com.mobidevelop.robovm:robovm-rt:$roboVMVersion",
robovm_cocoatouch : "com.mobidevelop.robovm:robovm-cocoatouch:$roboVMVersion",
robovm_storekit2 : "com.mobidevelop.robovm:robopods-swift-storekit2:18.2.0.1",
gdxBackendMoe : "io.github.berstanio:gdx-backend-moe:${gdxVersion}",
support_v4 : "com.android.support:support-v4:25.0.0"
]

Expand Down Expand Up @@ -73,6 +74,7 @@ ext {
buildscript {
ext {
roboVMVersion = '2.3.21'
moeVersion = '1.10.2'
}

repositories {
Expand All @@ -85,6 +87,7 @@ buildscript {
dependencies {
classpath "com.android.tools.build:gradle:8.2.0"
classpath "com.mobidevelop.robovm:robovm-gradle-plugin:${roboVMVersion}"
classpath "org.multi-os-engine:moe-gradle:${moeVersion}"
}
}

Expand Down
12 changes: 10 additions & 2 deletions gdx-pay-client/build.gradle
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
apply plugin : 'java-library'
apply from : '../publish_java.gradle'

sourceCompatibility = 11
targetCompatibility = 11
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}

tasks.withType(JavaCompile).configureEach {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}

sourceSets {
main {
Expand Down
3 changes: 3 additions & 0 deletions gdx-pay-iosmoe-apple/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.DS_Store
/build/
/native/GdxPayStoreKit2Bridge/.build/
59 changes: 59 additions & 0 deletions gdx-pay-iosmoe-apple/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# In-app purchasing implementation for Apple (iOS/MOE)

This module uses Apple's StoreKit 2 API through a small Swift bridge. The Java `PurchaseManageriOSApple`
implementation talks to the bridge through MOE/NatJ-compatible Objective-C selectors instead of depending on
RoboVM StoreKit bindings.

### Dependencies

implementation "com.badlogicgames.gdxpay:gdx-pay-iosmoe-apple:$gdxPayVersion"

### Native StoreKit 2 bridge

The Swift bridge lives in:

native/GdxPayStoreKit2Bridge

Build it as an iOS framework or XCFramework and include it in the MOE iOS application target. The exported Swift class
is `GDXStoreKit2Bridge`; the Java binding is mirrored in:

src/main/java/com/badlogic/gdx/pay/ios/apple/bindings/GDXStoreKit2Bridge.java

If the Swift API changes, regenerate or update this NatJ binding so the selectors stay aligned:

* `shared`
* `canMakePayments`
* `fetchProductsWithIdentifiers:completion:`
* `purchaseWithIdentifier:completion:`
* `fetchCurrentEntitlementsWithCompletion:`
* `restorePurchasesWithCompletion:`
* `startObservingTransactionsWithCompletion:`
* `stopObservingTransactions`

### Building and publishing locally

In Android Studio, use JDK 17 or newer as the Gradle JVM. The module uses a Java 17 toolchain to compile Java 11
bytecode, matching the rest of this repository.

Useful Gradle tasks:

./gradlew :gdx-pay-iosmoe-apple:build
./gradlew :gdx-pay-iosmoe-apple:publishToMavenLocal

### Instantiation

Add this to your `IOSLauncher`:

game.purchaseManager = new PurchaseManageriOSApple();

## Testing
Next to other ways, I find the easiest way to test the IAP the following:

* draft your IAP in AppStore Connect<sup>1</sup>
* upload your build with IAPs to AppStore connect
* release your build with TestFlight for internal or external test users
* your build installed from TestFlight will have working IAPs that are not charged to the users


(1) In order for you to use your actual IAP in your app you also have to fill some information regarding your App Store Connect account. Normally you will see a warning if something is missing, but sometimes when your app is marked as distributed for free, the warnings won't show. In case of IAP, make sure that you have filled required information in following section:
My Apps -> Agreements, Tax, and Banking -> Paid Apps. It's status should be "Active". As stated there: "The Paid Apps agreement alllows your organization to sell apps on the App Store or **offer in-app purchases.**"
21 changes: 21 additions & 0 deletions gdx-pay-iosmoe-apple/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
apply plugin : 'java-library'
apply plugin : 'moe-sdk'
apply from : '../publish_java.gradle'


java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}

tasks.withType(JavaCompile).configureEach {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}


dependencies {
api project(':gdx-pay-client')
api libraries.gdxBackendMoe
}
23 changes: 23 additions & 0 deletions gdx-pay-iosmoe-apple/native/GdxPayStoreKit2Bridge/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// swift-tools-version: 5.9

import PackageDescription

let package = Package(
name: "GdxPayStoreKit2Bridge",
platforms: [
.iOS(.v15)
],
products: [
.library(
name: "GdxPayStoreKit2Bridge",
type: .dynamic,
targets: ["GdxPayStoreKit2Bridge"]
)
],
targets: [
.target(
name: "GdxPayStoreKit2Bridge",
path: "Sources/GdxPayStoreKit2Bridge"
)
]
)
25 changes: 25 additions & 0 deletions gdx-pay-iosmoe-apple/native/GdxPayStoreKit2Bridge/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# GdxPayStoreKit2Bridge

This Swift package is the native StoreKit 2 bridge used by the MOE backend.

The public API intentionally exposes Objective-C compatible Foundation types only:

* `String`
* `Array`
* `Dictionary`
* `NSNumber` / `NSError`
* callback blocks

That keeps the Java side compatible with NatJ/WrapNatJGen while the actual StoreKit 2 implementation can use Swift concurrency internally.

## Build outline

Build this package as an iOS framework or XCFramework, then generate NatJ bindings for `GDXStoreKit2Bridge`.

Typical local flow:

```sh
swift build
```

For app integration, prefer an `.xcframework` containing simulator and device slices. The Java binding in `src/main/java/com/badlogic/gdx/pay/ios/apple/bindings/GDXStoreKit2Bridge.java` mirrors the Objective-C selectors exported by this Swift class.
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import Foundation
import StoreKit

@objc(GDXStoreKit2Bridge)
@available(iOS 15.0, macOS 12.0, *)
public final class GDXStoreKit2Bridge: NSObject {
@objc public static let shared = GDXStoreKit2Bridge()

private var productsById: [String: Product] = [:]
private var transactionUpdatesTask: Task<Void, Never>?

@objc public func canMakePayments() -> Bool {
AppStore.canMakePayments
}

@objc(fetchProductsWithIdentifiers:completion:)
public func fetchProducts(
withIdentifiers identifiers: [String],
completion: @escaping ([[String: Any]]?, NSError?) -> Void
) {
Task {
do {
let products = try await Product.products(for: identifiers)
productsById = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) })

var mappedProducts: [[String: Any]] = []
for product in products {
mappedProducts.append(await map(product))
}

completion(mappedProducts, nil)
} catch {
completion(nil, error as NSError)
}
}
}

@objc(purchaseWithIdentifier:completion:)
public func purchase(
withIdentifier identifier: String,
completion: @escaping ([String: Any]?, NSError?) -> Void
) {
Task {
guard let product = productsById[identifier] else {
completion(nil, bridgeError(code: 1, message: "Product not loaded: \(identifier)"))
return
}

do {
let result = try await product.purchase()

switch result {
case .success(let verificationResult):
let transaction = try checked(verificationResult)
await transaction.finish()
completion(map(transaction), nil)

case .userCancelled:
completion(["cancelled": true, "productID": identifier], nil)

case .pending:
completion(["pending": true, "productID": identifier], nil)

@unknown default:
completion(nil, bridgeError(code: 2, message: "Unknown purchase result for \(identifier)"))
}
} catch {
completion(nil, error as NSError)
}
}
}

@objc(fetchCurrentEntitlementsWithCompletion:)
public func fetchCurrentEntitlements(
completion: @escaping ([[String: Any]]?, NSError?) -> Void
) {
Task {
do {
var transactions: [[String: Any]] = []

for await result in Transaction.currentEntitlements {
let transaction = try checked(result)
transactions.append(map(transaction))
}

completion(transactions, nil)
} catch {
completion(nil, error as NSError)
}
}
}

@objc(restorePurchasesWithCompletion:)
public func restorePurchases(
completion: @escaping ([[String: Any]]?, NSError?) -> Void
) {
Task {
do {
try await AppStore.sync()
fetchCurrentEntitlements(completion: completion)
} catch {
completion(nil, error as NSError)
}
}
}

@objc(startObservingTransactionsWithCompletion:)
public func startObservingTransactions(
completion: @escaping ([String: Any]?, NSError?) -> Void
) {
stopObservingTransactions()

transactionUpdatesTask = Task {
for await result in Transaction.updates {
do {
let transaction = try checked(result)
await transaction.finish()
completion(map(transaction), nil)
} catch {
completion(nil, error as NSError)
}
}
}
}

@objc public func stopObservingTransactions() {
transactionUpdatesTask?.cancel()
transactionUpdatesTask = nil
}

private func checked<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .verified(let value):
return value
case .unverified(_, let error):
throw error
}
}

private func map(_ product: Product) async -> [String: Any] {
var mapped: [String: Any] = [
"id": product.id,
"displayName": product.displayName,
"description": product.description,
"displayPrice": product.displayPrice,
"currencyCode": product.priceFormatStyle.currencyCode,
"price": NSDecimalNumber(decimal: product.price)
]

if let subscription = product.subscription,
let introductoryOffer = subscription.introductoryOffer {
mapped["eligibleForIntroOffer"] = await subscription.isEligibleForIntroOffer
mapped["introPeriodValue"] = introductoryOffer.period.value
mapped["introPeriodUnit"] = map(introductoryOffer.period.unit)
mapped["introPrice"] = NSDecimalNumber(decimal: introductoryOffer.price)
}

return mapped
}

private func map(_ transaction: Transaction) -> [String: Any] {
[
"productID": transaction.productID,
"originalID": String(transaction.originalID),
"purchaseDate": transaction.purchaseDate.timeIntervalSince1970,
"jsonRepresentation": String(data: transaction.jsonRepresentation, encoding: .utf8) ?? "",
"jsonRepresentationBase64": transaction.jsonRepresentation.base64EncodedString()
]
}

private func map(_ unit: Product.SubscriptionPeriod.Unit) -> String {
switch unit {
case .day:
return "day"
case .week:
return "week"
case .month:
return "month"
case .year:
return "year"
@unknown default:
return "unknown"
}
}

private func bridgeError(code: Int, message: String) -> NSError {
NSError(
domain: "com.badlogic.gdx.pay.iosmoe.storekit2",
code: code,
userInfo: [NSLocalizedDescriptionKey: message]
)
}
}
Loading
Loading