Skip to content

Conversation

@Haidar0096
Copy link

Summary

Adds two new StoreKit 2 features to in_app_purchase_storekit:

  • SK2Transaction.unfinishedTransactions() - Queries only unfinished transactions for better performance
  • SK2PurchaseDetails.appAccountToken - Exposes user UUID for backend integration

Motivation

  1. Performance: Developers often only need unfinished transactions to complete them, not all historical transactions. This mirrors Apple's official Transaction.unfinished API.
  2. User Identification: The ability to set appAccountToken already exists when making purchases, but reading it back from transaction details was missing.

Changes

  • Added pigeon interface method for unfinishedTransactions()
  • Implemented Swift native code using Apple's Transaction.unfinished API
  • Exposed appAccountToken property in SK2PurchaseDetails
  • Added unit tests for both features

Breaking Changes

None. Both features are additive and maintain full backward compatibility.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces two new features from StoreKit 2: the unfinishedTransactions() API and the exposure of appAccountToken on purchase details. The changes are additive, well-implemented, and follow the existing architecture. The implementation correctly spans native Swift code, pigeon interface updates, and Dart-side wrappers, complete with corresponding unit tests. This is a solid contribution that enhances the plugin's capabilities.

Comment on lines 239 to 253
@MainActor in
do {
let transactionsMsgs = await rawUnfinishedTransactions().map {
$0.convertToPigeon(receipt: nil)
}
completion(.success(transactionsMsgs))
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The do block is unnecessary here because rawUnfinishedTransactions() is a non-throwing function. While it doesn't cause a bug in this case, it's a good practice to avoid do-catch structures when no errors can be thrown and caught. If rawUnfinishedTransactions were to throw, the lack of a catch block would prevent the completion handler from being called on an error path. Removing the do block simplifies the code and makes it more robust.

      @MainActor in
      let transactionsMsgs = await rawUnfinishedTransactions().map {
        $0.convertToPigeon(receipt: nil)
      }
      completion(.success(transactionsMsgs))

@Haidar0096
Copy link
Author

Haidar0096 commented Nov 15, 2025

This also partially solves #165355

@Haidar0096 Haidar0096 force-pushed the add-storekit2-transaction-unfinished-and-appaccounttoken branch from dabe171 to fa16cac Compare November 16, 2025 19:00
Copy link
Contributor

@LouiseHsu LouiseHsu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good!! Thank you so much for your contribution. I took a look, and the test failures seems to be half swift formatting errors and pigeon files. Could you upgrade pigeon and regenerate the files + fix the swift formatting errors? Otherwise looks good!

@Haidar0096 Haidar0096 force-pushed the add-storekit2-transaction-unfinished-and-appaccounttoken branch from fa16cac to 1c0aecc Compare November 17, 2025 10:24
@Haidar0096
Copy link
Author

Haidar0096 commented Nov 17, 2025

@LouiseHsu Thanks for the kind words!

Problem with updating pigeon to the latest version is that when I run dart run pigeon --input pigeons/sk2_pigeon.dart
It is generating equality methods that are causing compilation error

I downgraded pigeon until I found a working version (i.e. version before the introduction of the generation of == method)

Here is one of the error logs when running on real device after updating pigeon to the latest version 26.1.0:

Swift Compiler Error (Xcode): Invalid redeclaration of '=='
<path to packages folder>/flutter_packages/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/sk2_pigeon.g.swift:242:14
Generated file that is causing the error
// Copyright 2013 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Autogenerated from Pigeon (v25.5.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon

import Foundation

#if os(iOS)
  import Flutter
#elseif os(macOS)
  import FlutterMacOS
#else
  #error("Unsupported platform.")
#endif

/// Error class for passing custom error details to Dart side.
final class PigeonError: Error {
  let code: String
  let message: String?
  let details: Sendable?

  init(code: String, message: String?, details: Sendable?) {
    self.code = code
    self.message = message
    self.details = details
  }

  var localizedDescription: String {
    return
      "PigeonError(code: \(code), message: \(message ?? "<nil>"), details: \(details ?? "<nil>")"
  }
}

private func wrapResult(_ result: Any?) -> [Any?] {
  return [result]
}

private func wrapError(_ error: Any) -> [Any?] {
  if let pigeonError = error as? PigeonError {
    return [
      pigeonError.code,
      pigeonError.message,
      pigeonError.details,
    ]
  }
  if let flutterError = error as? FlutterError {
    return [
      flutterError.code,
      flutterError.message,
      flutterError.details,
    ]
  }
  return [
    "\(error)",
    "\(type(of: error))",
    "Stacktrace: \(Thread.callStackSymbols)",
  ]
}

private func createConnectionError(withChannelName channelName: String) -> PigeonError {
  return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "")
}

private func isNullish(_ value: Any?) -> Bool {
  return value is NSNull || value == nil
}

private func nilOrValue<T>(_ value: Any?) -> T? {
  if value is NSNull { return nil }
  return value as! T?
}

