From 30d5843ce3fb83d1f8d271431d39bb7b6a474fbf Mon Sep 17 00:00:00 2001 From: Ioannis Giannakakis Date: Tue, 12 May 2026 12:52:21 +0200 Subject: [PATCH] Add MOE StoreKit2 bridge module --- build.gradle | 11 +- gdx-pay-client/build.gradle | 12 +- gdx-pay-iosmoe-apple/.gitignore | 3 + gdx-pay-iosmoe-apple/README.md | 59 +++ gdx-pay-iosmoe-apple/build.gradle | 21 ++ .../GdxPayStoreKit2Bridge/Package.swift | 23 ++ .../native/GdxPayStoreKit2Bridge/README.md | 25 ++ .../GDXStoreKit2Bridge.swift | 193 ++++++++++ .../gdx/pay/ios/apple/IosVersion.java | 18 + .../pay/ios/apple/NatJStoreKit2Bridge.java | 212 +++++++++++ .../ios/apple/PurchaseManageriOSApple.java | 337 ++++++++++++++++++ .../gdx/pay/ios/apple/StoreKit2Bridge.java | 32 ++ .../pay/ios/apple/StoreKit2ProductInfo.java | 32 ++ .../ios/apple/StoreKit2TransactionInfo.java | 27 ++ .../apple/bindings/GDXStoreKit2Bridge.java | 86 +++++ settings.gradle | 1 + 16 files changed, 1086 insertions(+), 6 deletions(-) create mode 100644 gdx-pay-iosmoe-apple/.gitignore create mode 100644 gdx-pay-iosmoe-apple/README.md create mode 100644 gdx-pay-iosmoe-apple/build.gradle create mode 100644 gdx-pay-iosmoe-apple/native/GdxPayStoreKit2Bridge/Package.swift create mode 100644 gdx-pay-iosmoe-apple/native/GdxPayStoreKit2Bridge/README.md create mode 100644 gdx-pay-iosmoe-apple/native/GdxPayStoreKit2Bridge/Sources/GdxPayStoreKit2Bridge/GDXStoreKit2Bridge.swift create mode 100644 gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/IosVersion.java create mode 100644 gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/NatJStoreKit2Bridge.java create mode 100644 gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/PurchaseManageriOSApple.java create mode 100644 gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/StoreKit2Bridge.java create mode 100644 gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/StoreKit2ProductInfo.java create mode 100644 gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/StoreKit2TransactionInfo.java create mode 100644 gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/bindings/GDXStoreKit2Bridge.java diff --git a/build.gradle b/build.gradle index 11fea2e0..df86dc29 100644 --- a/build.gradle +++ b/build.gradle @@ -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 = [ @@ -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" ] @@ -73,6 +74,7 @@ ext { buildscript { ext { roboVMVersion = '2.3.21' + moeVersion = '1.10.2' } repositories { @@ -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}" } } diff --git a/gdx-pay-client/build.gradle b/gdx-pay-client/build.gradle index 0eb1977f..65c1c5f5 100644 --- a/gdx-pay-client/build.gradle +++ b/gdx-pay-client/build.gradle @@ -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 { diff --git a/gdx-pay-iosmoe-apple/.gitignore b/gdx-pay-iosmoe-apple/.gitignore new file mode 100644 index 00000000..eb8b103b --- /dev/null +++ b/gdx-pay-iosmoe-apple/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +/build/ +/native/GdxPayStoreKit2Bridge/.build/ diff --git a/gdx-pay-iosmoe-apple/README.md b/gdx-pay-iosmoe-apple/README.md new file mode 100644 index 00000000..f252dc25 --- /dev/null +++ b/gdx-pay-iosmoe-apple/README.md @@ -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 Connect1 +* 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.**" diff --git a/gdx-pay-iosmoe-apple/build.gradle b/gdx-pay-iosmoe-apple/build.gradle new file mode 100644 index 00000000..45a8279f --- /dev/null +++ b/gdx-pay-iosmoe-apple/build.gradle @@ -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 +} diff --git a/gdx-pay-iosmoe-apple/native/GdxPayStoreKit2Bridge/Package.swift b/gdx-pay-iosmoe-apple/native/GdxPayStoreKit2Bridge/Package.swift new file mode 100644 index 00000000..ac6d23d4 --- /dev/null +++ b/gdx-pay-iosmoe-apple/native/GdxPayStoreKit2Bridge/Package.swift @@ -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" + ) + ] +) diff --git a/gdx-pay-iosmoe-apple/native/GdxPayStoreKit2Bridge/README.md b/gdx-pay-iosmoe-apple/native/GdxPayStoreKit2Bridge/README.md new file mode 100644 index 00000000..5258eaec --- /dev/null +++ b/gdx-pay-iosmoe-apple/native/GdxPayStoreKit2Bridge/README.md @@ -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. diff --git a/gdx-pay-iosmoe-apple/native/GdxPayStoreKit2Bridge/Sources/GdxPayStoreKit2Bridge/GDXStoreKit2Bridge.swift b/gdx-pay-iosmoe-apple/native/GdxPayStoreKit2Bridge/Sources/GdxPayStoreKit2Bridge/GDXStoreKit2Bridge.swift new file mode 100644 index 00000000..fc333731 --- /dev/null +++ b/gdx-pay-iosmoe-apple/native/GdxPayStoreKit2Bridge/Sources/GdxPayStoreKit2Bridge/GDXStoreKit2Bridge.swift @@ -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? + + @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(_ result: VerificationResult) 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] + ) + } +} diff --git a/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/IosVersion.java b/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/IosVersion.java new file mode 100644 index 00000000..751aae04 --- /dev/null +++ b/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/IosVersion.java @@ -0,0 +1,18 @@ +package com.badlogic.gdx.pay.ios.apple; + +import apple.foundation.NSProcessInfo; +import apple.foundation.struct.NSOperatingSystemVersion; + +enum IosVersion { + ; + + static boolean is_7_0_orAbove() { + return NSProcessInfo.alloc().operatingSystemVersion().majorVersion() >= 7; + } + + static boolean is_11_2_orAbove() { + return ((NSProcessInfo.alloc().operatingSystemVersion().majorVersion() == 11 + && NSProcessInfo.alloc().operatingSystemVersion().minorVersion() >= 2) + || NSProcessInfo.alloc().operatingSystemVersion().majorVersion() > 11); + } +} \ No newline at end of file diff --git a/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/NatJStoreKit2Bridge.java b/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/NatJStoreKit2Bridge.java new file mode 100644 index 00000000..a00e4de7 --- /dev/null +++ b/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/NatJStoreKit2Bridge.java @@ -0,0 +1,212 @@ +package com.badlogic.gdx.pay.ios.apple; + +import apple.foundation.NSArray; +import apple.foundation.NSDictionary; +import apple.foundation.NSError; +import apple.foundation.NSNumber; + +import com.badlogic.gdx.pay.FreeTrialPeriod; +import com.badlogic.gdx.pay.ios.apple.bindings.GDXStoreKit2Bridge; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.List; + +final class NatJStoreKit2Bridge implements StoreKit2Bridge { + private final GDXStoreKit2Bridge bridge; + + NatJStoreKit2Bridge() { + this(GDXStoreKit2Bridge.shared()); + } + + NatJStoreKit2Bridge(GDXStoreKit2Bridge bridge) { + this.bridge = bridge; + } + + @Override + public boolean canMakePayments() { + return bridge.canMakePayments(); + } + + @Override + public void fetchProducts(Collection identifiers, final ProductsCallback callback) { + bridge.fetchProductsWithIdentifiersCompletion(toNSArray(identifiers), + new GDXStoreKit2Bridge.Block_fetchProductsWithIdentifiersCompletion() { + @Override + public void call_fetchProductsWithIdentifiersCompletion( + NSArray> products, NSError error) { + callback.onResult(mapProducts(products), toThrowable(error)); + } + }); + } + + @Override + public void purchase(String identifier, final TransactionCallback callback) { + bridge.purchaseWithIdentifierCompletion(identifier, + new GDXStoreKit2Bridge.Block_purchaseWithIdentifierCompletion() { + @Override + public void call_purchaseWithIdentifierCompletion(NSDictionary transaction, NSError error) { + callback.onResult(mapTransaction(transaction), toThrowable(error)); + } + }); + } + + @Override + public void fetchCurrentEntitlements(final TransactionsCallback callback) { + bridge.fetchCurrentEntitlementsWithCompletion( + new GDXStoreKit2Bridge.Block_fetchCurrentEntitlementsWithCompletion() { + @Override + public void call_fetchCurrentEntitlementsWithCompletion( + NSArray> transactions, NSError error) { + callback.onResult(mapTransactions(transactions), toThrowable(error)); + } + }); + } + + @Override + public void restorePurchases(final TransactionsCallback callback) { + bridge.restorePurchasesWithCompletion(new GDXStoreKit2Bridge.Block_restorePurchasesWithCompletion() { + @Override + public void call_restorePurchasesWithCompletion( + NSArray> transactions, NSError error) { + callback.onResult(mapTransactions(transactions), toThrowable(error)); + } + }); + } + + @Override + public void startObservingTransactions(final TransactionCallback callback) { + bridge.startObservingTransactionsWithCompletion( + new GDXStoreKit2Bridge.Block_startObservingTransactionsWithCompletion() { + @Override + public void call_startObservingTransactionsWithCompletion( + NSDictionary transaction, NSError error) { + callback.onResult(mapTransaction(transaction), toThrowable(error)); + } + }); + } + + @Override + public void stopObservingTransactions() { + bridge.stopObservingTransactions(); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private NSArray toNSArray(Collection values) { + if (values.isEmpty()) return (NSArray) NSArray.array(); + + Object[] strings = values.toArray(new Object[0]); + Object first = strings[0]; + Object[] rest = new Object[strings.length - 1]; + System.arraycopy(strings, 1, rest, 0, rest.length); + return (NSArray) NSArray.arrayWithObjects(first, rest); + } + + private List mapProducts(NSArray> products) { + List result = new ArrayList<>(); + if (products == null) return result; + + for (NSDictionary product : products) { + result.add(mapProduct(product)); + } + return result; + } + + private StoreKit2ProductInfo mapProduct(NSDictionary product) { + return new StoreKit2ProductInfo( + string(product, "id"), + string(product, "displayName"), + string(product, "description"), + string(product, "displayPrice"), + string(product, "currencyCode"), + decimal(product, "price"), + freeTrialPeriod(product)); + } + + private List mapTransactions(NSArray> transactions) { + List result = new ArrayList<>(); + if (transactions == null) return result; + + for (NSDictionary transaction : transactions) { + result.add(mapTransaction(transaction)); + } + return result; + } + + private StoreKit2TransactionInfo mapTransaction(NSDictionary transaction) { + if (transaction == null) return null; + + return new StoreKit2TransactionInfo( + string(transaction, "productID"), + string(transaction, "originalID"), + date(transaction, "purchaseDate"), + firstNonEmpty(string(transaction, "jsonRepresentationBase64"), string(transaction, "jsonRepresentation")), + bool(transaction, "cancelled"), + bool(transaction, "pending")); + } + + private FreeTrialPeriod freeTrialPeriod(NSDictionary product) { + if (!bool(product, "eligibleForIntroOffer")) return null; + + BigDecimal introPrice = decimal(product, "introPrice"); + if (introPrice != null && introPrice.compareTo(BigDecimal.ZERO) > 0) return null; + + Number value = number(product, "introPeriodValue"); + String unit = string(product, "introPeriodUnit"); + if (value == null || unit == null) return null; + + FreeTrialPeriod.PeriodUnit periodUnit; + if ("day".equals(unit)) periodUnit = FreeTrialPeriod.PeriodUnit.DAY; + else if ("week".equals(unit)) periodUnit = FreeTrialPeriod.PeriodUnit.WEEK; + else if ("month".equals(unit)) periodUnit = FreeTrialPeriod.PeriodUnit.MONTH; + else if ("year".equals(unit)) periodUnit = FreeTrialPeriod.PeriodUnit.YEAR; + else return null; + + return new FreeTrialPeriod(value.intValue(), periodUnit); + } + + private Throwable toThrowable(NSError error) { + if (error == null) return null; + return new RuntimeException(error.localizedDescription()); + } + + private String firstNonEmpty(String first, String second) { + return first != null && first.length() > 0 ? first : second; + } + + private String string(NSDictionary dictionary, String key) { + Object value = value(dictionary, key); + return value != null ? value.toString() : null; + } + + private boolean bool(NSDictionary dictionary, String key) { + Object value = value(dictionary, key); + if (value instanceof NSNumber) return ((NSNumber) value).boolValue(); + if (value instanceof Boolean) return (Boolean) value; + return value != null && Boolean.parseBoolean(value.toString()); + } + + private Date date(NSDictionary dictionary, String key) { + Number value = number(dictionary, key); + return value != null ? new Date((long) (value.doubleValue() * 1000D)) : new Date(); + } + + private BigDecimal decimal(NSDictionary dictionary, String key) { + Number number = number(dictionary, key); + return number != null ? BigDecimal.valueOf(number.doubleValue()) : null; + } + + private Number number(NSDictionary dictionary, String key) { + Object value = value(dictionary, key); + if (value instanceof NSNumber) return ((NSNumber) value).doubleValue(); + if (value instanceof Number) return (Number) value; + if (value == null) return null; + return Double.valueOf(value.toString()); + } + + private Object value(NSDictionary dictionary, String key) { + return dictionary != null ? dictionary.objectForKey(key) : null; + } +} diff --git a/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/PurchaseManageriOSApple.java b/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/PurchaseManageriOSApple.java new file mode 100644 index 00000000..c700b661 --- /dev/null +++ b/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/PurchaseManageriOSApple.java @@ -0,0 +1,337 @@ +/******************************************************************************* + * Copyright 2011 See AUTHORS file. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + ******************************************************************************/ + +package com.badlogic.gdx.pay.ios.apple; + +import com.badlogic.gdx.pay.FetchItemInformationException; +import com.badlogic.gdx.pay.GdxPayException; +import com.badlogic.gdx.pay.Information; +import com.badlogic.gdx.pay.Offer; +import com.badlogic.gdx.pay.PurchaseManager; +import com.badlogic.gdx.pay.PurchaseManagerConfig; +import com.badlogic.gdx.pay.PurchaseObserver; +import com.badlogic.gdx.pay.Transaction; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * The purchase manager implementation for Apple's iOS IAP system using StoreKit 2 through + * a Swift bridge that is exposed to MOE/NatJ. + */ +public class PurchaseManageriOSApple implements PurchaseManager { + private static final String TAG = "GdxPay/AppleIOS/MOE"; + private static final boolean LOGDEBUG = + Boolean.parseBoolean(System.getProperty("gdx.pay.ios.apple.logdebug", "false")); + private static final int LOGTYPELOG = 0; + private static final int LOGTYPEERROR = 1; + + private final StoreKit2Bridge storeKit2Bridge; + private final Map productsByStoreIdentifier = new HashMap<>(); + + private PurchaseObserver observer; + private PurchaseManagerConfig config; + private boolean installed; + + public PurchaseManageriOSApple() { + this(new NatJStoreKit2Bridge()); + } + + PurchaseManageriOSApple(StoreKit2Bridge storeKit2Bridge) { + this.storeKit2Bridge = storeKit2Bridge; + } + + @Override + public String storeName() { + return PurchaseManagerConfig.STORE_NAME_IOS_APPLE; + } + + @Override + public void install(PurchaseObserver observer, PurchaseManagerConfig config, boolean autoFetchInformation) { + this.observer = observer; + this.config = config; + + log(LOGTYPELOG, "Installing StoreKit 2 purchase manager..."); + + if (!storeKit2Bridge.canMakePayments()) { + observer.handleInstallError(new GdxPayException( + "Error installing purchase observer: Device not configured for purchases!")); + return; + } + + Collection productIdentifiers = configuredStoreIdentifiers(config); + if (productIdentifiers.isEmpty()) { + installed = true; + observer.handleInstall(); + return; + } + + storeKit2Bridge.fetchProducts(productIdentifiers, new StoreKit2Bridge.ProductsCallback() { + @Override + public void onResult(List products, Throwable error) { + if (error != null) { + String message = "Error requesting products: " + error.getMessage(); + log(LOGTYPEERROR, message, error); + PurchaseManageriOSApple.this.observer.handleInstallError( + new FetchItemInformationException(message)); + return; + } + + cacheProducts(products); + installed = true; + observeTransactionUpdates(); + restoreCurrentEntitlementsOnStartup(); + PurchaseManageriOSApple.this.observer.handleInstall(); + } + }); + } + + @Override + public boolean installed() { + return installed; + } + + @Override + public void dispose() { + storeKit2Bridge.stopObservingTransactions(); + productsByStoreIdentifier.clear(); + observer = null; + config = null; + installed = false; + log(LOGTYPELOG, "Disposed purchase manager!"); + } + + @Override + public void purchase(final String identifier) { + if (config == null || observer == null) return; + + Offer offer = config.getOffer(identifier); + if (offer == null) { + observer.handlePurchaseError(new GdxPayException("Unknown offer: " + identifier)); + return; + } + + final String identifierForStore = offer.getIdentifierForStore(PurchaseManagerConfig.STORE_NAME_IOS_APPLE); + StoreKit2ProductInfo product = productsByStoreIdentifier.get(identifierForStore); + if (product == null) { + log(LOGTYPELOG, "Requesting product info for " + identifierForStore); + storeKit2Bridge.fetchProducts(singleton(identifierForStore), new StoreKit2Bridge.ProductsCallback() { + @Override + public void onResult(List products, Throwable error) { + if (error != null) { + observer.handlePurchaseError(new GdxPayException( + "Error requesting product info to later purchase: " + error.getMessage())); + return; + } + + cacheProducts(products); + startPurchase(identifierForStore); + } + }); + return; + } + + startPurchase(identifierForStore); + } + + @Override + public void purchaseRestore() { + log(LOGTYPELOG, "Restoring purchases..."); + storeKit2Bridge.restorePurchases(new StoreKit2Bridge.TransactionsCallback() { + @Override + public void onResult(List transactions, Throwable error) { + if (error != null) { + observer.handleRestoreError(new GdxPayException( + "Restoring of purchases failed: " + error.getMessage())); + return; + } + + observer.handleRestore(toGdxTransactions(transactions)); + } + }); + } + + @Override + public Information getInformation(String identifier) { + StoreKit2ProductInfo product = productsByStoreIdentifier.get(identifier); + if (product == null && config != null && config.getOffer(identifier) != null) { + String storeIdentifier = config.getOffer(identifier) + .getIdentifierForStore(PurchaseManagerConfig.STORE_NAME_IOS_APPLE); + product = productsByStoreIdentifier.get(storeIdentifier); + } + if (product == null) return Information.UNAVAILABLE; + + return Information.newBuilder() + .localName(product.displayName) + .localDescription(product.description) + .localPricing(product.displayPrice) + .priceInCents(priceInCents(product.price)) + .priceAsDouble(product.price != null ? product.price.doubleValue() : null) + .priceCurrencyCode(product.currencyCode) + .freeTrialPeriod(product.freeTrialPeriod) + .build(); + } + + @Override + public String toString() { + return PurchaseManagerConfig.STORE_NAME_IOS_APPLE; + } + + void log(final int type, final String message) { + log(type, message, null); + } + + void log(final int type, final String message, Throwable e) { + if (LOGDEBUG) { + if (type == LOGTYPELOG) System.out.println('[' + TAG + "] " + message); + if (type == LOGTYPEERROR) System.err.println('[' + TAG + "] " + message); + if (e != null) e.printStackTrace(System.err); + } + } + + private void startPurchase(final String identifierForStore) { + log(LOGTYPELOG, "Purchasing product " + identifierForStore + " ..."); + storeKit2Bridge.purchase(identifierForStore, new StoreKit2Bridge.TransactionCallback() { + @Override + public void onResult(StoreKit2TransactionInfo transaction, Throwable error) { + if (error != null) { + observer.handlePurchaseError(new GdxPayException( + "Purchasing product " + identifierForStore + " failed: " + error.getMessage())); + return; + } + + if (transaction == null) { + observer.handlePurchaseError(new GdxPayException( + "Purchasing product " + identifierForStore + " returned no transaction")); + return; + } + + if (transaction.cancelled) { + observer.handlePurchaseCanceled(); + return; + } + + if (transaction.pending) { + log(LOGTYPELOG, "Purchase is pending for " + identifierForStore); + return; + } + + Transaction gdxTransaction = transaction(transaction); + if (gdxTransaction != null) observer.handlePurchase(gdxTransaction); + } + }); + } + + private void observeTransactionUpdates() { + storeKit2Bridge.startObservingTransactions(new StoreKit2Bridge.TransactionCallback() { + @Override + public void onResult(StoreKit2TransactionInfo transaction, Throwable error) { + if (error != null) { + observer.handlePurchaseError(new GdxPayException( + "Transaction update failed: " + error.getMessage())); + return; + } + + Transaction gdxTransaction = transaction(transaction); + if (gdxTransaction != null) observer.handlePurchase(gdxTransaction); + } + }); + } + + private void restoreCurrentEntitlementsOnStartup() { + storeKit2Bridge.fetchCurrentEntitlements(new StoreKit2Bridge.TransactionsCallback() { + @Override + public void onResult(List transactions, Throwable error) { + if (error != null) { + log(LOGTYPEERROR, "Startup entitlement restore failed: " + error.getMessage(), error); + return; + } + + Transaction[] restoredTransactions = toGdxTransactions(transactions); + if (restoredTransactions.length > 0) observer.handleRestore(restoredTransactions); + } + }); + } + + private Transaction[] toGdxTransactions(List transactions) { + List restored = new ArrayList<>(); + for (StoreKit2TransactionInfo transaction : transactions) { + Transaction gdxTransaction = transaction(transaction); + if (gdxTransaction != null) restored.add(gdxTransaction); + } + return restored.toArray(new Transaction[0]); + } + + private Transaction transaction(StoreKit2TransactionInfo info) { + if (info == null) return null; + + Offer offerForStore = config.getOfferForStore(PurchaseManagerConfig.STORE_NAME_IOS_APPLE, info.productId); + if (offerForStore == null) { + log(LOGTYPEERROR, "Product not configured in PurchaseManagerConfig: " + info.productId); + return null; + } + + StoreKit2ProductInfo product = productsByStoreIdentifier.get(info.productId); + + Transaction transaction = new Transaction(); + transaction.setIdentifier(offerForStore.getIdentifier()); + transaction.setStoreName(PurchaseManagerConfig.STORE_NAME_IOS_APPLE); + transaction.setOrderId(info.originalId); + transaction.setPurchaseTime(info.purchaseDate); + transaction.setPurchaseText("Purchased: " + (product != null ? product.displayName : info.productId)); + transaction.setPurchaseCost(product != null ? priceInCents(product.price) : 0); + transaction.setPurchaseCostCurrency(product != null ? product.currencyCode : null); + transaction.setReversalTime(null); + transaction.setReversalText(null); + transaction.setTransactionData(null); + transaction.setTransactionDataSignature(info.jsonRepresentation); + return transaction; + } + + private void cacheProducts(List products) { + for (StoreKit2ProductInfo product : products) { + productsByStoreIdentifier.put(product.id, product); + } + } + + private Collection configuredStoreIdentifiers(PurchaseManagerConfig config) { + int size = config.getOfferCount(); + Set productIdentifiers = new HashSet(size); + for (int i = 0; i < size; i++) { + productIdentifiers.add(config.getOffer(i) + .getIdentifierForStore(PurchaseManagerConfig.STORE_NAME_IOS_APPLE)); + } + return productIdentifiers; + } + + private Collection singleton(String value) { + Set values = new HashSet(1); + values.add(value); + return values; + } + + private int priceInCents(BigDecimal price) { + if (price == null) return 0; + return price.multiply(BigDecimal.valueOf(100D)).setScale(0, RoundingMode.CEILING).intValue(); + } +} diff --git a/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/StoreKit2Bridge.java b/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/StoreKit2Bridge.java new file mode 100644 index 00000000..abaf9a0c --- /dev/null +++ b/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/StoreKit2Bridge.java @@ -0,0 +1,32 @@ +package com.badlogic.gdx.pay.ios.apple; + +import java.util.Collection; +import java.util.List; + +interface StoreKit2Bridge { + boolean canMakePayments(); + + void fetchProducts(Collection identifiers, ProductsCallback callback); + + void purchase(String identifier, TransactionCallback callback); + + void fetchCurrentEntitlements(TransactionsCallback callback); + + void restorePurchases(TransactionsCallback callback); + + void startObservingTransactions(TransactionCallback callback); + + void stopObservingTransactions(); + + interface ProductsCallback { + void onResult(List products, Throwable error); + } + + interface TransactionsCallback { + void onResult(List transactions, Throwable error); + } + + interface TransactionCallback { + void onResult(StoreKit2TransactionInfo transaction, Throwable error); + } +} diff --git a/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/StoreKit2ProductInfo.java b/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/StoreKit2ProductInfo.java new file mode 100644 index 00000000..d99e2359 --- /dev/null +++ b/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/StoreKit2ProductInfo.java @@ -0,0 +1,32 @@ +package com.badlogic.gdx.pay.ios.apple; + +import com.badlogic.gdx.pay.FreeTrialPeriod; + +import java.math.BigDecimal; + +final class StoreKit2ProductInfo { + final String id; + final String displayName; + final String description; + final String displayPrice; + final String currencyCode; + final BigDecimal price; + final FreeTrialPeriod freeTrialPeriod; + + StoreKit2ProductInfo( + String id, + String displayName, + String description, + String displayPrice, + String currencyCode, + BigDecimal price, + FreeTrialPeriod freeTrialPeriod) { + this.id = id; + this.displayName = displayName; + this.description = description; + this.displayPrice = displayPrice; + this.currencyCode = currencyCode; + this.price = price; + this.freeTrialPeriod = freeTrialPeriod; + } +} diff --git a/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/StoreKit2TransactionInfo.java b/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/StoreKit2TransactionInfo.java new file mode 100644 index 00000000..fa83a896 --- /dev/null +++ b/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/StoreKit2TransactionInfo.java @@ -0,0 +1,27 @@ +package com.badlogic.gdx.pay.ios.apple; + +import java.util.Date; + +final class StoreKit2TransactionInfo { + final String productId; + final String originalId; + final Date purchaseDate; + final String jsonRepresentation; + final boolean cancelled; + final boolean pending; + + StoreKit2TransactionInfo( + String productId, + String originalId, + Date purchaseDate, + String jsonRepresentation, + boolean cancelled, + boolean pending) { + this.productId = productId; + this.originalId = originalId; + this.purchaseDate = purchaseDate; + this.jsonRepresentation = jsonRepresentation; + this.cancelled = cancelled; + this.pending = pending; + } +} diff --git a/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/bindings/GDXStoreKit2Bridge.java b/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/bindings/GDXStoreKit2Bridge.java new file mode 100644 index 00000000..38ba9294 --- /dev/null +++ b/gdx-pay-iosmoe-apple/src/main/java/com/badlogic/gdx/pay/ios/apple/bindings/GDXStoreKit2Bridge.java @@ -0,0 +1,86 @@ +package com.badlogic.gdx.pay.ios.apple.bindings; + +import apple.NSObject; +import apple.foundation.NSArray; +import apple.foundation.NSDictionary; +import apple.foundation.NSError; + +import org.moe.natj.general.NatJ; +import org.moe.natj.general.Pointer; +import org.moe.natj.general.ann.Library; +import org.moe.natj.general.ann.Runtime; +import org.moe.natj.objc.ObjCRuntime; +import org.moe.natj.objc.ann.ObjCBlock; +import org.moe.natj.objc.ann.ObjCClassBinding; +import org.moe.natj.objc.ann.ObjCClassName; +import org.moe.natj.objc.ann.Selector; + +@Library("GdxPayStoreKit2Bridge") +@Runtime(ObjCRuntime.class) +@ObjCClassBinding +@ObjCClassName("GDXStoreKit2Bridge") +public class GDXStoreKit2Bridge extends NSObject { + static { + NatJ.register(); + } + + protected GDXStoreKit2Bridge(Pointer peer) { + super(peer); + } + + @Selector("shared") + public static native GDXStoreKit2Bridge shared(); + + @Selector("canMakePayments") + public native boolean canMakePayments(); + + @Selector("fetchProductsWithIdentifiers:completion:") + public native void fetchProductsWithIdentifiersCompletion( + NSArray identifiers, + @ObjCBlock(name = "call_fetchProductsWithIdentifiersCompletion") + Block_fetchProductsWithIdentifiersCompletion completion); + + @Selector("purchaseWithIdentifier:completion:") + public native void purchaseWithIdentifierCompletion( + String identifier, + @ObjCBlock(name = "call_purchaseWithIdentifierCompletion") + Block_purchaseWithIdentifierCompletion completion); + + @Selector("fetchCurrentEntitlementsWithCompletion:") + public native void fetchCurrentEntitlementsWithCompletion( + @ObjCBlock(name = "call_fetchCurrentEntitlementsWithCompletion") + Block_fetchCurrentEntitlementsWithCompletion completion); + + @Selector("restorePurchasesWithCompletion:") + public native void restorePurchasesWithCompletion( + @ObjCBlock(name = "call_restorePurchasesWithCompletion") + Block_restorePurchasesWithCompletion completion); + + @Selector("startObservingTransactionsWithCompletion:") + public native void startObservingTransactionsWithCompletion( + @ObjCBlock(name = "call_startObservingTransactionsWithCompletion") + Block_startObservingTransactionsWithCompletion completion); + + @Selector("stopObservingTransactions") + public native void stopObservingTransactions(); + + public interface Block_fetchProductsWithIdentifiersCompletion { + void call_fetchProductsWithIdentifiersCompletion(NSArray> products, NSError error); + } + + public interface Block_purchaseWithIdentifierCompletion { + void call_purchaseWithIdentifierCompletion(NSDictionary transaction, NSError error); + } + + public interface Block_fetchCurrentEntitlementsWithCompletion { + void call_fetchCurrentEntitlementsWithCompletion(NSArray> transactions, NSError error); + } + + public interface Block_restorePurchasesWithCompletion { + void call_restorePurchasesWithCompletion(NSArray> transactions, NSError error); + } + + public interface Block_startObservingTransactionsWithCompletion { + void call_startObservingTransactionsWithCompletion(NSDictionary transaction, NSError error); + } +} diff --git a/settings.gradle b/settings.gradle index 8216cb4f..ea881edf 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,6 +4,7 @@ include ':gdx-pay-android-amazon' include ':gdx-pay-android-googlebilling' include ':gdx-pay-android-huawei' include ':gdx-pay-iosrobovm-apple' +include ':gdx-pay-iosmoe-apple' include ':gdx-pay-server' rootProject.name = "gdx-pay-root"