diff --git a/.vscode/launch.json b/.vscode/launch.json index 1aed9d028..75c5cdd8d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -85,6 +85,14 @@ "cwd": "${workspaceFolder}/example", "flutterMode": "release", "args": [] + }, + { + "name": "Example (iOS Device)", + "type": "dart", + "request": "launch", + "program": "lib/main.dart", + "cwd": "${workspaceFolder}/example", + "args": [] } ], "compounds": [] diff --git a/android/build.gradle b/android/build.gradle index 65e20ced4..98aa86753 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,6 +1,20 @@ import groovy.json.JsonSlurper -def openiapVersionsFile = new File(rootDir.parentFile, 'openiap-versions.json') +static File locateOpeniapVersionsFile(File startDir) { + File current = startDir + while (current != null) { + File candidate = new File(current, 'openiap-versions.json') + if (candidate.exists()) { + return candidate + } + current = current.getParentFile() + } + throw new GradleException( + "Unable to locate openiap-versions.json starting from ${startDir.absolutePath}" + ) +} + +def openiapVersionsFile = locateOpeniapVersionsFile(rootDir) def openiapVersions = new JsonSlurper().parse(openiapVersionsFile) def openiapGoogleVersion = openiapVersions['google'] diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index e81feb9d0..9049a1f21 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2,13 +2,13 @@ PODS: - Flutter (1.0.0) - flutter_inapp_purchase (0.0.1): - Flutter - - openiap (= 1.1.12) - - openiap (1.1.12) + - openiap (= 1.2.2) + - openiap (1.2.2) DEPENDENCIES: - Flutter (from `Flutter`) - flutter_inapp_purchase (from `.symlinks/plugins/flutter_inapp_purchase/ios`) - - openiap (from `https://github.com/hyodotdev/openiap-apple.git`, tag `1.1.12`) + - openiap (from `https://github.com/hyodotdev/openiap-apple.git`, tag `1.2.2`) EXTERNAL SOURCES: Flutter: @@ -17,18 +17,18 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_inapp_purchase/ios" openiap: :git: https://github.com/hyodotdev/openiap-apple.git - :tag: 1.1.12 + :tag: 1.2.2 CHECKOUT OPTIONS: openiap: :git: https://github.com/hyodotdev/openiap-apple.git - :tag: 1.1.12 + :tag: 1.2.2 SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_inapp_purchase: 8e8e96cc14bc141e3ffd432012fc9d4e347f8f71 - openiap: e207e50cc83dc28cd00c3701421a12eacdbd163a + flutter_inapp_purchase: 50cbf415ba7058ee0042620d433cddd2ff4cb09d + openiap: ea0f37fb1d08cd982ee4dfc219bd2e9c0656822c -PODFILE CHECKSUM: 9446ef2faab77e656d46b3aaf8938ab324919b93 +PODFILE CHECKSUM: 55b4ebbe613025bc8eab828c4569a30387a623c1 COCOAPODS: 1.16.2 diff --git a/example/lib/src/screens/available_purchases_screen.dart b/example/lib/src/screens/available_purchases_screen.dart index 8d0e1c51f..fb5688409 100644 --- a/example/lib/src/screens/available_purchases_screen.dart +++ b/example/lib/src/screens/available_purchases_screen.dart @@ -20,8 +20,10 @@ class _AvailablePurchasesScreenState extends State { List _availablePurchases = []; List _purchaseHistory = []; bool _loading = false; + bool _historyLoading = false; bool _connected = false; String? _error; + String? _historyError; /// Convert various date formats to milliseconds timestamp int _parseTimestamp(dynamic date) { @@ -100,14 +102,15 @@ class _AvailablePurchasesScreenState extends State { }); await _loadPurchases(); } catch (e) { + if (!mounted) { + debugPrint('Failed to initialize IAP connection: $e'); + return; + } setState(() { _error = e.toString(); - }); - debugPrint('Failed to initialize IAP connection: $e'); - } finally { - setState(() { _loading = false; }); + debugPrint('Failed to initialize IAP connection: $e'); } } @@ -130,35 +133,90 @@ class _AvailablePurchasesScreenState extends State { // Remove duplicates by productId, keeping the most recent one final deduplicatedPurchases = _deduplicatePurchases(availablePurchases); - // Load purchase history - List purchaseHistory = []; - if (_platformOrDefault() == IapPlatform.IOS) { - // iOS: include expired subscriptions as history - purchaseHistory = await _iap.getAvailablePurchases( - const PurchaseOptions(onlyIncludeActiveItemsIOS: false), - ); - } else { - // Android (GPB v8+): history of consumed items is not available via API - // Show active purchases only - purchaseHistory = []; + if (!mounted) { + return; } - debugPrint('Loaded ${purchaseHistory.length} purchases from history'); setState(() { _availablePurchases = deduplicatedPurchases; - _purchaseHistory = purchaseHistory; + _loading = false; }); debugPrint( - 'After deduplication: ${_availablePurchases.length} unique active purchases'); + 'After deduplication: ${deduplicatedPurchases.length} unique active purchases'); + + if (_platformOrDefault() == IapPlatform.IOS) { + unawaited(_loadPurchaseHistory()); + } else if (mounted) { + setState(() { + _purchaseHistory = []; + }); + } } catch (e) { + if (!mounted) { + debugPrint('Error loading purchases: $e'); + return; + } setState(() { _error = e.toString(); + _loading = false; + _historyLoading = false; }); debugPrint('Error loading purchases: $e'); + } + } + + Future _loadPurchaseHistory() async { + setState(() { + _historyLoading = true; + _historyError = null; + }); + + Timer? warningTimer; + warningTimer = Timer(const Duration(seconds: 12), () { + if (!mounted || !_historyLoading || _historyError != null) { + return; + } + setState(() { + _historyError = + 'Fetching purchase history is taking longer than expected. Still waiting...'; + }); + }); + + try { + final purchaseHistory = await _iap.getAvailablePurchases( + const PurchaseOptions( + onlyIncludeActiveItemsIOS: false, + alsoPublishToEventListenerIOS: false, + ), + ); + debugPrint('Loaded ${purchaseHistory.length} purchases from history'); + + if (!mounted) { + warningTimer?.cancel(); + return; + } + + setState(() { + _purchaseHistory = purchaseHistory; + _historyError = null; + }); + } catch (e) { + debugPrint('Error loading purchase history: $e'); + if (!mounted) { + warningTimer?.cancel(); + return; + } + setState(() { + _historyError = e.toString(); + }); } finally { + warningTimer?.cancel(); + if (!mounted) { + return; + } setState(() { - _loading = false; + _historyLoading = false; }); } } @@ -477,14 +535,31 @@ class _AvailablePurchasesScreenState extends State { const SizedBox(height: 24), ], - // Purchase History Section - if (_purchaseHistory.isNotEmpty) ...[ - const Text( - 'Purchase History', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), + // Purchase History Section (iOS only) + if (_platformOrDefault() == IapPlatform.IOS) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Expanded( + child: Text( + 'Purchase History', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + OutlinedButton.icon( + onPressed: + _historyLoading ? null : _loadPurchaseHistory, + icon: const Icon(Icons.history), + label: Text( + _purchaseHistory.isEmpty + ? 'Load History' + : 'Reload History', + ), + ), + ], ), const Text( 'All purchases including consumed items', @@ -494,13 +569,30 @@ class _AvailablePurchasesScreenState extends State { ), ), const SizedBox(height: 8), - ..._purchaseHistory - .map((item) => _buildPurchaseHistoryItem(item)), + if (_historyLoading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ) + else if (_historyError != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + _historyError!, + style: + const TextStyle(color: Colors.red, fontSize: 12), + ), + ), + if (_purchaseHistory.isNotEmpty) + ..._purchaseHistory + .map((item) => _buildPurchaseHistoryItem(item)), + const SizedBox(height: 24), ], // Empty State if (_availablePurchases.isEmpty && _purchaseHistory.isEmpty && + !_historyLoading && _error == null) ...[ Center( child: Column( diff --git a/example/lib/src/screens/purchase_flow_screen.dart b/example/lib/src/screens/purchase_flow_screen.dart index 7a4ebdea9..18452b7e1 100644 --- a/example/lib/src/screens/purchase_flow_screen.dart +++ b/example/lib/src/screens/purchase_flow_screen.dart @@ -363,12 +363,7 @@ Product ID: ${error.productId ?? 'unknown'} ), ); - List products; - if (result is FetchProductsResultProducts) { - products = result.value ?? const []; - } else { - products = const []; - } + final products = result.inAppProducts(); debugPrint('📦 Received ${products.length} products from fetchProducts'); diff --git a/example/lib/src/screens/subscription_flow_screen.dart b/example/lib/src/screens/subscription_flow_screen.dart index 2c5cf86d5..317eaaf18 100644 --- a/example/lib/src/screens/subscription_flow_screen.dart +++ b/example/lib/src/screens/subscription_flow_screen.dart @@ -27,7 +27,9 @@ class _SubscriptionFlowScreenState extends State { List _subscriptions = []; final Map _originalProducts = {}; List _activeSubscriptions = []; + final Map _activeSubscriptionInfo = {}; Purchase? _currentSubscription; + ActiveSubscription? _currentActiveSubscription; bool _hasActiveSubscription = false; bool _isProcessing = false; bool _connected = false; @@ -298,12 +300,7 @@ Has token: ${purchase.purchaseToken != null && purchase.purchaseToken!.isNotEmpt ), ); - List products; - if (result is FetchProductsResultSubscriptions) { - products = result.value ?? const []; - } else { - products = const []; - } + final products = result.subscriptionProducts(); debugPrint('Loaded ${products.length} subscriptions'); @@ -317,7 +314,7 @@ Has token: ${purchase.purchaseToken != null && purchase.purchaseToken!.isNotEmpt _originalProducts[productKey] = product; } - _subscriptions = products; + _subscriptions = List.of(products, growable: false); _isLoadingProducts = false; }); } catch (error) { @@ -334,36 +331,132 @@ Has token: ${purchase.purchaseToken != null && purchase.purchaseToken!.isNotEmpt if (!_connected) return; try { - // Get all available purchases - final purchases = await _iap.getAvailablePurchases(); - debugPrint('=== Checking Active Subscriptions ==='); - debugPrint('Total purchases found: ${purchases.length}'); - for (var p in purchases) { + + final summaries = await _iap.getActiveSubscriptions(subscriptionIds); + debugPrint('Active subscription summaries: ${summaries.length}'); + for (final summary in summaries) { debugPrint( - ' - ${p.productId}: token=${p.purchaseToken?.substring(0, 20)}...'); + ' • ${summary.productId} (tx: ${summary.transactionId}, expires: ${summary.expirationDateIOS})', + ); } - // Filter for subscriptions - final activeSubs = purchases - .where((p) => subscriptionIds.contains(p.productId)) - .toList(); + final purchases = await _iap.getAvailablePurchases( + const PurchaseOptions(onlyIncludeActiveItemsIOS: true), + ); + + debugPrint('Total available purchases found: ${purchases.length}'); + for (final p in purchases) { + final token = p.purchaseToken; + final tokenPreview = token == null + ? 'null' + : token.length <= 20 + ? token + : '${token.substring(0, 20)}...'; + debugPrint( + ' - ${p.productId}: token=$tokenPreview', + ); + } + + const double matchToleranceMs = 2000; // 2 seconds tolerance + + final Map summaryByProduct = {}; + for (final summary in summaries) { + final existing = summaryByProduct[summary.productId]; + if (existing == null || + summary.transactionDate > existing.transactionDate) { + summaryByProduct[summary.productId] = summary; + } + } + + final List activeSubs = []; + final Set addedProducts = {}; + + for (final entry in summaryByProduct.entries) { + final summary = entry.value; + Purchase? matched; + + for (final purchase in purchases) { + if (purchase.productId != summary.productId) { + continue; + } + + final matchesTransaction = purchase.id == summary.transactionId || + (purchase.ids?.contains(summary.transactionId) ?? false); + final matchesToken = summary.purchaseToken != null && + summary.purchaseToken!.isNotEmpty && + summary.purchaseToken == purchase.purchaseToken; + final matchesDate = + (purchase.transactionDate - summary.transactionDate).abs() <= + matchToleranceMs; + + if (matchesTransaction || matchesToken || matchesDate) { + matched = purchase; + break; + } + } + + if (matched != null && addedProducts.add(matched.productId)) { + activeSubs.add(matched); + } else if (matched == null) { + debugPrint( + '⚠️ No matching purchase found for active subscription ${summary.productId}', + ); + } + } + + if (activeSubs.isEmpty && summaryByProduct.isNotEmpty) { + for (final purchase in purchases) { + if (summaryByProduct.containsKey(purchase.productId) && + addedProducts.add(purchase.productId)) { + activeSubs.add(purchase); + } + } + } + + activeSubs.sort( + (a, b) => b.transactionDate.compareTo(a.transactionDate), + ); if (!mounted) return; setState(() { + _activeSubscriptionInfo + ..clear() + ..addAll(summaryByProduct); _activeSubscriptions = activeSubs; _hasActiveSubscription = activeSubs.isNotEmpty; _currentSubscription = activeSubs.isNotEmpty ? activeSubs.first : null; + _currentActiveSubscription = _currentSubscription != null + ? summaryByProduct[_currentSubscription!.productId] + : null; if (_currentSubscription != null) { debugPrint( - 'Current subscription: ${_currentSubscription!.productId}'); - debugPrint('Purchase token: ${_currentSubscription!.purchaseToken}'); - _purchaseResult = - 'Active: ${_currentSubscription!.productId}\nToken: ${_currentSubscription!.purchaseToken?.substring(0, 30)}...'; + 'Current subscription: ${_currentSubscription!.productId}', + ); + debugPrint( + 'Purchase token: ${_currentSubscription!.purchaseToken}', + ); + + final summary = _currentActiveSubscription; + final buffer = StringBuffer( + 'Active: ${_currentSubscription!.productId}', + ); + if (summary?.expirationDateIOS != null) { + buffer.write( + '\nExpires: ${_formatReadableDate(summary!.expirationDateIOS!)}', + ); + } + if (summary?.autoRenewingAndroid != null) { + buffer.write( + '\nAuto renew: ${summary!.autoRenewingAndroid == true}', + ); + } + _purchaseResult = buffer.toString(); } else { debugPrint('No active subscription found in filtered list'); + _purchaseResult = 'No active subscriptions found'; } }); } catch (error) { @@ -563,27 +656,21 @@ Has token: ${purchase.purchaseToken != null && purchase.purchaseToken!.isNotEmpt }); try { - final purchases = await _iap.getAvailablePurchases(); - debugPrint('Restored ${purchases.length} purchases'); + await _iap.restorePurchases(); + await _checkActiveSubscriptions(); if (!mounted) return; setState(() { - _activeSubscriptions = purchases - .where((p) => subscriptionIds.contains(p.productId)) - .toList(); - _hasActiveSubscription = _activeSubscriptions.isNotEmpty; - _currentSubscription = - _activeSubscriptions.isNotEmpty ? _activeSubscriptions.first : null; _isProcessing = false; _purchaseResult = - '✅ Restored ${_activeSubscriptions.length} subscriptions'; + '✅ Restored ${_activeSubscriptions.length} subscription(s)'; }); - // Verify each restored purchase for (final purchase in _activeSubscriptions) { debugPrint( - 'Restored: ${purchase.productId}, Token: ${purchase.purchaseToken}'); + 'Restored: ${purchase.productId}, Token: ${purchase.purchaseToken}', + ); } } catch (error) { debugPrint('Failed to restore purchases: $error'); @@ -630,14 +717,37 @@ Has token: ${purchase.purchaseToken != null && purchase.purchaseToken!.isNotEmpt } Widget _buildActiveSubscriptionCard(Purchase purchase) { + final summary = _activeSubscriptionInfo[purchase.productId]; final isCurrent = _currentSubscription?.productId == purchase.productId; final chips = [ _infoChip('State: ${purchase.purchaseState.name}'), _infoChip('Platform: ${purchase.platform.toJson().toLowerCase()}'), _infoChip('Quantity: ${purchase.quantity}'), - _infoChip('Auto renew: ${purchase.isAutoRenewing}') ]; + if (summary != null) { + chips.add( + _infoChip('Status: ${summary.isActive ? 'Active' : 'Inactive'}')); + } + + final bool? autoRenew = + summary?.autoRenewingAndroid ?? purchase.isAutoRenewing; + chips.add(_infoChip('Auto renew: ${autoRenew ?? 'unknown'}')); + + final expiration = summary?.expirationDateIOS; + if (expiration != null) { + chips.add( + _infoChip('Expires: ${_formatReadableDate(expiration)}'), + ); + } + if (summary?.willExpireSoon == true) { + chips.add(_infoChip('Expiring soon')); + } + final environment = summary?.environmentIOS; + if (environment != null && environment.isNotEmpty) { + chips.add(_infoChip('Env: $environment')); + } + return Card( margin: const EdgeInsets.only(bottom: 12), child: Padding( @@ -722,6 +832,14 @@ Has token: ${purchase.purchaseToken != null && purchase.purchaseToken!.isNotEmpt ); } + String _formatReadableDate(double timestamp) { + if (timestamp == 0) { + return 'Unknown'; + } + final date = DateTime.fromMillisecondsSinceEpoch(timestamp.round()); + return date.toLocal().toString().split('.').first; + } + Widget _buildSubscriptionTier(ProductCommon subscription) { final isCurrentSubscription = _currentSubscription?.productId == subscription.id; diff --git a/ios/Classes/FlutterIapHelper.swift b/ios/Classes/FlutterIapHelper.swift new file mode 100644 index 000000000..e7f6926a0 --- /dev/null +++ b/ios/Classes/FlutterIapHelper.swift @@ -0,0 +1,191 @@ +import Foundation +import OpenIAP + +enum FlutterIapHelper { + // MARK: - Sanitization + + private static let identifierKeys: Set = [ + "id", + "transactionId", + "productId", + "offerId", + "originalTransactionIdentifierIOS", + "subscriptionGroupIdIOS", + "webOrderLineItemIdIOS", + "orderIdAndroid", + "obfuscatedAccountIdAndroid", + "obfuscatedProfileIdAndroid" + ] + + private static let sensitiveKeyFragments: [String] = [ + "token", + "receipt", + "jws", + "signature" + ] + + static func sanitizeDictionary(_ dictionary: [String: Any?]) -> [String: Any] { + var result: [String: Any] = [:] + for (key, value) in dictionary { + let lowerKey = key.lowercased() + if sensitiveKeyFragments.contains(where: { lowerKey.contains($0) }) { + result[key] = "hidden" + continue + } + + guard let sanitizedValue = sanitizeValue(value) else { continue } + + if let number = sanitizedValue as? NSNumber, identifierKeys.contains(key) { + result[key] = number.stringValue + } else { + result[key] = sanitizedValue + } + } + return result + } + + static func sanitizeArray(_ array: [[String: Any?]]) -> [[String: Any]] { + array.map { sanitizeDictionary($0) } + } + + static func sanitizeValue(_ value: Any?) -> Any? { + guard let value else { return nil } + + if let dictionary = value as? [String: Any?] { + return sanitizeDictionary(dictionary) + } + + if let dictionary = value as? [String: Any] { + let optionalDictionary = dictionary.reduce(into: [String: Any?]()) { result, element in + result[element.key] = element.value + } + return sanitizeDictionary(optionalDictionary) + } + + if let array = value as? [Any?] { + return array.compactMap { sanitizeValue($0) } + } + + if let array = value as? [Any] { + return array.compactMap { sanitizeValue($0) } + } + + return value + } + + static func jsonString(from value: Any) -> String? { + guard JSONSerialization.isValidJSONObject(value) else { return nil } + guard let data = try? JSONSerialization.data(withJSONObject: value, options: []) else { return nil } + return String(data: data, encoding: .utf8) + } + + // MARK: - Parsing helpers + + static func parseProductQueryType(_ rawValue: String?) -> ProductQueryType { + guard let raw = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return .all + } + switch raw.lowercased() { + case "inapp", ProductQueryType.inApp.rawValue: + return .inApp + case ProductQueryType.subs.rawValue: + return .subs + case ProductQueryType.all.rawValue: + return .all + default: + return .all + } + } + + static func decodeProductRequest(from payload: [String: Any]) throws -> ProductRequest { + if let skus = payload["skus"] as? [String], !skus.isEmpty { + let type = parseProductQueryType(payload["type"] as? String) + return try OpenIapSerialization.productRequest(skus: skus, type: type) + } + + let indexedSkus = payload.keys + .compactMap { Int($0) } + .sorted() + .compactMap { payload[String($0)] as? String } + + if !indexedSkus.isEmpty { + return try OpenIapSerialization.productRequest(skus: indexedSkus, type: .all) + } + + return try OpenIapSerialization.decode(object: payload, as: ProductRequest.self) + } + + static func decodePurchaseOptions(from dictionary: [String: Any]) throws -> PurchaseOptions { + try OpenIapSerialization.purchaseOptions(from: dictionary) + } + + static func decodePurchaseOptions(alsoPublish: Bool, onlyIncludeActive: Bool) throws -> PurchaseOptions { + try decodePurchaseOptions(from: [ + "alsoPublishToEventListenerIOS": alsoPublish, + "onlyIncludeActiveItemsIOS": onlyIncludeActive + ]) + } + + static func decodeRequestPurchaseProps(from payload: [String: Any]) throws -> RequestPurchaseProps { + if payload["requestPurchase"] != nil || payload["requestSubscription"] != nil { + FlutterIapLog.payload("decodeRequestPurchaseProps.normalized", payload: sanitizeValue(payload)) + return try OpenIapSerialization.decode(object: payload, as: RequestPurchaseProps.self) + } + + if let request = payload["request"] { + let parsedType = parseProductQueryType(payload["type"] as? String) + let purchaseType: ProductQueryType = parsedType == .all ? .inApp : parsedType + var normalized: [String: Any] = ["type": purchaseType.rawValue] + switch purchaseType { + case .subs: + normalized["requestSubscription"] = request + case .inApp: + normalized["requestPurchase"] = request + case .all: + break + } + FlutterIapLog.payload("decodeRequestPurchaseProps.normalized", payload: sanitizeValue(normalized)) + return try OpenIapSerialization.decode(object: normalized, as: RequestPurchaseProps.self) + } + + if let sku = payload["sku"] as? String, !sku.isEmpty { + let parsedType = parseProductQueryType(payload["type"] as? String) + let purchaseType: ProductQueryType = parsedType == .subs ? .subs : .inApp + var iosPayload: [String: Any?] = payload + iosPayload["sku"] = sku + let normalized: [String: Any] = [ + "type": purchaseType.rawValue, + purchaseType == .subs ? "requestSubscription" : "requestPurchase": [ + "ios": sanitizeDictionary(iosPayload) + ] + ] + FlutterIapLog.payload("decodeRequestPurchaseProps.normalized", payload: sanitizeValue(normalized)) + return try OpenIapSerialization.decode(object: normalized, as: RequestPurchaseProps.self) + } + + throw PurchaseError.make(code: .developerError, message: "Invalid purchase request payload") + } + + static func decodePurchaseInput(from payload: Any) throws -> PurchaseInput { + try OpenIapSerialization.purchaseInput(from: payload) + } + + static func fallbackPurchaseInput(for transactionId: String) throws -> PurchaseInput { + let payload: [String: Any] = [ + "id": transactionId, + "ids": [], + "isAutoRenewing": false, + "platform": IapPlatform.ios.rawValue, + "productId": "", + "purchaseState": PurchaseState.purchased.rawValue, + "purchaseToken": transactionId, + "quantity": 1, + "transactionDate": 0 + ] + return try decodePurchaseInput(from: payload) + } + + static func decodeReceiptValidationProps(for sku: String) throws -> ReceiptValidationProps { + try OpenIapSerialization.receiptValidationProps(from: ["sku": sku]) + } +} diff --git a/ios/Classes/FlutterIapLog.swift b/ios/Classes/FlutterIapLog.swift new file mode 100644 index 000000000..9f7ec2e4d --- /dev/null +++ b/ios/Classes/FlutterIapLog.swift @@ -0,0 +1,98 @@ +import Foundation +#if canImport(os) +import os +#endif + +enum FlutterIapLog { + enum Level: String { + case debug + case info + case warn + case error + } + + private static var isEnabled: Bool = { + #if DEBUG + return true + #else + return false + #endif + }() + + private static var handler: ((Level, String) -> Void)? + + static func setEnabled(_ enabled: Bool) { + isEnabled = enabled + } + + static func setHandler(_ newHandler: ((Level, String) -> Void)?) { + handler = newHandler + } + + static func debug(_ message: String) { log(.debug, message) } + static func info(_ message: String) { log(.info, message) } + static func warn(_ message: String) { log(.warn, message) } + static func error(_ message: String) { log(.error, message) } + + static func payload(_ name: String, payload: Any?) { + log(.debug, "\(name) payload: \(stringify(payload))") + } + + static func result(_ name: String, value: Any?) { + log(.debug, "\(name) result: \(stringify(value))") + } + + static func failure(_ name: String, error: Error) { + log(.error, "\(name) failed: \(error.localizedDescription)") + } + + private static func log(_ level: Level, _ message: String) { + guard isEnabled else { return } + + if let handler { + handler(level, message) + return + } + + #if canImport(os) + if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) { + let logger = Logger(subsystem: "dev.hyo.flutter-inapp-purchase", category: "FlutterIap") + let formatted = "[FlutterIap] \(message)" + switch level { + case .debug: + logger.debug("\(formatted, privacy: .public)") + case .info: + logger.info("\(formatted, privacy: .public)") + case .warn: + logger.warning("\(formatted, privacy: .public)") + case .error: + logger.error("\(formatted, privacy: .public)") + } + } else { + NSLog("[FlutterIap][%@] %@", level.rawValue.uppercased(), message) + } + #else + NSLog("[FlutterIap][%@] %@", level.rawValue.uppercased(), message) + #endif + } + + private static func stringify(_ value: Any?) -> String { + guard let sanitized = FlutterIapHelper.sanitizeValue(value) else { + return "null" + } + + if let jsonObject = sanitized as? [String: Any], + JSONSerialization.isValidJSONObject(jsonObject), + let data = try? JSONSerialization.data(withJSONObject: jsonObject, options: []) { + return String(data: data, encoding: .utf8) ?? String(describing: sanitized) + } + + if let jsonArray = sanitized as? [Any], + JSONSerialization.isValidJSONObject(jsonArray), + let data = try? JSONSerialization.data(withJSONObject: jsonArray, options: []) { + return String(data: data, encoding: .utf8) ?? String(describing: sanitized) + } + + return String(describing: sanitized) + } +} diff --git a/ios/Classes/FlutterInappPurchasePlugin.swift b/ios/Classes/FlutterInappPurchasePlugin.swift index 95bb59ab4..e14d51490 100644 --- a/ios/Classes/FlutterInappPurchasePlugin.swift +++ b/ios/Classes/FlutterInappPurchasePlugin.swift @@ -15,13 +15,17 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { // No local StoreKit caches; OpenIAP handles state internally private var processedTransactionIds: Set = [] - // Produce standardized message from OpenIAP error catalog - private func defaultMessage(for code: String) -> String { - return OpenIapError.defaultMessage(for: code) + // Override local catalog with canonical OpenIAP messages + private func defaultMessage(for code: ErrorCode) -> String { + PurchaseError.defaultMessage(for: code) + } + + private func defaultMessage(for rawCode: String) -> String { + PurchaseError.defaultMessage(for: rawCode) } public static func register(with registrar: FlutterPluginRegistrar) { - print("\(TAG) Swift register called") + FlutterIapLog.debug("Swift register called") let channel = FlutterMethodChannel(name: "flutter_inapp", binaryMessenger: registrar.messenger()) let instance = FlutterInappPurchasePlugin() registrar.addMethodCallDelegate(instance, channel: channel) @@ -39,11 +43,11 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { @MainActor private func handleOnMain(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - print("\(FlutterInappPurchasePlugin.TAG) Swift handle called with method: '\(call.method)' and arguments: \(String(describing: call.arguments))") + FlutterIapLog.debug("Swift handle called with method: \(call.method)") switch call.method { case "canMakePayments": - print("\(FlutterInappPurchasePlugin.TAG) canMakePayments called (OpenIAP)") + FlutterIapLog.debug("canMakePayments called (OpenIAP)") // OpenIAP abstraction: assume payments can be made once initialized result(true) @@ -61,7 +65,8 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { let params: [String: Any] = ["skus": skus, "type": "all"] fetchProducts(params: params, result: result) } else { - result(FlutterError(code: OpenIapError.DeveloperError, message: "Invalid params for fetchProducts", details: nil)) + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "Invalid params for fetchProducts", details: nil)) } case "getAvailableItems": @@ -84,30 +89,33 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } else if let sku = call.arguments as? String { requestPurchase(args: ["sku": sku], result: result) } else { - result(FlutterError(code: OpenIapError.DeveloperError, message: "Invalid params for requestPurchase", details: nil)) + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "Invalid params for requestPurchase", details: nil)) } case "finishTransaction": - // Support both old and new API - var transactionId: String? - - print("\(FlutterInappPurchasePlugin.TAG) finishTransaction called with arguments: \(String(describing: call.arguments))") - + FlutterIapLog.payload("finishTransaction", payload: call.arguments) + if let args = call.arguments as? [String: Any] { - transactionId = args["transactionId"] as? String ?? args["transactionIdentifier"] as? String - print("\(FlutterInappPurchasePlugin.TAG) Extracted transactionId from args: \(transactionId ?? "nil")") + if let purchasePayload = args["purchase"] as? [String: Any] { + let isConsumable = args["isConsumable"] as? Bool + finishTransaction(purchaseDict: purchasePayload, isConsumable: isConsumable, result: result) + return + } + if let id = args["transactionId"] as? String ?? args["transactionIdentifier"] as? String { + FlutterIapLog.debug("finishTransaction extracted transactionId: \(id)") + finishTransaction(transactionId: id, result: result) + return + } } else if let id = call.arguments as? String { - transactionId = id - print("\(FlutterInappPurchasePlugin.TAG) Using direct string as transactionId: \(id)") - } - - guard let id = transactionId else { - print("\(FlutterInappPurchasePlugin.TAG) ERROR: No transactionId found in arguments") - result(FlutterError(code: OpenIapError.DeveloperError, message: "transactionId required", details: nil)) + FlutterIapLog.debug("finishTransaction using direct transactionId: \(id)") + finishTransaction(transactionId: id, result: result) return } - print("\(FlutterInappPurchasePlugin.TAG) Final transactionId to finish: \(id)") - finishTransaction(transactionId: id, result: result) + + FlutterIapLog.error("finishTransaction called without transaction info") + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "transactionId required", details: nil)) case "getStorefrontIOS": getStorefrontIOS(result: result) @@ -125,7 +133,8 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { if #available(iOS 16.0, *) { presentCodeRedemptionSheetIOS(result: result) } else { - result(FlutterError(code: OpenIapError.FeatureNotSupported, message: "Code redemption requires iOS 16.0+", details: nil)) + let code: ErrorCode = .featureNotSupported + result(FlutterError(code: code.rawValue, message: "Code redemption requires iOS 16.0+", details: nil)) } case "getPromotedProductIOS": @@ -139,7 +148,8 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { case "validateReceiptIOS": guard let args = call.arguments as? [String: Any], let sku = args["sku"] as? String else { - result(FlutterError(code: OpenIapError.DeveloperError, message: "sku required", details: nil)) + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) return } validateReceiptIOS(productId: sku, result: result) @@ -152,7 +162,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { // MARK: - Connection Management private func initConnection(result: @escaping FlutterResult) { - print("\(FlutterInappPurchasePlugin.TAG) initConnection called") + FlutterIapLog.debug("initConnection called") // Ensure listeners are set before initializing connection (Expo-style) setupOpenIapListeners() Task { @MainActor in @@ -161,15 +171,15 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { result(nil) } catch { await MainActor.run { - let code = OpenIapError.InitConnection - result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) + let code: ErrorCode = .initConnection + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: nil)) } } } } private func endConnection(result: @escaping FlutterResult) { - print("\(FlutterInappPurchasePlugin.TAG) endConnection called") + FlutterIapLog.debug("endConnection called") removeOpenIapListeners() Task { _ = try? await OpenIapModule.shared.endConnection() @@ -187,40 +197,30 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { // MARK: - OpenIAP Listeners private func setupOpenIapListeners() { if purchaseUpdatedToken != nil || purchaseErrorToken != nil { return } - print("\(FlutterInappPurchasePlugin.TAG) Setting up OpenIAP listeners") - + FlutterIapLog.debug("Setting up OpenIAP listeners") + purchaseUpdatedToken = OpenIapModule.shared.purchaseUpdatedListener { [weak self] purchase in Task { @MainActor in - guard let self = self else { return } - print("\(FlutterInappPurchasePlugin.TAG) ✅ purchaseUpdatedListener fired") - let payload = OpenIapSerialization.purchase(purchase) - var sanitized = self.sanitize(dict: payload) - // Coerce iOS fields that Dart expects as String - if let n = sanitized["webOrderLineItemIdIOS"] as? NSNumber { sanitized["webOrderLineItemIdIOS"] = n.stringValue } - if let n = sanitized["originalTransactionIdentifierIOS"] as? NSNumber { sanitized["originalTransactionIdentifierIOS"] = n.stringValue } - if let n = sanitized["subscriptionGroupIdIOS"] as? NSNumber { sanitized["subscriptionGroupIdIOS"] = n.stringValue } - if let n = sanitized["reasonIOS"] as? NSNumber { sanitized["reasonIOS"] = n.stringValue } - if let jsonData = try? JSONSerialization.data(withJSONObject: sanitized), - let jsonString = String(data: jsonData, encoding: .utf8) { - print("\(FlutterInappPurchasePlugin.TAG) Emitting purchase-updated to Flutter") + guard let self else { return } + FlutterIapLog.debug("purchaseUpdatedListener fired for \(purchase.productId)") + let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(purchase)) + if let jsonString = FlutterIapHelper.jsonString(from: payload) { self.channel?.invokeMethod("purchase-updated", arguments: jsonString) } } } - + purchaseErrorToken = OpenIapModule.shared.purchaseErrorListener { [weak self] error in Task { @MainActor in - guard let self = self else { return } - print("\(FlutterInappPurchasePlugin.TAG) ❌ purchaseErrorListener fired") + guard let self else { return } + FlutterIapLog.debug("purchaseErrorListener fired") let errorData: [String: Any?] = [ - "code": error.code, + "code": error.code.rawValue, "message": error.message, "productId": error.productId ] - let sanitized = self.sanitize(dict: errorData) - if let jsonData = try? JSONSerialization.data(withJSONObject: sanitized), - let jsonString = String(data: jsonData, encoding: .utf8) { - print("\(FlutterInappPurchasePlugin.TAG) Emitting purchase-error to Flutter") + let compacted = FlutterIapHelper.sanitizeDictionary(errorData) + if let jsonString = FlutterIapHelper.jsonString(from: compacted) { self.channel?.invokeMethod("purchase-error", arguments: jsonString) } } @@ -229,7 +229,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { promotedProductToken = OpenIapModule.shared.promotedProductListenerIOS { [weak self] productId in Task { @MainActor in guard let self = self else { return } - print("\(FlutterInappPurchasePlugin.TAG) 📱 promotedProductListenerIOS fired for: \(productId)") + FlutterIapLog.debug("promotedProductListenerIOS fired for: \(productId)") // Emit event that Dart expects: name 'iap-promoted-product' with String payload self.channel?.invokeMethod("iap-promoted-product", arguments: productId) } @@ -245,12 +245,6 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { promotedProductToken = nil } - private func sanitize(dict: [String: Any?]) -> [String: Any] { - var sanitized: [String: Any] = [:] - for (k, v) in dict { sanitized[k] = v ?? NSNull() } - return sanitized - } - // All transaction event handling is routed via OpenIapModule listeners // No direct StoreKit transaction state evaluation; handled by OpenIAP @@ -264,41 +258,31 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { // MARK: - Product Loading private func fetchProducts(params: [String: Any], result: @escaping FlutterResult) { - let rawSkus = params["skus"] as? [String] - // Support alternative array format (0,1,2 indexes) - var skus = rawSkus ?? [] - if skus.isEmpty { - var temp: [String] = [] - var i = 0 - while let sku = params["\(i)"] as? String { temp.append(sku); i += 1 } - skus = temp - } - let typeStr = (params["type"] as? String) ?? "all" - print("\(FlutterInappPurchasePlugin.TAG) fetchProducts called with skus: \(skus), type: \(typeStr)") - guard !skus.isEmpty else { - result(FlutterError(code: OpenIapError.QueryProduct, message: "Empty SKU list provided", details: nil)) - return - } + FlutterIapLog.payload("fetchProducts", payload: params) Task { @MainActor in do { - let reqType: OpenIapRequestProductType = { - switch typeStr.lowercased() { - case "inapp": return .inApp - case "subs": return .subs - default: return .all - } - }() - let request = OpenIapProductRequest(skus: skus, type: reqType) - let products: [OpenIapProduct] = try await OpenIapModule.shared.fetchProducts(request) - let serialized = OpenIapSerialization.products(products) + let request = try FlutterIapHelper.decodeProductRequest(from: params) + let products = try await OpenIapModule.shared.fetchProducts(request) + let serialized = FlutterIapHelper.sanitizeArray( + OpenIapSerialization.products(products, logger: { FlutterIapLog.debug($0) }) + ) + FlutterIapLog.result("fetchProducts", value: serialized) result(serialized) + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("fetchProducts", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId + )) } catch { - let code = OpenIapError.QueryProduct - result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) + FlutterIapLog.failure("fetchProducts", error: error) + let code: ErrorCode = .queryProduct + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) } } } - + // MARK: - Available Items private func getAvailableItems( @@ -306,42 +290,22 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { onlyIncludeActiveItems: Bool = true, alsoPublishToEventListener: Bool = false ) { - print("\(FlutterInappPurchasePlugin.TAG) getAvailableItems called with onlyIncludeActiveItems: \(onlyIncludeActiveItems), alsoPublishToEventListener: \(alsoPublishToEventListener)") + FlutterIapLog.debug("getAvailableItems called (onlyActive: \(onlyIncludeActiveItems), alsoPublish: \(alsoPublishToEventListener))") Task { @MainActor in do { - let opts = OpenIapGetAvailablePurchasesProps( - alsoPublishToEventListenerIOS: alsoPublishToEventListener, - onlyIncludeActiveItemsIOS: onlyIncludeActiveItems + let opts = try FlutterIapHelper.decodePurchaseOptions( + alsoPublish: alsoPublishToEventListener, + onlyIncludeActive: onlyIncludeActiveItems ) let purchases = try await OpenIapModule.shared.getAvailablePurchases(opts) - let serialized = OpenIapSerialization.purchases(purchases) - let sanitized = serialized.map { item -> [String: Any] in - var dict = self.sanitize(dict: item) - // Coerce iOS fields that Dart expects as String (avoid int→String? cast errors) - let stringKeys = [ - "webOrderLineItemIdIOS", - "originalTransactionIdentifierIOS", - "subscriptionGroupIdIOS", - "reasonIOS", - "storefrontCountryCodeIOS", - "appBundleIdIOS", - "ownershipTypeIOS", - "productTypeIOS", - "currencyIOS", - ] - for key in stringKeys { - if let n = dict[key] as? NSNumber { dict[key] = n.stringValue } - } - // Ensure id/transactionId are strings if present - if let n = dict["id"] as? NSNumber { dict["id"] = n.stringValue } - if let n = dict["transactionId"] as? NSNumber { dict["transactionId"] = n.stringValue } - return dict - } - await MainActor.run { result(sanitized) } + let serialized = FlutterIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases)) + FlutterIapLog.result("getAvailableItems", value: serialized) + await MainActor.run { result(serialized) } } catch { + FlutterIapLog.failure("getAvailableItems", error: error) await MainActor.run { - let code = OpenIapError.ServiceError - result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) } } } @@ -351,68 +315,85 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { private func requestPurchase(args: [String: Any], result: @escaping FlutterResult) { let sku = (args["sku"] as? String) ?? (args["productId"] as? String) guard let sku else { - result(FlutterError(code: OpenIapError.DeveloperError, message: "sku required", details: nil)) + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) return } - let autoFinish = (args["andDangerouslyFinishTransactionAutomatically"] as? Bool) ?? false - let appAccountToken = args["appAccountToken"] as? String - let quantity: Int? = { - if let q = args["quantity"] as? Int { return q } - if let qs = args["quantity"] as? String, let q = Int(qs) { return q } - return nil - }() - var withOffer: OpenIapDiscountOffer? = nil - if let offer = args["withOffer"] as? [String: Any] { - if let id = offer["identifier"] as? String, - let key = offer["keyIdentifier"] as? String, - let nonce = offer["nonce"] as? String, - let sig = offer["signature"] as? String, - let ts = offer["timestamp"] as? String { - withOffer = OpenIapDiscountOffer(identifier: id, keyIdentifier: key, nonce: nonce, signature: sig, timestamp: ts) - } - } - print("\(FlutterInappPurchasePlugin.TAG) requestPurchase called with sku: \(sku)") + FlutterIapLog.payload("requestPurchase", payload: args) + Task { @MainActor in do { - let props = OpenIapRequestPurchaseProps( - sku: sku, - andDangerouslyFinishTransactionAutomatically: autoFinish, - appAccountToken: appAccountToken, - quantity: quantity, - withOffer: withOffer - ) + let props = try FlutterIapHelper.decodeRequestPurchaseProps(from: args) _ = try await OpenIapModule.shared.requestPurchase(props) + FlutterIapLog.info("requestPurchase dispatched successfully for sku \(sku)") result(nil) + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("requestPurchase", error: purchaseError) + let errorData: [String: Any?] = [ + "code": purchaseError.code.rawValue, + "message": purchaseError.message.isEmpty ? defaultMessage(for: purchaseError.code) : purchaseError.message, + "productId": purchaseError.productId ?? sku + ] + if let payload = FlutterIapHelper.jsonString(from: FlutterIapHelper.sanitizeDictionary(errorData)) { + channel?.invokeMethod("purchase-error", arguments: payload) + } + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId ?? sku + )) } catch { - let errorData: [String: Any] = [ - "code": OpenIapError.PurchaseError, - "message": defaultMessage(for: OpenIapError.PurchaseError), + FlutterIapLog.failure("requestPurchase", error: error) + let code: ErrorCode = .purchaseError + let errorData: [String: Any?] = [ + "code": code.rawValue, + "message": error.localizedDescription, "productId": sku ] - if let jsonData = try? JSONSerialization.data(withJSONObject: errorData), - let jsonString = String(data: jsonData, encoding: .utf8) { - channel?.invokeMethod("purchase-error", arguments: jsonString) + if let payload = FlutterIapHelper.jsonString(from: FlutterIapHelper.sanitizeDictionary(errorData)) { + channel?.invokeMethod("purchase-error", arguments: payload) } - let code = OpenIapError.PurchaseError - result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) } } } // MARK: - Transaction Management - private func finishTransaction(transactionId: String, result: @escaping FlutterResult) { - print("\(FlutterInappPurchasePlugin.TAG) finishTransaction called with transactionId: '\(transactionId)'") - + private func finishTransaction(purchaseDict: [String: Any], isConsumable: Bool?, result: @escaping FlutterResult) { + FlutterIapLog.payload("finishTransaction", payload: [ + "purchase": purchaseDict, + "isConsumable": isConsumable as Any + ]) Task { @MainActor in do { - _ = try await OpenIapModule.shared.finishTransaction(transactionIdentifier: transactionId) + let purchase = try FlutterIapHelper.decodePurchaseInput(from: purchaseDict) + try await OpenIapModule.shared.finishTransaction(purchase: purchase, isConsumable: isConsumable) + FlutterIapLog.result("finishTransaction", value: true) result(nil) } catch { - await MainActor.run { - let code = OpenIapError.ServiceError - result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) + FlutterIapLog.failure("finishTransaction", error: error) + if let idValue = purchaseDict["id"] as? String, !idValue.isEmpty { + finishTransaction(transactionId: idValue, result: result) + return } + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + private func finishTransaction(transactionId: String, result: @escaping FlutterResult) { + FlutterIapLog.debug("finishTransaction fallback with transactionId: \(transactionId)") + Task { @MainActor in + do { + let fallback = try FlutterIapHelper.fallbackPurchaseInput(for: transactionId) + try await OpenIapModule.shared.finishTransaction(purchase: fallback, isConsumable: nil) + result(nil) + } catch { + FlutterIapLog.failure("finishTransactionFallback", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) } } } @@ -422,15 +403,16 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { // (Moved below iOS-specific features section to align with Expo ordering) @available(iOS 16.0, *) private func presentCodeRedemptionSheetIOS(result: @escaping FlutterResult) { - print("\(FlutterInappPurchasePlugin.TAG) presentCodeRedemptionSheet called") + FlutterIapLog.debug("presentCodeRedemptionSheetIOS called") Task { @MainActor in do { _ = try await OpenIapModule.shared.presentCodeRedemptionSheetIOS() + FlutterIapLog.result("presentCodeRedemptionSheetIOS", value: true) result(nil) } catch { await MainActor.run { - let code = OpenIapError.ServiceError - result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: nil)) } } } @@ -438,29 +420,32 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { @available(iOS 15.0, *) private func showManageSubscriptionsIOS(result: @escaping FlutterResult) { - print("\(FlutterInappPurchasePlugin.TAG) showManageSubscriptions called") + FlutterIapLog.debug("showManageSubscriptionsIOS called") Task { @MainActor in do { _ = try await OpenIapModule.shared.showManageSubscriptionsIOS() + FlutterIapLog.result("showManageSubscriptionsIOS", value: true) result(nil) } catch { await MainActor.run { - let code = OpenIapError.ActivityUnavailable - result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) + let code: ErrorCode = .activityUnavailable + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: nil)) } } } } private func requestPurchaseOnPromotedProductIOS(result: @escaping FlutterResult) { + FlutterIapLog.debug("requestPurchaseOnPromotedProductIOS called") Task { @MainActor in do { try await OpenIapModule.shared.requestPurchaseOnPromotedProductIOS() + FlutterIapLog.result("requestPurchaseOnPromotedProductIOS", value: true) result(nil) } catch { await MainActor.run { - let code = OpenIapError.ServiceError - result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: nil)) } } } @@ -469,40 +454,34 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { private func getPromotedProductIOS(result: @escaping FlutterResult) { Task { @MainActor in do { - // Try to get the promoted product identifier first if let promoted = try await OpenIapModule.shared.getPromotedProductIOS() { - let sku = promoted.productIdentifier - // Fetch full OpenIAP product serialization for consistent shape - let request = OpenIapProductRequest(skus: [sku], type: .all) - let products: [OpenIapProduct] = try await OpenIapModule.shared.fetchProducts(request) - let serialized = OpenIapSerialization.products(products) - if let first = serialized.first { - let sanitized = self.sanitize(dict: first) - result(sanitized) - } else { - result(nil) - } + let serialized = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.encode(promoted)) + FlutterIapLog.result("getPromotedProductIOS", value: serialized) + result(serialized) } else { + FlutterIapLog.info("No promoted product available") result(nil) } } catch { await MainActor.run { - let code = OpenIapError.ServiceError - result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: nil)) } } } } private func getStorefrontIOS(result: @escaping FlutterResult) { + FlutterIapLog.debug("getStorefrontIOS called") Task { @MainActor in do { let code = try await OpenIapModule.shared.getStorefrontIOS() + FlutterIapLog.result("getStorefrontIOS", value: ["countryCode": code]) result(["countryCode": code]) } catch { await MainActor.run { - let code = OpenIapError.ServiceError - result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: nil)) } } } @@ -512,26 +491,30 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { Task { @MainActor in do { let pending = try await OpenIapModule.shared.getPendingTransactionsIOS() - let serialized = OpenIapSerialization.purchases(pending) - let sanitized = serialized.map { self.sanitize(dict: $0) } - result(sanitized) + let purchases = pending.map { Purchase.purchaseIos($0) } + let serialized = FlutterIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases)) + FlutterIapLog.result("getPendingTransactionsIOS", value: serialized) + result(serialized) } catch { await MainActor.run { - let code = OpenIapError.ServiceError - result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: nil)) } } } } private func clearTransactionIOS(result: @escaping FlutterResult) { + FlutterIapLog.debug("clearTransactionIOS called") Task { @MainActor in do { try await OpenIapModule.shared.clearTransactionIOS() + FlutterIapLog.result("clearTransactionIOS", value: true) result(nil) } catch { await MainActor.run { - result(FlutterError(code: OpenIapError.ServiceError, message: error.localizedDescription, details: nil)) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: error.localizedDescription, details: nil)) } } } @@ -540,12 +523,12 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { // MARK: - Receipt Validation (OpenIAP) private func validateReceiptIOS(productId: String, result: @escaping FlutterResult) { - print("\(FlutterInappPurchasePlugin.TAG) validateReceiptIOS called for product: \(productId)") + FlutterIapLog.debug("validateReceiptIOS called for product: \(productId)") Task { @MainActor in do { - let props = OpenIapReceiptValidationProps(sku: productId) + let props = try FlutterIapHelper.decodeReceiptValidationProps(for: productId) let res = try await OpenIapModule.shared.validateReceiptIOS(props) - var payload: [String: Any] = [ + var payload: [String: Any?] = [ "isValid": res.isValid, "receiptData": res.receiptData, // Provide both fields for compatibility with OpenIAP spec and legacy @@ -554,13 +537,15 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { "platform": "ios" ] if let latest = res.latestTransaction { - payload["latestTransaction"] = sanitize(dict: OpenIapSerialization.purchase(latest)) + payload["latestTransaction"] = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(latest)) } - await MainActor.run { result(payload) } + let sanitized = FlutterIapHelper.sanitizeDictionary(payload) + FlutterIapLog.result("validateReceiptIOS", value: sanitized) + await MainActor.run { result(sanitized) } } catch { await MainActor.run { - let code = OpenIapError.TransactionValidationFailed - result(FlutterError(code: code, message: defaultMessage(for: code), details: nil)) + let code: ErrorCode = .transactionValidationFailed + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: nil)) } } } @@ -575,6 +560,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { // No StoreKit product/period type mapping needed; OpenIAP provides serialization } + // Fallback for iOS < 15.0 public class FlutterInappPurchasePluginLegacy: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { @@ -586,6 +572,7 @@ public class FlutterInappPurchasePluginLegacy: NSObject, FlutterPlugin { } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - result(FlutterError(code: OpenIapError.FeatureNotSupported, message: "iOS 15.0+ required", details: nil)) + let code: ErrorCode = .featureNotSupported + result(FlutterError(code: code.rawValue, message: "iOS 15.0+ required", details: nil)) } } diff --git a/lib/flutter_inapp_purchase.dart b/lib/flutter_inapp_purchase.dart index 3e5a73407..bf8530922 100644 --- a/lib/flutter_inapp_purchase.dart +++ b/lib/flutter_inapp_purchase.dart @@ -235,76 +235,30 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { try { if (_platform.isIOS) { final requestVariant = params.request; - String? sku; - bool autoFinish = false; - String? appAccountToken; - int? quantity; - gentype.DiscountOfferInputIOS? offer; + + Map? payload; if (requestVariant is gentype.RequestPurchasePropsRequestSubscription) { - final iosProps = requestVariant.value.ios; - if (iosProps == null) { - throw const gentype.PurchaseError( - code: gentype.ErrorCode.DeveloperError, - message: 'Missing iOS purchase parameters', - ); - } - sku = iosProps.sku; - autoFinish = - iosProps.andDangerouslyFinishTransactionAutomatically ?? - false; - appAccountToken = iosProps.appAccountToken; - quantity = iosProps.quantity; - offer = iosProps.withOffer; + payload = _buildIosPurchasePayload( + nativeType, + requestVariant.value.ios, + ); } else if (requestVariant is gentype.RequestPurchasePropsRequestPurchase) { - final iosProps = requestVariant.value.ios; - if (iosProps == null) { - throw const gentype.PurchaseError( - code: gentype.ErrorCode.DeveloperError, - message: 'Missing iOS purchase parameters', - ); - } - sku = iosProps.sku; - autoFinish = - iosProps.andDangerouslyFinishTransactionAutomatically ?? - false; - appAccountToken = iosProps.appAccountToken; - quantity = iosProps.quantity; - offer = iosProps.withOffer; + payload = _buildIosPurchasePayload( + nativeType, + requestVariant.value.ios, + ); } - if (sku == null || sku.isEmpty) { + if (payload == null) { throw const gentype.PurchaseError( code: gentype.ErrorCode.DeveloperError, message: 'Missing iOS purchase parameters', ); } - final payload = { - 'sku': sku, - 'andDangerouslyFinishTransactionAutomatically': autoFinish, - }; - - if (appAccountToken != null && appAccountToken.isNotEmpty) { - payload['appAccountToken'] = appAccountToken; - } - - if (quantity != null) { - payload['quantity'] = quantity; - } - - if (offer != null) { - payload['withOffer'] = { - 'identifier': offer.identifier, - 'keyIdentifier': offer.keyIdentifier, - 'nonce': offer.nonce, - 'signature': offer.signature, - 'timestamp': offer.timestamp.toString(), - }; - } - await _channel.invokeMethod('requestPurchase', payload); return null; } @@ -455,6 +409,13 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } try { + final normalizedOptions = gentype.PurchaseOptions( + alsoPublishToEventListenerIOS: + options?.alsoPublishToEventListenerIOS ?? false, + onlyIncludeActiveItemsIOS: + options?.onlyIncludeActiveItemsIOS ?? true, + ); + bool hasResolvableIdentifier(gentype.Purchase purchase) { final token = purchase.purchaseToken; if (token != null && token.isNotEmpty) { @@ -469,41 +430,44 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { return purchase.id.isNotEmpty; } - if (_platform.isAndroid) { - // Android unified available items - final dynamic result = - await _channel.invokeMethod('getAvailableItems'); - final items = extractPurchases( - result, - platformIsAndroid: true, - platformIsIOS: false, - acknowledgedAndroidPurchaseTokens: - _acknowledgedAndroidPurchaseTokens, - ); - // Filter out incomplete purchases (must have productId and either purchaseToken or transactionId) - return items - .where( - (p) => p.productId.isNotEmpty && hasResolvableIdentifier(p)) - .toList(); - } else if (_platform.isIOS) { - // On iOS, pass both iOS-specific options to native method - final args = options?.toJson() ?? {}; - - final dynamic result = - await _channel.invokeMethod('getAvailableItems', args); - final items = extractPurchases( - result, - platformIsAndroid: false, - platformIsIOS: true, - acknowledgedAndroidPurchaseTokens: - _acknowledgedAndroidPurchaseTokens, - ); - return items - .where( - (p) => p.productId.isNotEmpty && hasResolvableIdentifier(p)) - .toList(); + Future> resolvePurchases() async { + List raw = const []; + + if (_platform.isIOS) { + final args = { + 'alsoPublishToEventListenerIOS': + normalizedOptions.alsoPublishToEventListenerIOS ?? false, + 'onlyIncludeActiveItemsIOS': + normalizedOptions.onlyIncludeActiveItemsIOS ?? true, + }; + final dynamic result = + await _channel.invokeMethod('getAvailableItems', args); + raw = extractPurchases( + result, + platformIsAndroid: false, + platformIsIOS: true, + acknowledgedAndroidPurchaseTokens: + _acknowledgedAndroidPurchaseTokens, + ); + } else if (_platform.isAndroid) { + final dynamic result = + await _channel.invokeMethod('getAvailableItems'); + raw = extractPurchases( + result, + platformIsAndroid: true, + platformIsIOS: false, + acknowledgedAndroidPurchaseTokens: + _acknowledgedAndroidPurchaseTokens, + ); + } + + return raw + .where((purchase) => purchase.productId.isNotEmpty) + .where(hasResolvableIdentifier) + .toList(growable: false); } - return const []; + + return await resolvePurchases(); } catch (error) { throw gentype.PurchaseError( code: gentype.ErrorCode.ServiceError, @@ -512,6 +476,58 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } }; + Map? _buildIosPurchasePayload( + String nativeType, + Object? iosProps, + ) { + if (iosProps == null) { + return null; + } + + Map propsJson; + if (iosProps is gentype.RequestPurchaseIosProps) { + propsJson = iosProps.toJson(); + } else if (iosProps is gentype.RequestSubscriptionIosProps) { + propsJson = iosProps.toJson(); + } else { + return null; + } + + final String? sku = propsJson['sku'] as String?; + if (sku == null || sku.isEmpty) { + return null; + } + + final payload = { + 'sku': sku, + 'type': nativeType, + 'andDangerouslyFinishTransactionAutomatically': + (propsJson['andDangerouslyFinishTransactionAutomatically'] + as bool?) ?? + false, + }; + + final String? appAccountToken = propsJson['appAccountToken'] as String?; + if (appAccountToken != null && appAccountToken.isNotEmpty) { + payload['appAccountToken'] = appAccountToken; + } + + final dynamic quantityValue = propsJson['quantity']; + if (quantityValue is int) { + payload['quantity'] = quantityValue; + } else if (quantityValue is num) { + payload['quantity'] = quantityValue.toInt(); + } + + final dynamic offerValue = propsJson['withOffer']; + if (offerValue is Map) { + payload['withOffer'] = Map.from(offerValue); + } + + payload.removeWhere((_, value) => value == null); + return payload; + } + /// iOS specific: Get storefront gentype.QueryGetStorefrontIOSHandler get getStorefrontIOS => () async { if (!_platform.isIOS) { @@ -992,7 +1008,51 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { 'purchaseToken': purchaseToken, }, ); - final didAcknowledgeSucceed = _didAndroidAcknowledgeSucceed(result); + bool didAcknowledgeSucceed(dynamic response) { + if (response == null) { + return false; + } + + if (response is bool) { + return response; + } + + Map? parsed; + + if (response is String) { + try { + final dynamic decoded = jsonDecode(response); + if (decoded is Map) { + parsed = decoded; + } else { + return false; + } + } catch (_) { + return false; + } + } else if (response is Map) { + parsed = Map.from(response); + } + + if (parsed != null) { + final dynamic code = parsed['responseCode']; + if (code is num && code == 0) { + return true; + } + if (code is String && int.tryParse(code) == 0) { + return true; + } + + final bool? success = parsed['success'] as bool?; + if (success != null) { + return success; + } + } + + return false; + } + + final didAcknowledge = didAcknowledgeSucceed(result); parseAndLogAndroidResponse( result, successLog: @@ -1000,7 +1060,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { failureLog: '[FlutterInappPurchase] Android: Failed to parse acknowledge response', ); - if (didAcknowledgeSucceed) { + if (didAcknowledge) { _acknowledgedAndroidPurchaseTokens[purchaseToken] = true; } else if (kDebugMode) { debugPrint( @@ -1014,9 +1074,12 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { debugPrint( '[FlutterInappPurchase] iOS: Finishing transaction with ID: $transactionId', ); - await _channel.invokeMethod('finishTransaction', { + final payload = { 'transactionId': transactionId, - }); + 'purchase': purchase.toJson(), + 'isConsumable': consumable, + }; + await _channel.invokeMethod('finishTransaction', payload); return; } @@ -1193,10 +1256,15 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { for (final item in merged) { try { final Map itemMap; - if (item is Map) { - itemMap = item; - } else if (item is Map) { - itemMap = Map.from(item); + if (item is Map) { + final normalized = normalizeDynamicMap(item); + if (normalized == null) { + debugPrint( + '[flutter_inapp_purchase] Skipping product with null map after normalization: ${item.runtimeType}', + ); + continue; + } + itemMap = normalized; } else { debugPrint( '[flutter_inapp_purchase] Skipping unexpected item type: ${item.runtimeType}', @@ -1217,6 +1285,10 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { debugPrint( '[flutter_inapp_purchase] Skipping product due to parse error: $error', ); + debugPrint( + '[flutter_inapp_purchase] Item runtimeType: ${item.runtimeType}'); + debugPrint( + '[flutter_inapp_purchase] Item values: ${jsonEncode(item)}'); } } @@ -1248,50 +1320,6 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } }; - bool _didAndroidAcknowledgeSucceed(dynamic response) { - if (response == null) { - return false; - } - - if (response is bool) { - return response; - } - - Map? parsed; - - if (response is String) { - try { - final dynamic decoded = jsonDecode(response); - if (decoded is Map) { - parsed = decoded; - } else { - return false; - } - } catch (_) { - return false; - } - } else if (response is Map) { - parsed = Map.from(response); - } - - if (parsed != null) { - final dynamic code = parsed['responseCode']; - if (code is num && code == 0) { - return true; - } - if (code is String && int.tryParse(code) == 0) { - return true; - } - - final bool? success = parsed['success'] as bool?; - if (success != null) { - return success; - } - } - - return false; - } - // MARK: - StoreKit 2 specific methods gentype.MutationRestorePurchasesHandler get restorePurchases => () async { diff --git a/lib/helpers.dart b/lib/helpers.dart index cd3b46f0b..ec59dda1f 100644 --- a/lib/helpers.dart +++ b/lib/helpers.dart @@ -590,20 +590,16 @@ gentype.PricingPhasesAndroid _parsePricingPhases(dynamic json) { } gentype.SubscriptionInfoIOS? _parseSubscriptionInfoIOS(dynamic value) { - if (value is Map) { - return gentype.SubscriptionInfoIOS.fromJson(value); - } - if (value is Map) { - return gentype.SubscriptionInfoIOS.fromJson( - Map.from(value), - ); + final normalizedMap = normalizeDynamicMap(value); + if (normalizedMap != null) { + return gentype.SubscriptionInfoIOS.fromJson(normalizedMap); } if (value is List && value.isNotEmpty) { - final first = value.first; - if (first is Map) { - return gentype.SubscriptionInfoIOS.fromJson( - Map.from(first), - ); + for (final candidate in value) { + final map = normalizeDynamicMap(candidate); + if (map != null) { + return gentype.SubscriptionInfoIOS.fromJson(map); + } } } return null; @@ -619,6 +615,39 @@ gentype.SubscriptionPeriodIOS? _parseSubscriptionPeriod(dynamic value) { } } +Map? normalizeDynamicMap(dynamic value) { + if (value is Map) { + return value.map( + (key, dynamic val) => MapEntry(key, normalizeDynamicValue(val)), + ); + } + if (value is Map) { + final normalized = {}; + value.forEach((dynamic key, dynamic val) { + if (key == null) { + return; + } + final stringKey = key.toString(); + if (stringKey.isEmpty) { + return; + } + normalized[stringKey] = normalizeDynamicValue(val); + }); + return normalized; + } + return null; +} + +dynamic normalizeDynamicValue(dynamic value) { + if (value is Map || value is Map) { + return normalizeDynamicMap(value); + } + if (value is List) { + return value.map(normalizeDynamicValue).toList(); + } + return value; +} + gentype.PaymentModeIOS? _parsePaymentMode(dynamic value) { if (value == null) return null; final raw = value.toString().toUpperCase(); diff --git a/test/subscription_info_parse_test.dart b/test/subscription_info_parse_test.dart new file mode 100644 index 000000000..426378d51 --- /dev/null +++ b/test/subscription_info_parse_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; +import 'package:flutter_inapp_purchase/types.dart' as types; + +void main() { + test('parse subscription info ios from dynamic map', () { + final Map raw = { + 'id': 'dev.hyo.martie.premium', + 'title': 'Premium', + 'description': 'Martie Premium', + 'currency': 'KRW', + 'displayName': 'Premium', + 'displayNameIOS': 'Premium', + 'displayPrice': '₩14,000', + 'price': 14000, + 'type': 'subs', + 'typeIOS': 'auto-renewable-subscription', + 'isFamilyShareableIOS': false, + 'jsonRepresentationIOS': '{}', + 'platform': 'ios', + 'subscriptionPeriodUnitIOS': 'month', + 'subscriptionInfoIOS': { + 'subscriptionPeriod': { + 'value': 1, + 'unit': 'month', + }, + 'subscriptionGroupId': '21686373', + }, + }; + + final product = parseProductFromNative( + raw, + 'subs', + fallbackIsIOS: true, + ); + + expect(product, isA()); + final subscription = product as types.ProductSubscriptionIOS; + expect(subscription.subscriptionInfoIOS, isNotNull); + expect(subscription.subscriptionInfoIOS!.subscriptionGroupId, '21686373'); + expect( + subscription.subscriptionInfoIOS!.subscriptionPeriod.unit, + types.SubscriptionPeriodIOS.Month, + ); + }); +}