func deepEqualsSk2Pigeon(_ lhs: Any?, _ rhs: Any?) -> Bool {
  let cleanLhs = nilOrValue(lhs) as Any?
  let cleanRhs = nilOrValue(rhs) as Any?
  switch (cleanLhs, cleanRhs) {
  case (nil, nil):
    return true

  case (nil, _), (_, nil):
    return false

  case is (Void, Void):
    return true

  case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable):
    return cleanLhsHashable == cleanRhsHashable

  case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]):
    guard cleanLhsArray.count == cleanRhsArray.count else { return false }
    for (index, element) in cleanLhsArray.enumerated() {
      if !deepEqualsSk2Pigeon(element, cleanRhsArray[index]) {
        return false
      }
    }
    return true

  case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]):
    guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false }
    for (key, cleanLhsValue) in cleanLhsDictionary {
      guard cleanRhsDictionary.index(forKey: key) != nil else { return false }
      if !deepEqualsSk2Pigeon(cleanLhsValue, cleanRhsDictionary[key]!) {
        return false
      }
    }
    return true

  default:
    // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue.
    return false
  }
}

func deepHashSk2Pigeon(value: Any?, hasher: inout Hasher) {
  if let valueList = value as? [AnyHashable] {
     for item in valueList { deepHashSk2Pigeon(value: item, hasher: &hasher) }
     return
  }

  if let valueDict = value as? [AnyHashable: AnyHashable] {
    for key in valueDict.keys { 
      hasher.combine(key)
      deepHashSk2Pigeon(value: valueDict[key]!, hasher: &hasher)
    }
    return
  }

  if let hashableValue = value as? AnyHashable {
    hasher.combine(hashableValue.hashValue)
  }

  return hasher.combine(String(describing: value))
}

    

enum SK2ProductTypeMessage: Int {
  /// A consumable in-app purchase.
  case consumable = 0
  /// A non-consumable in-app purchase.
  case nonConsumable = 1
  /// A non-renewing subscription.
  case nonRenewable = 2
  /// An auto-renewable subscription.
  case autoRenewable = 3
}

enum SK2SubscriptionOfferTypeMessage: Int {
  case introductory = 0
  case promotional = 1
  case winBack = 2
}

enum SK2SubscriptionOfferPaymentModeMessage: Int {
  case payAsYouGo = 0
  case payUpFront = 1
  case freeTrial = 2
}

enum SK2SubscriptionPeriodUnitMessage: Int {
  case day = 0
  case week = 1
  case month = 2
  case year = 3
}

enum SK2ProductPurchaseResultMessage: Int {
  case success = 0
  case unverified = 1
  case userCancelled = 2
  case pending = 3
}

/// Generated class from Pigeon that represents data sent in messages.
struct SK2SubscriptionOfferMessage: Hashable {
  var id: String? = nil
  var price: Double
  var type: SK2SubscriptionOfferTypeMessage
  var period: SK2SubscriptionPeriodMessage
  var periodCount: Int64
  var paymentMode: SK2SubscriptionOfferPaymentModeMessage


  // swift-format-ignore: AlwaysUseLowerCamelCase
  static func fromList(_ pigeonVar_list: [Any?]) -> SK2SubscriptionOfferMessage? {
    let id: String? = nilOrValue(pigeonVar_list[0])
    let price = pigeonVar_list[1] as! Double
    let type = pigeonVar_list[2] as! SK2SubscriptionOfferTypeMessage
    let period = pigeonVar_list[3] as! SK2SubscriptionPeriodMessage
    let periodCount = pigeonVar_list[4] as! Int64
    let paymentMode = pigeonVar_list[5] as! SK2SubscriptionOfferPaymentModeMessage

    return SK2SubscriptionOfferMessage(
      id: id,
      price: price,
      type: type,
      period: period,
      periodCount: periodCount,
      paymentMode: paymentMode
    )
  }
  func toList() -> [Any?] {
    return [
      id,
      price,
      type,
      period,
      periodCount,
      paymentMode,
    ]
  }
  static func == (lhs: SK2SubscriptionOfferMessage, rhs: SK2SubscriptionOfferMessage) -> Bool {
    return deepEqualsSk2Pigeon(lhs.toList(), rhs.toList())  }
  func hash(into hasher: inout Hasher) {
    deepHashSk2Pigeon(value: toList(), hasher: &hasher)
  }
}

/// Generated class from Pigeon that represents data sent in messages.
struct SK2SubscriptionPeriodMessage: Hashable {
  /// The number of units that the period represents.
  var value: Int64
  /// The unit of time that this period represents.
  var unit: SK2SubscriptionPeriodUnitMessage


  // swift-format-ignore: AlwaysUseLowerCamelCase
  static func fromList(_ pigeonVar_list: [Any?]) -> SK2SubscriptionPeriodMessage? {
    let value = pigeonVar_list[0] as! Int64
    let unit = pigeonVar_list[1] as! SK2SubscriptionPeriodUnitMessage

    return SK2SubscriptionPeriodMessage(
      value: value,
      unit: unit
    )
  }
  func toList() -> [Any?] {
    return [
      value,
      unit,
    ]
  }
  static func == (lhs: SK2SubscriptionPeriodMessage, rhs: SK2SubscriptionPeriodMessage) -> Bool {
    return deepEqualsSk2Pigeon(lhs.toList(), rhs.toList())  }
  func hash(into hasher: inout Hasher) {
    deepHashSk2Pigeon(value: toList(), hasher: &hasher)
  }
}

