diff --git a/Docs/Integration.md b/Docs/Integration.md index 5341d86..8619f7c 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -8,4 +8,33 @@ By default, the package injects its native wrapper through `buildTransitive` on The managed API surface is intentionally small and designed for app-level orchestration. +## Transaction acknowledgement and offer codes + +`PurchaseAsync`, `RestoreAsync`, `GetCurrentEntitlementsAsync`, and +`GetUnfinishedTransactionsAsync` expose the verified transaction id and signed +transaction JWS. `Transaction.updates` exposes the same proof through +`StoreKitTransactionUpdate`. + +The wrapper does not finish successful purchases or transaction updates +automatically. After the consuming app has verified the JWS and durably +acknowledged the entitlement, call: + +```csharp +await client.FinishTransactionAsync(transactionId, cancellationToken); +``` + +This makes temporary backend or network failures recoverable through +`GetUnfinishedTransactionsAsync`. + +On iOS 16 or later, present Apple’s native code-entry sheet with: + +```csharp +var result = await client.PresentOfferCodeRedeemSheetAsync(cancellationToken); +``` + +`Succeeded` means the sheet was presented. Dismissal is not proof of a redeemed +code; observe and verify the resulting transaction instead. Apps supporting +iOS 15 must keep any StoreKit 1 presentation adapter in the app itself; this +StoreKit 2 package does not reference StoreKit 1. + For source-mode usage, see `Docs/SourceMode.md`. diff --git a/README.md b/README.md index 2a053b0..67ba5ae 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ Public OSS repository that packages a StoreKit 2 iOS interop wrapper into a cons A NuGet package that: - provides a minimal managed API for StoreKit 2 purchase and restore flows; +- presents Apple’s offer-code redemption sheet on iOS 16+; +- exposes current entitlements, unfinished verified transactions, transaction ids, and signed JWS payloads; +- lets the consuming app finish a transaction explicitly after its own backend acknowledgement; - exposes store-formatted subscription prices plus an optional annual-to-monthly equivalent formatted with StoreKit's native price style; - redistributes the native wrapper `xcframework` inside the `.nupkg`; - injects the wrapper into consuming apps through `buildTransitive` `NativeReference`. diff --git a/src/Kapusch.StoreKit2ApisForiOSComponents/Native/iOS/KapuschStoreKit2Interop/Sources/KapuschStoreKit2Interop/Interop.swift b/src/Kapusch.StoreKit2ApisForiOSComponents/Native/iOS/KapuschStoreKit2Interop/Sources/KapuschStoreKit2Interop/Interop.swift index a76f9e3..579d607 100644 --- a/src/Kapusch.StoreKit2ApisForiOSComponents/Native/iOS/KapuschStoreKit2Interop/Sources/KapuschStoreKit2Interop/Interop.swift +++ b/src/Kapusch.StoreKit2ApisForiOSComponents/Native/iOS/KapuschStoreKit2Interop/Sources/KapuschStoreKit2Interop/Interop.swift @@ -1,5 +1,6 @@ import Foundation import StoreKit +import UIKit public typealias KapuschStoreKit2PurchaseCallback = @convention(c) ( Int32, @@ -8,6 +9,7 @@ public typealias KapuschStoreKit2PurchaseCallback = @convention(c) ( UnsafePointer?, UnsafePointer?, UnsafePointer?, + UnsafePointer?, UnsafeMutableRawPointer ) -> Void @@ -27,11 +29,19 @@ public typealias KapuschStoreKit2OfferMetadataCallback = @convention(c) ( ) -> Void public typealias KapuschStoreKit2TransactionUpdateCallback = @convention(c) ( + UnsafePointer?, UnsafePointer?, UnsafePointer?, UnsafePointer? ) -> Void +public typealias KapuschStoreKit2OperationCallback = @convention(c) ( + Int32, + UnsafePointer?, + UnsafePointer?, + UnsafeMutableRawPointer +) -> Void + private enum NativeStatus: Int32 { case success = 0 case cancelled = 1 @@ -42,6 +52,7 @@ private enum NativeStatus: Int32 { private struct RestoreTransactionPayload: Codable { let productId: String let originalTransactionId: String + let transactionId: String let signedTransactionInfo: String } @@ -84,6 +95,16 @@ private final class OfferMetadataCallbackContext: @unchecked Sendable { } } +private final class OperationCallbackContext: @unchecked Sendable { + let callback: KapuschStoreKit2OperationCallback + let context: UnsafeMutableRawPointer + + init(callback: @escaping KapuschStoreKit2OperationCallback, context: UnsafeMutableRawPointer) { + self.callback = callback + self.context = context + } +} + @MainActor private var transactionUpdatesTask: Task? @@ -106,24 +127,28 @@ private func callPurchaseCallback( status: NativeStatus, productId: String? = nil, originalTransactionId: String? = nil, + transactionId: String? = nil, signedTransactionInfo: String? = nil, errorCode: String? = nil, errorMessage: String? = nil ) { withCString(productId) { productIdC in withCString(originalTransactionId) { originalTransactionIdC in - withCString(signedTransactionInfo) { signedTransactionInfoC in - withCString(errorCode) { errorCodeC in - withCString(errorMessage) { errorMessageC in - callbackContext.callback( - status.rawValue, - productIdC, - originalTransactionIdC, - signedTransactionInfoC, - errorCodeC, - errorMessageC, - callbackContext.context - ) + withCString(transactionId) { transactionIdC in + withCString(signedTransactionInfo) { signedTransactionInfoC in + withCString(errorCode) { errorCodeC in + withCString(errorMessage) { errorMessageC in + callbackContext.callback( + status.rawValue, + productIdC, + originalTransactionIdC, + transactionIdC, + signedTransactionInfoC, + errorCodeC, + errorMessageC, + callbackContext.context + ) + } } } } @@ -131,6 +156,24 @@ private func callPurchaseCallback( } } +private func callOperationCallback( + _ callbackContext: OperationCallbackContext, + succeeded: Bool, + errorCode: String? = nil, + errorMessage: String? = nil +) { + withCString(errorCode) { errorCodeC in + withCString(errorMessage) { errorMessageC in + callbackContext.callback( + succeeded ? NativeStatus.success.rawValue : NativeStatus.failed.rawValue, + errorCodeC, + errorMessageC, + callbackContext.context + ) + } + } +} + private func callRestoreCallback( _ callbackContext: RestoreCallbackContext, status: NativeStatus, @@ -177,7 +220,8 @@ private func callTransactionUpdateCallback( _ callback: KapuschStoreKit2TransactionUpdateCallback?, productId: String?, originalTransactionId: String?, - transactionId: String? + transactionId: String?, + signedTransactionInfo: String? ) { guard let callback else { return @@ -186,7 +230,9 @@ private func callTransactionUpdateCallback( withCString(productId) { productIdC in withCString(originalTransactionId) { originalTransactionIdC in withCString(transactionId) { transactionIdC in - callback(productIdC, originalTransactionIdC, transactionIdC) + withCString(signedTransactionInfo) { signedTransactionInfoC in + callback(productIdC, originalTransactionIdC, transactionIdC, signedTransactionInfoC) + } } } } @@ -275,6 +321,7 @@ private func collectCurrentEntitlements( RestoreTransactionPayload( productId: transaction.productID, originalTransactionId: String(transaction.originalID), + transactionId: String(transaction.id), signedTransactionInfo: verificationResult.jwsRepresentation ) ) @@ -347,13 +394,12 @@ private func executePurchase( let transactionJws = verificationResult.jwsRepresentation let originalId = String(transaction.originalID) - await transaction.finish() - callPurchaseCallback( callbackContext, status: .success, productId: transaction.productID, originalTransactionId: originalId, + transactionId: String(transaction.id), signedTransactionInfo: transactionJws ) @@ -364,6 +410,7 @@ private func executePurchase( status: .failed, productId: transaction.productID, originalTransactionId: String(transaction.originalID), + transactionId: String(transaction.id), signedTransactionInfo: transactionJws, errorCode: "unverified_transaction", errorMessage: error.localizedDescription @@ -409,12 +456,11 @@ public func kstorekit2_transaction_updates_start( transactionUpdatesCallback, productId: transaction.productID, originalTransactionId: String(transaction.originalID), - transactionId: String(transaction.id) + transactionId: String(transaction.id), + signedTransactionInfo: verificationResult.jwsRepresentation ) } - await transaction.finish() - case .unverified: continue } @@ -423,6 +469,87 @@ public func kstorekit2_transaction_updates_start( } } +@_cdecl("kstorekit2_offer_code_redeem_start") +public func kstorekit2_offer_code_redeem_start( + _ callback: @escaping KapuschStoreKit2OperationCallback, + _ context: UnsafeMutableRawPointer +) { + let callbackContext = OperationCallbackContext(callback: callback, context: context) + + Task { @MainActor in + guard #available(iOS 16.0, *) else { + callOperationCallback( + callbackContext, + succeeded: false, + errorCode: "offer_code_sheet_requires_ios_16", + errorMessage: "The StoreKit 2 offer code sheet requires iOS 16 or later." + ) + return + } + + guard let windowScene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }) else { + callOperationCallback( + callbackContext, + succeeded: false, + errorCode: "active_window_scene_unavailable", + errorMessage: "No active window scene is available to present the offer code sheet." + ) + return + } + + do { + try await AppStore.presentOfferCodeRedeemSheet(in: windowScene) + callOperationCallback(callbackContext, succeeded: true) + } catch { + let nsError = error as NSError + callOperationCallback( + callbackContext, + succeeded: false, + errorCode: "\(nsError.domain):\(nsError.code)", + errorMessage: nsError.localizedDescription + ) + } + } +} + +@_cdecl("kstorekit2_finish_transaction_start") +public func kstorekit2_finish_transaction_start( + _ transactionIdPtr: UnsafePointer?, + _ callback: @escaping KapuschStoreKit2OperationCallback, + _ context: UnsafeMutableRawPointer +) { + let callbackContext = OperationCallbackContext(callback: callback, context: context) + guard let transactionId = sanitizeRequiredCstring(transactionIdPtr) else { + callOperationCallback( + callbackContext, + succeeded: false, + errorCode: "invalid_transaction_id", + errorMessage: "The transaction id is empty." + ) + return + } + + Task { + for await verificationResult in Transaction.unfinished { + guard case .verified(let transaction) = verificationResult else { + continue + } + guard String(transaction.id) == transactionId else { + continue + } + + await transaction.finish() + callOperationCallback(callbackContext, succeeded: true) + return + } + + // Finishing is idempotent. A missing unfinished transaction is already acknowledged. + callOperationCallback(callbackContext, succeeded: true) + } +} + @_cdecl("kstorekit2_purchase_start") public func kstorekit2_purchase_start( _ productIdPtr: UnsafePointer?, @@ -625,6 +752,55 @@ public func kstorekit2_current_entitlements_start( } } +@_cdecl("kstorekit2_unfinished_transactions_start") +public func kstorekit2_unfinished_transactions_start( + _ productIdsJsonPtr: UnsafePointer?, + _ callback: @escaping KapuschStoreKit2RestoreCallback, + _ context: UnsafeMutableRawPointer +) { + let callbackContext = RestoreCallbackContext(callback: callback, context: context) + let filterProductIds = parseProductIdsJson(productIdsJsonPtr) + + Task { + var transactions: [RestoreTransactionPayload] = [] + for await verificationResult in Transaction.unfinished { + guard case .verified(let transaction) = verificationResult else { + continue + } + if !filterProductIds.isEmpty && !filterProductIds.contains(transaction.productID) { + continue + } + + transactions.append( + RestoreTransactionPayload( + productId: transaction.productID, + originalTransactionId: String(transaction.originalID), + transactionId: String(transaction.id), + signedTransactionInfo: verificationResult.jwsRepresentation + ) + ) + } + + do { + let payloadData = try JSONEncoder().encode(transactions) + let payloadJson = String(data: payloadData, encoding: .utf8) + callRestoreCallback( + callbackContext, + status: .success, + payloadJson: payloadJson + ) + } catch { + let nsError = error as NSError + callRestoreCallback( + callbackContext, + status: .failed, + errorCode: "\(nsError.domain):\(nsError.code)", + errorMessage: nsError.localizedDescription + ) + } + } +} + @_cdecl("kstorekit2_offer_metadata_start") public func kstorekit2_offer_metadata_start( _ productIdsJsonPtr: UnsafePointer?, diff --git a/src/Kapusch.StoreKit2ApisForiOSComponents/StoreKit2BillingClient.cs b/src/Kapusch.StoreKit2ApisForiOSComponents/StoreKit2BillingClient.cs index 3dbc61e..f2c4e93 100644 --- a/src/Kapusch.StoreKit2ApisForiOSComponents/StoreKit2BillingClient.cs +++ b/src/Kapusch.StoreKit2ApisForiOSComponents/StoreKit2BillingClient.cs @@ -6,7 +6,17 @@ namespace Kapusch.StoreKit2.iOS; public sealed record StoreKitTransactionUpdate( string? ProductId, string? OriginalTransactionId, - string? TransactionId + string? TransactionId, + string? SignedTransactionInfo +); + +/// +/// Result of a StoreKit operation that does not itself create a purchase. +/// +public sealed record StoreKitOperationResult( + bool Succeeded, + string? ErrorCode = null, + string? ErrorMessage = null ); /// @@ -57,6 +67,20 @@ Task GetCurrentEntitlementsAsync( CancellationToken cancellationToken = default ); + Task GetUnfinishedTransactionsAsync( + IReadOnlyList productIds, + CancellationToken cancellationToken = default + ); + + Task PresentOfferCodeRedeemSheetAsync( + CancellationToken cancellationToken = default + ); + + Task FinishTransactionAsync( + string transactionId, + CancellationToken cancellationToken = default + ); + Task> GetOfferMetadataAsync( IReadOnlyList productIds, CancellationToken cancellationToken = default @@ -104,6 +128,20 @@ public Task GetCurrentEntitlementsAsync( CancellationToken cancellationToken = default ) => StoreKitNativeInterop.GetCurrentEntitlementsAsync(productIds, cancellationToken); + public Task GetUnfinishedTransactionsAsync( + IReadOnlyList productIds, + CancellationToken cancellationToken = default + ) => StoreKitNativeInterop.GetUnfinishedTransactionsAsync(productIds, cancellationToken); + + public Task PresentOfferCodeRedeemSheetAsync( + CancellationToken cancellationToken = default + ) => StoreKitNativeInterop.PresentOfferCodeRedeemSheetAsync(cancellationToken); + + public Task FinishTransactionAsync( + string transactionId, + CancellationToken cancellationToken = default + ) => StoreKitNativeInterop.FinishTransactionAsync(transactionId, cancellationToken); + public Task> GetOfferMetadataAsync( IReadOnlyList productIds, CancellationToken cancellationToken = default diff --git a/src/Kapusch.StoreKit2ApisForiOSComponents/StoreKitNativeInterop.iOS.cs b/src/Kapusch.StoreKit2ApisForiOSComponents/StoreKitNativeInterop.iOS.cs index ffdfc04..587067c 100644 --- a/src/Kapusch.StoreKit2ApisForiOSComponents/StoreKitNativeInterop.iOS.cs +++ b/src/Kapusch.StoreKit2ApisForiOSComponents/StoreKitNativeInterop.iOS.cs @@ -13,6 +13,9 @@ internal sealed class RestorePayloadTransaction [JsonPropertyName("originalTransactionId")] public string? OriginalTransactionId { get; init; } + [JsonPropertyName("transactionId")] + public string? TransactionId { get; init; } + [JsonPropertyName("signedTransactionInfo")] public string? SignedTransactionInfo { get; init; } } @@ -56,6 +59,7 @@ private static partial void PurchaseStart( IntPtr, IntPtr, IntPtr, + IntPtr, void> callback, IntPtr context ); @@ -81,6 +85,7 @@ private static partial void PurchasePromotionalStart( IntPtr, IntPtr, IntPtr, + IntPtr, void> callback, IntPtr context ); @@ -107,6 +112,34 @@ private static partial void CurrentEntitlementsStart( IntPtr context ); + [LibraryImport( + "__Internal", + EntryPoint = "kstorekit2_unfinished_transactions_start", + StringMarshalling = StringMarshalling.Utf8 + )] + private static partial void UnfinishedTransactionsStart( + string productIdsJson, + delegate* unmanaged[Cdecl] callback, + IntPtr context + ); + + [LibraryImport("__Internal", EntryPoint = "kstorekit2_offer_code_redeem_start")] + private static partial void OfferCodeRedeemStart( + delegate* unmanaged[Cdecl] callback, + IntPtr context + ); + + [LibraryImport( + "__Internal", + EntryPoint = "kstorekit2_finish_transaction_start", + StringMarshalling = StringMarshalling.Utf8 + )] + private static partial void FinishTransactionStart( + string transactionId, + delegate* unmanaged[Cdecl] callback, + IntPtr context + ); + [LibraryImport( "__Internal", EntryPoint = "kstorekit2_offer_metadata_start", @@ -120,7 +153,7 @@ IntPtr context [LibraryImport("__Internal", EntryPoint = "kstorekit2_transaction_updates_start")] private static partial void TransactionUpdatesStart( - delegate* unmanaged[Cdecl] callback + delegate* unmanaged[Cdecl] callback ); private static int _transactionUpdatesStarted; @@ -144,7 +177,15 @@ private sealed class OfferMetadataRequestContext( TaskCompletionSource> completion ) { - public TaskCompletionSource> Completion { get; } = completion; + public TaskCompletionSource> Completion { get; } = + completion; + } + + private sealed class OperationRequestContext( + TaskCompletionSource completion + ) + { + public TaskCompletionSource Completion { get; } = completion; } private sealed class CallbackSubscription(Action dispose) : IDisposable @@ -165,14 +206,8 @@ CancellationToken cancellationToken { cancellationToken.ThrowIfCancellationRequested(); var normalizedProductId = ValidateRequiredValue(productId, nameof(productId)); - return StartPurchaseRequest( - context => - PurchaseStart( - normalizedProductId, - appAccountToken, - &OnPurchaseCompleted, - context - ) + return StartPurchaseRequest(context => + PurchaseStart(normalizedProductId, appAccountToken, &OnPurchaseCompleted, context) ); } @@ -211,19 +246,18 @@ CancellationToken cancellationToken ); } - return StartPurchaseRequest( - context => - PurchasePromotionalStart( - normalizedProductId, - appAccountToken, - normalizedOfferId, - normalizedKeyId, - normalizedNonce, - normalizedSignature, - promotionalOffer.Timestamp, - &OnPurchaseCompleted, - context - ) + return StartPurchaseRequest(context => + PurchasePromotionalStart( + normalizedProductId, + appAccountToken, + normalizedOfferId, + normalizedKeyId, + normalizedNonce, + normalizedSignature, + promotionalOffer.Timestamp, + &OnPurchaseCompleted, + context + ) ); } @@ -234,7 +268,9 @@ CancellationToken cancellationToken { cancellationToken.ThrowIfCancellationRequested(); var payloadJson = SerializeProductIds(productIds); - return StartRestoreRequest(context => RestoreStart(payloadJson, &OnRestoreCompleted, context)); + return StartRestoreRequest(context => + RestoreStart(payloadJson, &OnRestoreCompleted, context) + ); } public static Task GetCurrentEntitlementsAsync( @@ -244,9 +280,42 @@ CancellationToken cancellationToken { cancellationToken.ThrowIfCancellationRequested(); var payloadJson = SerializeProductIds(productIds); - return StartRestoreRequest( - context => - CurrentEntitlementsStart(payloadJson, &OnRestoreCompleted, context) + return StartRestoreRequest(context => + CurrentEntitlementsStart(payloadJson, &OnRestoreCompleted, context) + ); + } + + public static Task GetUnfinishedTransactionsAsync( + IReadOnlyList productIds, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + var payloadJson = SerializeProductIds(productIds); + return StartRestoreRequest(context => + UnfinishedTransactionsStart(payloadJson, &OnRestoreCompleted, context) + ); + } + + public static Task PresentOfferCodeRedeemSheetAsync( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + return StartOperationRequest(context => + OfferCodeRedeemStart(&OnOperationCompleted, context) + ); + } + + public static Task FinishTransactionAsync( + string transactionId, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + var normalizedTransactionId = ValidateRequiredValue(transactionId, nameof(transactionId)); + return StartOperationRequest(context => + FinishTransactionStart(normalizedTransactionId, &OnOperationCompleted, context) ); } @@ -295,7 +364,9 @@ CancellationToken cancellationToken return completion.Task; } - public static IDisposable SubscribeToTransactionUpdates(Action handler) + public static IDisposable SubscribeToTransactionUpdates( + Action handler + ) { ArgumentNullException.ThrowIfNull(handler); TransactionUpdated += handler; @@ -320,9 +391,7 @@ public static void EnsureTransactionUpdatesListenerStarted() } } - private static Task StartPurchaseRequest( - Action startNativeCall - ) + private static Task StartPurchaseRequest(Action startNativeCall) { var completion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously @@ -372,6 +441,33 @@ private static Task StartRestoreRequest(Action st return completion.Task; } + private static Task StartOperationRequest( + Action startNativeCall + ) + { + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var requestContext = new OperationRequestContext(completion); + var gcHandle = GCHandle.Alloc(requestContext, GCHandleType.Normal); + + try + { + startNativeCall(GCHandle.ToIntPtr(gcHandle)); + } + catch + { + if (gcHandle.IsAllocated) + { + gcHandle.Free(); + } + + throw; + } + + return completion.Task; + } + private static string SerializeProductIds(IReadOnlyList productIds) { ArgumentNullException.ThrowIfNull(productIds); @@ -382,10 +478,7 @@ private static string SerializeProductIds(IReadOnlyList productIds) .Distinct(StringComparer.Ordinal) .ToArray(); - return JsonSerializer.Serialize( - sanitized, - StoreKitJsonContext.Default.StringArray - ); + return JsonSerializer.Serialize(sanitized, StoreKitJsonContext.Default.StringArray); } private static string ValidateRequiredValue(string? value, string paramName) @@ -399,6 +492,7 @@ private static void OnPurchaseCompleted( int status, IntPtr productId, IntPtr originalTransactionId, + IntPtr transactionId, IntPtr signedTransactionInfo, IntPtr errorCode, IntPtr errorMessage, @@ -422,6 +516,7 @@ IntPtr context MapOutcome(status), PtrToString(productId), PtrToString(originalTransactionId), + PtrToString(transactionId), PtrToString(signedTransactionInfo), PtrToString(errorCode), PtrToString(errorMessage) @@ -438,6 +533,44 @@ IntPtr context } } + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static void OnOperationCompleted( + int status, + IntPtr errorCode, + IntPtr errorMessage, + IntPtr context + ) + { + var gcHandle = GCHandle.FromIntPtr(context); + if (gcHandle.Target is not OperationRequestContext requestContext) + { + if (gcHandle.IsAllocated) + { + gcHandle.Free(); + } + + return; + } + + try + { + requestContext.Completion.TrySetResult( + new StoreKitOperationResult( + status == 0, + PtrToString(errorCode), + PtrToString(errorMessage) + ) + ); + } + finally + { + if (gcHandle.IsAllocated) + { + gcHandle.Free(); + } + } + } + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] private static void OnRestoreCompleted( int status, @@ -502,11 +635,16 @@ IntPtr context { var errorCodeValue = PtrToString(errorCode); var errorMessageValue = PtrToString(errorMessage); - if (!string.IsNullOrWhiteSpace(errorCodeValue) || !string.IsNullOrWhiteSpace(errorMessageValue)) + if ( + !string.IsNullOrWhiteSpace(errorCodeValue) + || !string.IsNullOrWhiteSpace(errorMessageValue) + ) { requestContext.Completion.TrySetException( new InvalidOperationException( - errorMessageValue ?? errorCodeValue ?? "StoreKit offer metadata query failed." + errorMessageValue + ?? errorCodeValue + ?? "StoreKit offer metadata query failed." ) ); return; @@ -527,7 +665,8 @@ IntPtr context private static void OnTransactionUpdated( IntPtr productId, IntPtr originalTransactionId, - IntPtr transactionId + IntPtr transactionId, + IntPtr signedTransactionInfo ) { var handlers = TransactionUpdated; @@ -539,7 +678,8 @@ IntPtr transactionId var update = new StoreKitTransactionUpdate( PtrToString(productId), PtrToString(originalTransactionId), - PtrToString(transactionId) + PtrToString(transactionId), + PtrToString(signedTransactionInfo) ); foreach (var invocation in handlers.GetInvocationList()) @@ -585,11 +725,13 @@ private static IReadOnlyList ParseRestoreTransaction .Where(static item => !string.IsNullOrWhiteSpace(item.ProductId) && !string.IsNullOrWhiteSpace(item.OriginalTransactionId) + && !string.IsNullOrWhiteSpace(item.TransactionId) && !string.IsNullOrWhiteSpace(item.SignedTransactionInfo) ) .Select(static item => new StoreKitRestoreTransaction( item.ProductId!, item.OriginalTransactionId!, + item.TransactionId!, item.SignedTransactionInfo! )) .ToArray(); diff --git a/src/Kapusch.StoreKit2ApisForiOSComponents/StoreKitPurchaseResult.cs b/src/Kapusch.StoreKit2ApisForiOSComponents/StoreKitPurchaseResult.cs index 680dd08..3891e6d 100644 --- a/src/Kapusch.StoreKit2ApisForiOSComponents/StoreKitPurchaseResult.cs +++ b/src/Kapusch.StoreKit2ApisForiOSComponents/StoreKitPurchaseResult.cs @@ -12,6 +12,7 @@ public sealed record StoreKitPurchaseResult( StoreKitInteropOutcome Outcome, string? ProductId, string? OriginalTransactionId, + string? TransactionId, string? SignedTransactionInfo, string? ErrorCode, string? ErrorMessage diff --git a/src/Kapusch.StoreKit2ApisForiOSComponents/StoreKitRestoreResult.cs b/src/Kapusch.StoreKit2ApisForiOSComponents/StoreKitRestoreResult.cs index 57e0e47..feabf89 100644 --- a/src/Kapusch.StoreKit2ApisForiOSComponents/StoreKitRestoreResult.cs +++ b/src/Kapusch.StoreKit2ApisForiOSComponents/StoreKitRestoreResult.cs @@ -3,6 +3,7 @@ namespace Kapusch.StoreKit2.iOS; public sealed record StoreKitRestoreTransaction( string ProductId, string OriginalTransactionId, + string TransactionId, string SignedTransactionInfo ); diff --git a/src/Kapusch.StoreKit2ApisForiOSComponents/nuget-readme.md b/src/Kapusch.StoreKit2ApisForiOSComponents/nuget-readme.md index 856f7db..b03e062 100644 --- a/src/Kapusch.StoreKit2ApisForiOSComponents/nuget-readme.md +++ b/src/Kapusch.StoreKit2ApisForiOSComponents/nuget-readme.md @@ -6,3 +6,6 @@ - Native wrapper: `kstorekit2.xcframework` - Native injection: `buildTransitive` `NativeReference` - Offer metadata: store-formatted current price and optional monthly equivalent for one-year subscriptions +- Offer-code sheet: native StoreKit 2 presentation on iOS 16+ +- Recovery: verified unfinished transactions and signed JWS payloads +- Acknowledgement: explicit transaction finishing after app/backend acceptance