/// Generated class from Pigeon that represents data sent in messages.
struct SK2SubscriptionInfoMessage: Hashable {
  /// An array of all the promotional offers configured for this subscription.
  var promotionalOffers: [SK2SubscriptionOfferMessage]
  /// The group identifier for this subscription.
  var subscriptionGroupID: String
  /// The duration that this subscription lasts before auto-renewing.
  var subscriptionPeriod: SK2SubscriptionPeriodMessage


  // swift-format-ignore: AlwaysUseLowerCamelCase
  static func fromList(_ pigeonVar_list: [Any?]) -> SK2SubscriptionInfoMessage? {
    let promotionalOffers = pigeonVar_list[0] as! [SK2SubscriptionOfferMessage]
    let subscriptionGroupID = pigeonVar_list[1] as! String
    let subscriptionPeriod = pigeonVar_list[2] as! SK2SubscriptionPeriodMessage

    return SK2SubscriptionInfoMessage(
      promotionalOffers: promotionalOffers,
      subscriptionGroupID: subscriptionGroupID,
      subscriptionPeriod: subscriptionPeriod
    )
  }
  func toList() -> [Any?] {
    return [
      promotionalOffers,
      subscriptionGroupID,
      subscriptionPeriod,
    ]
  }
  static func == (lhs: SK2SubscriptionInfoMessage, rhs: SK2SubscriptionInfoMessage) -> Bool {
    return deepEqualsSk2Pigeon(lhs.toList(), rhs.toList())  }
  func hash(into hasher: inout Hasher) {
    deepHashSk2Pigeon(value: toList(), hasher: &hasher)
  }
}

/// A Pigeon message class representing a Product
/// https://developer.apple.com/documentation/storekit/product
///
/// Generated class from Pigeon that represents data sent in messages.
struct SK2ProductMessage: Hashable {
  /// The unique product identifier.
  var id: String
  /// The localized display name of the product, if it exists.
  var displayName: String
  /// The localized description of the product.
  var description: String
  /// The localized string representation of the product price, suitable for display.
  var price: Double
  /// The localized price of the product as a string.
  var displayPrice: String
  /// The types of in-app purchases.
  var type: SK2ProductTypeMessage
  /// The subscription information for an auto-renewable subscription.
  var subscription: SK2SubscriptionInfoMessage? = nil
  /// The currency and locale information for this product
  var priceLocale: SK2PriceLocaleMessage


  // swift-format-ignore: AlwaysUseLowerCamelCase
  static func fromList(_ pigeonVar_list: [Any?]) -> SK2ProductMessage? {
    let id = pigeonVar_list[0] as! String
    let displayName = pigeonVar_list[1] as! String
    let description = pigeonVar_list[2] as! String
    let price = pigeonVar_list[3] as! Double
    let displayPrice = pigeonVar_list[4] as! String
    let type = pigeonVar_list[5] as! SK2ProductTypeMessage
    let subscription: SK2SubscriptionInfoMessage? = nilOrValue(pigeonVar_list[6])
    let priceLocale = pigeonVar_list[7] as! SK2PriceLocaleMessage

    return SK2ProductMessage(
      id: id,
      displayName: displayName,
      description: description,
      price: price,
      displayPrice: displayPrice,
      type: type,
      subscription: subscription,
      priceLocale: priceLocale
    )
  }
  func toList() -> [Any?] {
    return [
      id,
      displayName,
      description,
      price,
      displayPrice,
      type,
      subscription,
      priceLocale,
    ]
  }
  static func == (lhs: SK2ProductMessage, rhs: SK2ProductMessage) -> Bool {
    return deepEqualsSk2Pigeon(lhs.toList(), rhs.toList())  }
  func hash(into hasher: inout Hasher) {
    deepHashSk2Pigeon(value: toList(), hasher: &hasher)
  }
}

/// Generated class from Pigeon that represents data sent in messages.
struct SK2PriceLocaleMessage: Hashable {
  var currencyCode: String
  var currencySymbol: String


  // swift-format-ignore: AlwaysUseLowerCamelCase
  static func fromList(_ pigeonVar_list: [Any?]) -> SK2PriceLocaleMessage? {
    let currencyCode = pigeonVar_list[0] as! String
    let currencySymbol = pigeonVar_list[1] as! String

    return SK2PriceLocaleMessage(
      currencyCode: currencyCode,
      currencySymbol: currencySymbol
    )
  }
  func toList() -> [Any?] {
    return [
      currencyCode,
      currencySymbol,
    ]
  }
  static func == (lhs: SK2PriceLocaleMessage, rhs: SK2PriceLocaleMessage) -> Bool {
    return deepEqualsSk2Pigeon(lhs.toList(), rhs.toList())  }
  func hash(into hasher: inout Hasher) {
    deepHashSk2Pigeon(value: toList(), hasher: &hasher)
  }
}

/// A Pigeon message class representing a Signature
/// https://developer.apple.com/documentation/storekit/product/subscriptionoffer/signature
///
/// Generated class from Pigeon that represents data sent in messages.
struct SK2SubscriptionOfferSignatureMessage: Hashable {
  var keyID: String
  var nonce: String
  var timestamp: Int64
  var signature: String


  // swift-format-ignore: AlwaysUseLowerCamelCase
  static func fromList(_ pigeonVar_list: [Any?]) -> SK2SubscriptionOfferSignatureMessage? {
    let keyID = pigeonVar_list[0] as! String
    let nonce = pigeonVar_list[1] as! String
    let timestamp = pigeonVar_list[2] as! Int64
    let signature = pigeonVar_list[3] as! String

    return SK2SubscriptionOfferSignatureMessage(
      keyID: keyID,
      nonce: nonce,
      timestamp: timestamp,
      signature: signature
    )
  }
  func toList() -> [Any?] {
    return [
      keyID,
      nonce,
      timestamp,
      signature,
    ]
  }
  static func == (lhs: SK2SubscriptionOfferSignatureMessage, rhs: SK2SubscriptionOfferSignatureMessage) -> Bool {
    return deepEqualsSk2Pigeon(lhs.toList(), rhs.toList())  }
  func hash(into hasher: inout Hasher) {
    deepHashSk2Pigeon(value: toList(), hasher: &hasher)
  }
}

/// Generated class from Pigeon that represents data sent in messages.
struct SK2SubscriptionOfferPurchaseMessage: Hashable {
  var promotionalOfferId: String
  var promotionalOfferSignature: SK2SubscriptionOfferSignatureMessage


  // swift-format-ignore: AlwaysUseLowerCamelCase
  static func fromList(_ pigeonVar_list: [Any?]) -> SK2SubscriptionOfferPurchaseMessage? {
    let promotionalOfferId = pigeonVar_list[0] as! String
    let promotionalOfferSignature = pigeonVar_list[1] as! SK2SubscriptionOfferSignatureMessage

    return SK2SubscriptionOfferPurchaseMessage(
      promotionalOfferId: promotionalOfferId,
      promotionalOfferSignature: promotionalOfferSignature
    )
  }
  func toList() -> [Any?] {
    return [
      promotionalOfferId,
      promotionalOfferSignature,
    ]
  }
  static func == (lhs: SK2SubscriptionOfferPurchaseMessage, rhs: SK2SubscriptionOfferPurchaseMessage) -> Bool {
    return deepEqualsSk2Pigeon(lhs.toList(), rhs.toList())  }
  func hash(into hasher: inout Hasher) {
    deepHashSk2Pigeon(value: toList(), hasher: &hasher)
  }
}

/// Generated class from Pigeon that represents data sent in messages.
struct SK2ProductPurchaseOptionsMessage: Hashable {
  var appAccountToken: String? = nil
  var quantity: Int64? = nil
  var promotionalOffer: SK2SubscriptionOfferPurchaseMessage? = nil
  var winBackOfferId: String? = nil


  // swift-format-ignore: AlwaysUseLowerCamelCase
  static func fromList(_ pigeonVar_list: [Any?]) -> SK2ProductPurchaseOptionsMessage? {
    let appAccountToken: String? = nilOrValue(pigeonVar_list[0])
    let quantity: Int64? = nilOrValue(pigeonVar_list[1])
    let promotionalOffer: SK2SubscriptionOfferPurchaseMessage? = nilOrValue(pigeonVar_list[2])
    let winBackOfferId: String? = nilOrValue(pigeonVar_list[3])

    return SK2ProductPurchaseOptionsMessage(
      appAccountToken: appAccountToken,
      quantity: quantity,
      promotionalOffer: promotionalOffer,
      winBackOfferId: winBackOfferId
    )
  }
  func toList() -> [Any?] {
    return [
      appAccountToken,
      quantity,
      promotionalOffer,
      winBackOfferId,
    ]
  }
  static func == (lhs: SK2ProductPurchaseOptionsMessage, rhs: SK2ProductPurchaseOptionsMessage) -> Bool {
    return deepEqualsSk2Pigeon(lhs.toList(), rhs.toList())  }
  func hash(into hasher: inout Hasher) {
    deepHashSk2Pigeon(value: toList(), hasher: &hasher)
  }
}

/// Generated class from Pigeon that represents data sent in messages.
struct SK2TransactionMessage: Hashable {
  var id: Int64
  var originalId: Int64
  var productId: String
  var purchaseDate: String
  var expirationDate: String? = nil
  var purchasedQuantity: Int64
  var appAccountToken: String? = nil
  var restoring: Bool
  var receiptData: String? = nil
  var error: SK2ErrorMessage? = nil
  var jsonRepresentation: String? = nil


  // swift-format-ignore: AlwaysUseLowerCamelCase
  static func fromList(_ pigeonVar_list: [Any?]) -> SK2TransactionMessage? {
    let id = pigeonVar_list[0] as! Int64
    let originalId = pigeonVar_list[1] as! Int64
    let productId = pigeonVar_list[2] as! String
    let purchaseDate = pigeonVar_list[3] as! String
    let expirationDate: String? = nilOrValue(pigeonVar_list[4])
    let purchasedQuantity = pigeonVar_list[5] as! Int64
    let appAccountToken: String? = nilOrValue(pigeonVar_list[6])
    let restoring = pigeonVar_list[7] as! Bool
    let receiptData: String? = nilOrValue(pigeonVar_list[8])
    let error: SK2ErrorMessage? = nilOrValue(pigeonVar_list[9])
    let jsonRepresentation: String? = nilOrValue(pigeonVar_list[10])

    return SK2TransactionMessage(
      id: id,
      originalId: originalId,
      productId: productId,
      purchaseDate: purchaseDate,
      expirationDate: expirationDate,
      purchasedQuantity: purchasedQuantity,
      appAccountToken: appAccountToken,
      restoring: restoring,
      receiptData: receiptData,
      error: error,
      jsonRepresentation: jsonRepresentation
    )
  }
  func toList() -> [Any?] {
    return [
      id,
      originalId,
      productId,
      purchaseDate,
      expirationDate,
      purchasedQuantity,
      appAccountToken,
      restoring,
      receiptData,
      error,
      jsonRepresentation,
    ]
  }
  static func == (lhs: SK2TransactionMessage, rhs: SK2TransactionMessage) -> Bool {
    return deepEqualsSk2Pigeon(lhs.toList(), rhs.toList())  }
  func hash(into hasher: inout Hasher) {
    deepHashSk2Pigeon(value: toList(), hasher: &hasher)
  }
}

/// Generated class from Pigeon that represents data sent in messages.
struct SK2ErrorMessage: Hashable {
  var code: Int64
  var domain: String
  var userInfo: [String: Any]? = nil


  // swift-format-ignore: AlwaysUseLowerCamelCase
  static func fromList(_ pigeonVar_list: [Any?]) -> SK2ErrorMessage? {
    let code = pigeonVar_list[0] as! Int64
    let domain = pigeonVar_list[1] as! String
    let userInfo: [String: Any]? = nilOrValue(pigeonVar_list[2])

    return SK2ErrorMessage(
      code: code,
      domain: domain,
      userInfo: userInfo
    )
  }
  func toList() -> [Any?] {
    return [
      code,
      domain,
      userInfo,
    ]
  }
  static func == (lhs: SK2ErrorMessage, rhs: SK2ErrorMessage) -> Bool {
    return deepEqualsSk2Pigeon(lhs.toList(), rhs.toList())  }
  func hash(into hasher: inout Hasher) {
    deepHashSk2Pigeon(value: toList(), hasher: &hasher)
  }
}

private class Sk2PigeonPigeonCodecReader: FlutterStandardReader {
  override func readValue(ofType type: UInt8) -> Any? {
    switch type {
    case 129:
      let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
      if let enumResultAsInt = enumResultAsInt {
        return SK2ProductTypeMessage(rawValue: enumResultAsInt)
      }
      return nil
    case 130:
      let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
      if let enumResultAsInt = enumResultAsInt {
        return SK2SubscriptionOfferTypeMessage(rawValue: enumResultAsInt)
      }
      return nil
    case 131:
      let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
      if let enumResultAsInt = enumResultAsInt {
        return SK2SubscriptionOfferPaymentModeMessage(rawValue: enumResultAsInt)
      }
      return nil
    case 132:
      let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
      if let enumResultAsInt = enumResultAsInt {
        return SK2SubscriptionPeriodUnitMessage(rawValue: enumResultAsInt)
      }
      return nil
    case 133:
      let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
      if let enumResultAsInt = enumResultAsInt {
        return SK2ProductPurchaseResultMessage(rawValue: enumResultAsInt)
      }
      return nil
    case 134:
      return SK2SubscriptionOfferMessage.fromList(self.readValue() as! [Any?])
    case 135:
      return SK2SubscriptionPeriodMessage.fromList(self.readValue() as! [Any?])
    case 136:
      return SK2SubscriptionInfoMessage.fromList(self.readValue() as! [Any?])
    case 137:
      return SK2ProductMessage.fromList(self.readValue() as! [Any?])
    case 138:
      return SK2PriceLocaleMessage.fromList(self.readValue() as! [Any?])
    case 139:
      return SK2SubscriptionOfferSignatureMessage.fromList(self.readValue() as! [Any?])
    case 140:
      return SK2SubscriptionOfferPurchaseMessage.fromList(self.readValue() as! [Any?])
    case 141:
      return SK2ProductPurchaseOptionsMessage.fromList(self.readValue() as! [Any?])
    case 142:
      return SK2TransactionMessage.fromList(self.readValue() as! [Any?])
    case 143:
      return SK2ErrorMessage.fromList(self.readValue() as! [Any?])
    default:
      return super.readValue(ofType: type)
    }
  }
}

private class Sk2PigeonPigeonCodecWriter: FlutterStandardWriter {
  override func writeValue(_ value: Any) {
    if let value = value as? SK2ProductTypeMessage {
      super.writeByte(129)
      super.writeValue(value.rawValue)
    } else if let value = value as? SK2SubscriptionOfferTypeMessage {
      super.writeByte(130)
      super.writeValue(value.rawValue)
    } else if let value = value as? SK2SubscriptionOfferPaymentModeMessage {
      super.writeByte(131)
      super.writeValue(value.rawValue)
    } else if let value = value as? SK2SubscriptionPeriodUnitMessage {
      super.writeByte(132)
      super.writeValue(value.rawValue)
    } else if let value = value as? SK2ProductPurchaseResultMessage {
      super.writeByte(133)
      super.writeValue(value.rawValue)
    } else if let value = value as? SK2SubscriptionOfferMessage {
      super.writeByte(134)
      super.writeValue(value.toList())
    } else if let value = value as? SK2SubscriptionPeriodMessage {
      super.writeByte(135)
      super.writeValue(value.toList())
    } else if let value = value as? SK2SubscriptionInfoMessage {
      super.writeByte(136)
      super.writeValue(value.toList())
    } else if let value = value as? SK2ProductMessage {
      super.writeByte(137)
      super.writeValue(value.toList())
    } else if let value = value as? SK2PriceLocaleMessage {
      super.writeByte(138)
      super.writeValue(value.toList())
    } else if let value = value as? SK2SubscriptionOfferSignatureMessage {
      super.writeByte(139)
      super.writeValue(value.toList())
    } else if let value = value as? SK2SubscriptionOfferPurchaseMessage {
      super.writeByte(140)
      super.writeValue(value.toList())
    } else if let value = value as? SK2ProductPurchaseOptionsMessage {
      super.writeByte(141)
      super.writeValue(value.toList())
    } else if let value = value as? SK2TransactionMessage {
      super.writeByte(142)
      super.writeValue(value.toList())
    } else if let value = value as? SK2ErrorMessage {
      super.writeByte(143)
      super.writeValue(value.toList())
    } else {
      super.writeValue(value)
    }
  }
}

private class Sk2PigeonPigeonCodecReaderWriter: FlutterStandardReaderWriter {
  override func reader(with data: Data) -> FlutterStandardReader {
    return Sk2PigeonPigeonCodecReader(data: data)
  }

  override func writer(with data: NSMutableData) -> FlutterStandardWriter {
    return Sk2PigeonPigeonCodecWriter(data: data)
  }
}

class Sk2PigeonPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
  static let shared = Sk2PigeonPigeonCodec(readerWriter: Sk2PigeonPigeonCodecReaderWriter())
}


/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol InAppPurchase2API {
  func canMakePayments() throws -> Bool
  func products(identifiers: [String], completion: @escaping (Result<[SK2ProductMessage], Error>) -> Void)
  func purchase(id: String, options: SK2ProductPurchaseOptionsMessage?, completion: @escaping (Result<SK2ProductPurchaseResultMessage, Error>) -> Void)
  func isWinBackOfferEligible(productId: String, offerId: String, completion: @escaping (Result<Bool, Error>) -> Void)
  func isIntroductoryOfferEligible(productId: String, completion: @escaping (Result<Bool, Error>) -> Void)
  func transactions(completion: @escaping (Result<[SK2TransactionMessage], Error>) -> Void)
  func unfinishedTransactions(completion: @escaping (Result<[SK2TransactionMessage], Error>) -> Void)
  func finish(id: Int64, completion: @escaping (Result<Void, Error>) -> Void)
  func startListeningToTransactions() throws
  func stopListeningToTransactions() throws
  func restorePurchases(completion: @escaping (Result<Void, Error>) -> Void)
  func countryCode(completion: @escaping (Result<String, Error>) -> Void)
  func sync(completion: @escaping (Result<Void, Error>) -> Void)
}

/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class InAppPurchase2APISetup {
  static var codec: FlutterStandardMessageCodec { Sk2PigeonPigeonCodec.shared }
  /// Sets up an instance of `InAppPurchase2API` to handle messages through the `binaryMessenger`.
  static func setUp(binaryMessenger: FlutterBinaryMessenger, api: InAppPurchase2API?, messageChannelSuffix: String = "") {
    let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
    let canMakePaymentsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.canMakePayments\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
    if let api = api {
      canMakePaymentsChannel.setMessageHandler { _, reply in
        do {
          let result = try api.canMakePayments()
          reply(wrapResult(result))
        } catch {
          reply(wrapError(error))
        }
      }
    } else {
      canMakePaymentsChannel.setMessageHandler(nil)
    }
    let productsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.products\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
    if let api = api {
      productsChannel.setMessageHandler { message, reply in
        let args = message as! [Any?]
        let identifiersArg = args[0] as! [String]
        api.products(identifiers: identifiersArg) { result in
          switch result {
          case .success(let res):
            reply(wrapResult(res))
          case .failure(let error):
            reply(wrapError(error))
          }
        }
      }
    } else {
      productsChannel.setMessageHandler(nil)
    }
    let purchaseChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.purchase\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
    if let api = api {
      purchaseChannel.setMessageHandler { message, reply in
        let args = message as! [Any?]
        let idArg = args[0] as! String
        let optionsArg: SK2ProductPurchaseOptionsMessage? = nilOrValue(args[1])
        api.purchase(id: idArg, options: optionsArg) { result in
          switch result {
          case .success(let res):
            reply(wrapResult(res))
          case .failure(let error):
            reply(wrapError(error))
          }
        }
      }
    } else {
      purchaseChannel.setMessageHandler(nil)
    }
    let isWinBackOfferEligibleChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isWinBackOfferEligible\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
    if let api = api {
      isWinBackOfferEligibleChannel.setMessageHandler { message, reply in
        let args = message as! [Any?]
        let productIdArg = args[0] as! String
        let offerIdArg = args[1] as! String
        api.isWinBackOfferEligible(productId: productIdArg, offerId: offerIdArg) { result in
          switch result {
          case .success(let res):
            reply(wrapResult(res))
          case .failure(let error):
            reply(wrapError(error))
          }
        }
      }
    } else {
      isWinBackOfferEligibleChannel.setMessageHandler(nil)
    }
    let isIntroductoryOfferEligibleChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isIntroductoryOfferEligible\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
    if let api = api {
      isIntroductoryOfferEligibleChannel.setMessageHandler { message, reply in
        let args = message as! [Any?]
        let productIdArg = args[0] as! String
        api.isIntroductoryOfferEligible(productId: productIdArg) { result in
          switch result {
          case .success(let res):
            reply(wrapResult(res))
          case .failure(let error):
            reply(wrapError(error))
          }
        }
      }
    } else {
      isIntroductoryOfferEligibleChannel.setMessageHandler(nil)
    }
    let transactionsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.transactions\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
    if let api = api {
      transactionsChannel.setMessageHandler { _, reply in
        api.transactions { result in
          switch result {
          case .success(let res):
            reply(wrapResult(res))
          case .failure(let error):
            reply(wrapError(error))
          }
        }
      }
    } else {
      transactionsChannel.setMessageHandler(nil)
    }
    let unfinishedTransactionsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.unfinishedTransactions\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
    if let api = api {
      unfinishedTransactionsChannel.setMessageHandler { _, reply in
        api.unfinishedTransactions { result in
          switch result {
          case .success(let res):
            reply(wrapResult(res))
          case .failure(let error):
            reply(wrapError(error))
          }
        }
      }
    } else {
      unfinishedTransactionsChannel.setMessageHandler(nil)
    }
    let finishChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.finish\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
    if let api = api {
      finishChannel.setMessageHandler { message, reply in
        let args = message as! [Any?]
        let idArg = args[0] as! Int64
        api.finish(id: idArg) { result in
          switch result {
          case .success:
            reply(wrapResult(nil))
          case .failure(let error):
            reply(wrapError(error))
          }
        }
      }
    } else {
      finishChannel.setMessageHandler(nil)
    }
    let startListeningToTransactionsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.startListeningToTransactions\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
    if let api = api {
      startListeningToTransactionsChannel.setMessageHandler { _, reply in
        do {
          try api.startListeningToTransactions()
          reply(wrapResult(nil))
        } catch {
          reply(wrapError(error))
        }
      }
    } else {
      startListeningToTransactionsChannel.setMessageHandler(nil)
    }
    let stopListeningToTransactionsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.stopListeningToTransactions\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
    if let api = api {
      stopListeningToTransactionsChannel.setMessageHandler { _, reply in
        do {
          try api.stopListeningToTransactions()
          reply(wrapResult(nil))
        } catch {
          reply(wrapError(error))
        }
      }
    } else {
      stopListeningToTransactionsChannel.setMessageHandler(nil)
    }
    let restorePurchasesChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.restorePurchases\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
    if let api = api {
      restorePurchasesChannel.setMessageHandler { _, reply in
        api.restorePurchases { result in
          switch result {
          case .success:
            reply(wrapResult(nil))
          case .failure(let error):
            reply(wrapError(error))
          }
        }
      }
    } else {
      restorePurchasesChannel.setMessageHandler(nil)
    }
    let countryCodeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.countryCode\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
    if let api = api {
      countryCodeChannel.setMessageHandler { _, reply in
        api.countryCode { result in
          switch result {
          case .success(let res):
            reply(wrapResult(res))
          case .failure(let error):
            reply(wrapError(error))
          }
        }
      }
    } else {
      countryCodeChannel.setMessageHandler(nil)
    }
    let syncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.sync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
    if let api = api {
      syncChannel.setMessageHandler { _, reply in
        api.sync { result in
          switch result {
          case .success:
            reply(wrapResult(nil))
          case .failure(let error):
            reply(wrapError(error))
          }
        }
      }
    } else {
      syncChannel.setMessageHandler(nil)
    }
  }
}
/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift.
protocol InAppPurchase2CallbackAPIProtocol {
  func onTransactionsUpdated(newTransactions newTransactionsArg: [SK2TransactionMessage], completion: @escaping (Result<Void, PigeonError>) -> Void)
}
class InAppPurchase2CallbackAPI: InAppPurchase2CallbackAPIProtocol {
  private let binaryMessenger: FlutterBinaryMessenger
  private let messageChannelSuffix: String
  init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") {
    self.binaryMessenger = binaryMessenger
    self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
  }
  var codec: Sk2PigeonPigeonCodec {
    return Sk2PigeonPigeonCodec.shared
  }
  func onTransactionsUpdated(newTransactions newTransactionsArg: [SK2TransactionMessage], completion: @escaping (Result<Void, PigeonError>) -> Void) {
    let channelName: String = "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2CallbackAPI.onTransactionsUpdated\(messageChannelSuffix)"
    let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec)
    channel.sendMessage([newTransactionsArg] as [Any?]) { response in
      guard let listResponse = response as? [Any?] else {
        completion(.failure(createConnectionError(withChannelName: channelName)))
        return
      }
      if listResponse.count > 1 {
        let code: String = listResponse[0] as! String
        let message: String? = nilOrValue(listResponse[1])
        let details: String? = nilOrValue(listResponse[2])
        completion(.failure(PigeonError(code: code, message: message, details: details)))
      } else {
        completion(.success(()))
      }
    }
  }
}

Here is the version that introduced this behavior in the generated code
Screenshot 2025-11-17 at 11 51 37 AM

What I am going to do now is pin pigeon to the latest version I found that works (25.2.0) and push the changes.

Do you think this works?

@stuartmorgan-g
Copy link
Collaborator

A repo-wide update of all packages to Pigeon 26 landed yesterday, FYI.

@Haidar0096
Copy link
Author

@stuartmorgan-g I fetched it locally, and ran the pigeon generator, and I am hitting
something weird, here are the things I did exactly and the weird thing happening:

Commands I ran:

# Verify pigeon version
cd packages/in_app_purchase/in_app_purchase_storekit
dart pub deps | grep pigeon
# Output: ├── pigeon 26.1.0

# Regenerate pigeon
dart run pigeon --input pigeons/sk2_pigeon.dart

# Check format
cd ../../..
dart run script/tool/bin/flutter_plugin_tools.dart format --packages
in_app_purchase_storekit

Result:
Format fails with 2 Swift lint errors:

  sk2_pigeon.g.swift:76: [AlwaysUseLowerCamelCase] rename 'deepEqualssk2_pigeon' using
  lowerCamelCase
  sk2_pigeon.g.swift:117: [AlwaysUseLowerCamelCase] rename 'deepHashsk2_pigeon' using
  lowerCamelCase

The weird thing: upstream/main's generated file has the // swift-format-ignore: AlwaysUseLowerCamelCase comments before these functions, but when I regenerate pigeon
locally with the same version (26.1.0), it doesn't generate them. Should pigeon be
generating these comments, or are you doing something else to make them be generated when you run pigeon?

@stuartmorgan-g
Copy link
Collaborator

Sorry, I meant to follow up on that this morning and had forgotten. I filed flutter/flutter#178736 to track the problem; for now you can just manually add those (which is what I did to unblock the roll).

(Longer term we need to fix that issue, and/or change the output names of these Pigeon files.)

@Haidar0096
Copy link
Author

@stuartmorgan-g ohh I thought claude was hallucinating when it told me the flutter team added them manually😂😂

Thank you for the guidance, will supress them manually then and update the PR🙏🏻

And a suggestion, if this takes some time to fix (the pigeon issue), maybe mention this as a note in the pigeon section so new comers like me know that they are not doing anything wrong in their setup but rather they should supress such errors (to be specified) manually for now.

@Haidar0096 Haidar0096 force-pushed the add-storekit2-transaction-unfinished-and-appaccounttoken branch from 1c0aecc to f8257a4 Compare November 18, 2025 16:58
@stuartmorgan-g
Copy link
Collaborator

stuartmorgan-g commented Nov 18, 2025

And a suggestion, if this takes some time to fix (the pigeon issue), maybe mention this as a note in the pigeon section so new comers like me know that they are not doing anything wrong in their setup but rather they should supress such errors (to be specified) manually for now.

Having to make manual changes is extremely rare, and my plan was to resolve this case ASAP; it confuses everyone (not just newcomers). I wouldn't have done it at all except that it was blocking an important change in flutter/flutter for complex reasons.

Yesterday I thought this was a quick Pigeon fix that I would do today, but having investigated the cause more I'll probably change the Swift filenames for this package today as the short-term fix.

@Haidar0096
Copy link
Author

The remaining failing check as per logs is due to:

[!] CDN: trunk URL couldn't be downloaded: https://cdn.cocoapods.org/deprecated_podspecs.txt Response: 500 <!DOCTYPE html>

I don't have an option to rerun the check to try it again.

@stuartmorgan-g
Copy link
Collaborator

'll probably change the Swift filenames for this package today as the short-term fix.

This landed as #10465, so the manual change is no longer necessary.

…aseDetails

This PR adds two new features to in_app_purchase_storekit for StoreKit 2:

1. SK2Transaction.unfinishedTransactions() - Queries only unfinished transactions,
   mirroring Apple's Transaction.unfinished API for better performance.

2. SK2PurchaseDetails.appAccountToken - Exposes the UUID that associates
   transactions with users in custom backend systems.

Both features are additive and maintain full backward compatibility.

Tests included for both features.
Previously returned nil for receipt data, now properly includes
jwsRepresentation for server-side verification.
   swift-format-ignore comments for deepEqualssk2_pigeon and
   deepHashsk2_pigeon functions as a workaround for
   flutter/flutter#178736.
@Haidar0096 Haidar0096 force-pushed the add-storekit2-transaction-unfinished-and-appaccounttoken branch from f8257a4 to 6445537 Compare November 19, 2025 13:36
@Haidar0096 Haidar0096 requested a review from LouiseHsu November 19, 2025 17:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants