refactor: modernize iOS Plugin and Improve Purchase Flow#560
Conversation
Revamps the iOS native layer for better stability and maintainability. Introduces `FlutterIapHelper` for data sanitization and parsing, and `FlutterIapLog` for structured logging. The main plugin class is refactored to delegate logic to these new components, resulting in cleaner, more robust code aligned with the underlying OpenIAP library.
Enhances the core logic for handling subscriptions and purchases. This commit refactors the `requestPurchase`, `getAvailableItems`, and `finishTransaction` methods for improved reliability. It also introduces helper functions for data normalization and adds tests for parsing subscription information, ensuring more robust data handling.
Improves the example apps UI for managing subscriptions and viewing purchase history. This commit refactors the subscription and purchase screens to provide a clearer and more interactive user experience. Key changes include separating purchase history loading, adding explicit buttons to load or reload history, and displaying more detailed subscription information like expiration dates and auto-renewal status.
Updates project dependencies and build-related configurations. This commit updates the `openiap` dependency to version `1.2.2`. It also includes improvements to the Android build script for locating version files and adds a new VS Code launch configuration for easier debugging on iOS devices.
|
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. WalkthroughAdds iOS helper and logging utilities, refactors iOS plugin to use OpenIAP decoding and standardized errors, normalizes Dart parsing and purchase flows, enhances example screens (iOS history/subscriptions), adds normalization helpers and tests, makes Android Gradle locate openiap-versions.json upward, and adds a VSCode launch config. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor App as Flutter App (Dart)
participant Dart as flutter_inapp_purchase.dart
participant MethodCh as MethodChannel
participant iOS as FlutterInappPurchasePlugin (iOS)
participant Helper as FlutterIapHelper
participant Log as FlutterIapLog
rect rgba(200,230,255,0.25)
App->>Dart: getAvailablePurchases(options)
Dart->>Dart: normalize options (PurchaseOptions)
Dart->>MethodCh: invokeMethod("getAvailableItems", normalized)
MethodCh->>iOS: getAvailableItems(params)
iOS->>Helper: decodePurchaseOptions(params)
Helper-->>iOS: PurchaseOptions
iOS->>Log: payload("getAvailableItems", params)
iOS-->>MethodCh: sanitized items
MethodCh-->>Dart: items
Dart->>Dart: resolve/aggregate results
Dart-->>App: purchases list
end
sequenceDiagram
autonumber
actor App as Flutter App (Dart)
participant Dart as flutter_inapp_purchase.dart
participant MethodCh as MethodChannel
participant iOS as FlutterInappPurchasePlugin (iOS)
participant Helper as FlutterIapHelper
participant Log as FlutterIapLog
rect rgba(220,255,220,0.25)
App->>Dart: requestPurchase(props)
Dart->>Dart: _buildIosPurchasePayload(...)
Dart->>MethodCh: invokeMethod("requestPurchase", payload)
MethodCh->>iOS: requestPurchase(payload)
iOS->>Helper: decodeRequestPurchaseProps(payload)
Helper-->>iOS: RequestPurchaseProps
iOS->>Log: payload("requestPurchase", normalized)
iOS-->>MethodCh: result / error (standardized)
MethodCh-->>Dart: response
end
rect rgba(255,230,200,0.25)
App->>Dart: finishTransaction(purchase, isConsumable)
Dart->>MethodCh: invokeMethod("finishTransaction", {transactionId,purchase,isConsumable})
MethodCh->>iOS: finishTransaction(payload)
iOS->>Log: payload("finishTransaction", payload)
iOS-->>MethodCh: status
MethodCh-->>Dart: status
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary of ChangesHello @hyochan, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request focuses on modernizing the iOS plugin and refining the in-app purchase experience within the example application. The changes introduce a more structured approach to iOS native code, improve the reliability of data exchange between Flutter and native platforms, and provide a clearer user interface for managing purchases and subscriptions. The primary goal is to create a more stable and debuggable in-app purchase system. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #560 +/- ##
==========================================
+ Coverage 30.45% 32.24% +1.79%
==========================================
Files 8 8
Lines 2243 2267 +24
==========================================
+ Hits 683 731 +48
+ Misses 1560 1536 -24 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Code Review
This pull request is a significant and well-executed refactoring of the iOS plugin and example app. It modernizes the Swift code by introducing structured logging, better error handling, and cleaner payload management through helper classes, aligning with the updated OpenIAP library. The changes in the example app, particularly for handling purchase history and active subscriptions, greatly improve the user experience and provide a more robust demonstration. My review focuses on a few key areas to ensure the stability of this large refactor: I've identified a potential data leak in the new logging sanitization logic, a risk of type mismatch from removed data coercion that could affect Dart-side parsing, and a minor configuration issue with a hardcoded device ID. Addressing these points will help solidify this excellent modernization effort.
ios/Classes/FlutterIapHelper.swift
Outdated
| if let optionalDictionary = value as? [String: Any?] { | ||
| return sanitizeDictionary(optionalDictionary) | ||
| } |
There was a problem hiding this comment.
The sanitizeValue function has inconsistent logic for handling dictionaries. A dictionary of type [String: Any] is sanitized recursively, which includes hiding tokens. However, a dictionary of type [String: Any?] is only passed to sanitizeDictionary, which just removes nil values but does not perform recursive sanitization or token hiding. This can lead to sensitive tokens being logged if they are nested inside a dictionary with optional values.
You can refactor this function to handle both [String: Any] and [String: Any?] with a single, recursive logic block by casting to [String: Any?]. Similarly, array handling can be simplified.
Example refactor:
static func sanitizeValue(_ value: Any?) -> Any? {
guard let value else { return nil }
if let dictionary = value as? [String: Any?] {
return dictionary.reduce(into: [String: Any]()) { result, element in
let (key, rawValue) = element
if key.lowercased().contains("token") {
result[key] = "hidden"
} else if let sanitized = sanitizeValue(rawValue) {
result[key] = sanitized
}
}
}
if let array = value as? [Any?] {
return array.compactMap { sanitizeValue($0) }
}
return value
}| return dict | ||
| } | ||
| await MainActor.run { result(sanitized) } | ||
| let serialized = FlutterIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases)) |
There was a problem hiding this comment.
The previous implementation of getAvailableItems included logic to coerce NSNumber fields (like transactionId, webOrderLineItemIdIOS, etc.) to String before sending them to the Dart side. This refactoring has removed that coercion logic. If the underlying OpenIapModule still returns these identifiers as NSNumber, this change could cause parsing errors or crashes on the Dart side, which may expect these fields to be strings. Please verify that OpenIapSerialization.purchases now correctly returns all identifier fields as strings, or consider re-introducing the type coercion to prevent potential runtime issues.
.vscode/launch.json
Outdated
| "request": "launch", | ||
| "program": "lib/main.dart", | ||
| "cwd": "${workspaceFolder}/example", | ||
| "deviceId": "00008101-0006455036D8001E", |
There was a problem hiding this comment.
A hardcoded deviceId has been added to this shared launch configuration. This value is specific to a developer's machine and will likely cause this configuration to fail for other contributors. It's best to avoid committing machine-specific settings. Please consider removing this line or using an environment variable (e.g., ${env:IOS_DEVICE_ID}) to make it configurable for everyone.
| } | ||
|
|
||
| _subscriptions = products; | ||
| _subscriptions = List<ProductCommon>.from(products, growable: false); |
There was a problem hiding this comment.
While creating a new list from products is safe, you can avoid creating an intermediate list by using List.of with growable: false for a more direct and slightly more efficient approach, especially if products is already a list.
| _subscriptions = List<ProductCommon>.from(products, growable: false); | |
| _subscriptions = List.of(products, growable: false); |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (6)
example/lib/src/screens/purchase_flow_screen.dart (2)
90-91: Avoid logging full purchase tokens/objects (PII).Mask or truncate tokens and avoid dumping full objects to logs.
Apply this diff:
- debugPrint('PurchaseToken: ${purchase.purchaseToken}'); - debugPrint('Full purchase data: $purchase'); + final masked = _mask(purchase.purchaseToken); + debugPrint('PurchaseToken: $masked'); + debugPrint('Full purchase data: <redacted>');Add this helper (anywhere in the State class):
String _mask(String? v, {int showLast = 4}) { if (v == null || v.isEmpty) return 'N/A'; final keep = v.length <= showLast ? v.length : showLast; return '*' * (v.length - keep) + v.substring(v.length - keep); }
210-214: Mask tokens in success logs as well.Same concern on success path.
Apply this diff:
- debugPrint('Purchase token: ${purchase.purchaseToken}'); + debugPrint('Purchase token: ${_mask(purchase.purchaseToken)}');lib/helpers.dart (1)
717-746: Bug: defaulting unknown Android purchase state to Purchased.This can grant entitlements erroneously. Default to Unknown instead.
Apply this diff:
int _coerceAndroidPurchaseState(dynamic value) { if (value == null) { - return AndroidPurchaseState.Purchased.value; + return AndroidPurchaseState.Unknown.value; } @@ if (value is String) { @@ } - return AndroidPurchaseState.Purchased.value; + return AndroidPurchaseState.Unknown.value; }lib/flutter_inapp_purchase.dart (1)
1372-1411: Heuristic for iOS subscription detection is too weak.
receipt != null || productId.contains('sub')can misclassify. Use iOS fields instead and check expiry.Apply this diff:
- } else if (purchase is gentype.PurchaseIOS) { - final receipt = purchase.purchaseToken; - final bool isSubscription = - receipt != null || purchase.productId.contains('sub'); - final bool isActive = - (purchase.purchaseState == gentype.PurchaseState.Purchased || - purchase.purchaseState == - gentype.PurchaseState.Restored) && - isSubscription; + } else if (purchase is gentype.PurchaseIOS) { + final nowMs = DateTime.now().millisecondsSinceEpoch.toDouble(); + final bool isSubscription = + (purchase.subscriptionGroupIdIOS?.isNotEmpty ?? false) || + (purchase.originalTransactionIdentifierIOS?.isNotEmpty ?? + false); + final bool notExpired = + purchase.expirationDateIOS == null || + (purchase.expirationDateIOS! > 0 && + purchase.expirationDateIOS! > nowMs); + final bool isActive = + isSubscription && + (purchase.purchaseState == gentype.PurchaseState.Purchased || + purchase.purchaseState == + gentype.PurchaseState.Restored) && + notExpired;example/lib/src/screens/subscription_flow_screen.dart (2)
1082-1086: Potential RangeError:substring(0, 10)without length guardIf the token is shorter than 10 chars, this throws. Use a safe preview helper.
- 'Token: ${_currentSubscription!.purchaseToken?.substring(0, 10)}...', + 'Token: ${_safeTokenPreview(_currentSubscription!.purchaseToken, 10)}',And add:
+ String _safeTokenPreview(String? token, int maxLen) { + if (token == null) return 'null'; + return token.length <= maxLen ? token : '${token.substring(0, maxLen)}...'; + }
620-624: Samesubstringissue hereGuard length when previewing
testToken.- debugPrint('Using test token: ${testToken.substring(0, 20)}...'); + debugPrint('Using test token: ${_safeTokenPreview(testToken, 20)}');
🧹 Nitpick comments (18)
.vscode/launch.json (2)
88-96: Avoid committing a machine‑specific iOS deviceId (UDID).Hard-coding
"deviceId": "00008101-0006455036D8001E"will fail for other contributors and causes churn when devices change. Make this config portable by removing the deviceId and letting Flutter/Dart select the active iOS device/simulator at launch.Apply this diff:
- }, - { - "name": "Example (iOS Device)", + }, + { + "name": "Flutter: Run Example App (iOS)", "type": "dart", "request": "launch", "program": "lib/main.dart", "cwd": "${workspaceFolder}/example", - "deviceId": "00008101-0006455036D8001E", "args": [] }Optional: if you want a pinned-device config for personal use, keep it in an ignored file (e.g., .vscode/launch.local.json) and add it to .gitignore.
88-96: Naming consistency with existing Flutter entries.Rename “Example (iOS Device)” to follow the existing “Flutter: Run Example App (…)” pattern for quick palette filtering.
android/build.gradle (2)
3-15: Helper is solid; consider droppingstaticfor Gradle/Groovy script safetyTop‑level
staticmethods in Groovy scripts can be finicky across Gradle versions. A plain script method is sufficient and avoids edge compatibility issues.Apply this small tweak:
-static File locateOpeniapVersionsFile(File startDir) { +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}" ) }Please run a local Gradle sync/build to confirm no configuration‑time warnings/regressions after this change.
17-17: Start search fromprojectDirfor broader coverageStarting at
rootDirskips the module’s own directory; beginning atprojectDirfinds files placed beside this module and still walks up to the repo root or beyond.-def openiapVersionsFile = locateOpeniapVersionsFile(rootDir) +def openiapVersionsFile = locateOpeniapVersionsFile(projectDir)sanity check: ensure the intended
openiap-versions.jsonis still resolved in your CI and local dev layouts (monorepo, nested includes).test/subscription_info_parse_test.dart (1)
31-45: Nice focused test; consider asserting product type too.Add a quick check to guard against accidental type regressions.
Apply this diff:
final subscription = product as types.ProductSubscriptionIOS; + expect(subscription.type, types.ProductType.Subs);lib/helpers.dart (1)
478-488: Guard against non-list discounts payloads.Casting directly to List can throw when servers send objects.
Apply this diff:
gentype.DiscountIOS? _asDiscount(dynamic e) { if (e is Map<String, dynamic>) return gentype.DiscountIOS.fromJson(e); if (e is Map) return gentype.DiscountIOS.fromJson(Map<String, dynamic>.from(e)); return null; } List<gentype.DiscountIOS>? _parseDiscountsIOS(dynamic json) { - if (json == null) return null; - final list = json as List<dynamic>; - return list - .map( - (e) => gentype.DiscountIOS.fromJson( - e is Map<String, dynamic> ? e : Map<String, dynamic>.from(e as Map), - ), - ) - .toList(); + if (json == null) return null; + if (json is! List) return null; + return (json as List) + .map<gentype.DiscountIOS?>(_asDiscount) + .whereType<gentype.DiscountIOS>() + .toList(growable: false); }example/lib/src/screens/available_purchases_screen.dart (2)
200-204: Deduplicate history list too (optional).Keeps UI consistent with active list behavior.
Apply this diff:
- setState(() { - _purchaseHistory = purchaseHistory; + setState(() { + _purchaseHistory = _deduplicatePurchases(purchaseHistory); _historyError = null; });
136-147: Mounted early-return is fine; minor: ensure loading reset in non-mounted cases.Not harmful since widget is gone, but can trap spinners in tests. Optional to set local flags before return.
lib/flutter_inapp_purchase.dart (1)
1011-1070: Acknowledge response parsing is robust; consider extraction.Minor: extract
didAcknowledgeSucceedto utils for reuse/testing.ios/Classes/FlutterIapLog.swift (2)
22-35: Make access tohandler/isEnabledthread-safeThese statics can be mutated/read from different threads. Consider a lightweight synchronization (e.g., serial queue) to avoid data races.
Also applies to: 49-55
75-93: stringify: considerEncodablesupport for richer payloadsIf
sanitizedvalues conform toEncodable, encode viaJSONEncoderbefore falling back toString(describing:).ios/Classes/FlutterInappPurchasePlugin.swift (1)
507-521: Unify error messaging with the rest of the fileHere you surface
error.localizedDescriptiondirectly; elsewhere you usedefaultMessage(for:). Align for consistency.- result(FlutterError(code: code.rawValue, message: error.localizedDescription, details: nil)) + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription))example/lib/src/screens/subscription_flow_screen.dart (3)
330-369: Active subscription resolution is thoughtful; keep time unit consistentYou compare dates with a 2s tolerance assuming milliseconds. If either side is seconds, matching may fail. Consider normalizing to ms or guarding by magnitude.
- const double matchToleranceMs = 2000; // 2 seconds tolerance + const double matchToleranceMs = 2000; // ms + num norm(num t) => t > 1e12 ? t : t * 1000; // seconds -> ms ... - final matchesDate = - (purchase.transactionDate - summary.transactionDate).abs() <= - matchToleranceMs; + final matchesDate = + (norm(purchase.transactionDate) - norm(summary.transactionDate)) + .abs() <= matchToleranceMs;Also applies to: 372-420, 423-461
1254-1262: UI shows full token in multiple places; mask it in productionEven in examples, prefer token previews. You already added a helper—reuse it here when printing/showing tokens.
Also applies to: 1265-1324
835-841: Date formatting: handle seconds vs msIf an iOS field arrives in seconds, this will render 1970 dates. Normalize as in the earlier suggestion.
- final date = DateTime.fromMillisecondsSinceEpoch(timestamp.round()); + final millis = timestamp >= 1e12 ? timestamp : timestamp * 1000; + final date = DateTime.fromMillisecondsSinceEpoch(millis.round());ios/Classes/FlutterIapHelper.swift (3)
147-160: Set a realistictransactionDatein fallback purchase input
0can break ordering and matching heuristics. Use current time in ms.- "transactionDate": 0 + "transactionDate": Int(Date().timeIntervalSince1970 * 1000)
50-55: jsonString helper: optionally pretty‑print in DEBUGMinor: in DEBUG, consider
.prettyPrintedfor easier dev inspection.
58-72: parseProductQueryType: accept common aliasesOptional: accept "inapp", "in-app", "subs", "subscriptions" for resilience.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
example/ios/Podfile.lockis excluded by!**/*.lock
📒 Files selected for processing (11)
.vscode/launch.json(1 hunks)android/build.gradle(1 hunks)example/lib/src/screens/available_purchases_screen.dart(5 hunks)example/lib/src/screens/purchase_flow_screen.dart(1 hunks)example/lib/src/screens/subscription_flow_screen.dart(7 hunks)ios/Classes/FlutterIapHelper.swift(1 hunks)ios/Classes/FlutterIapLog.swift(1 hunks)ios/Classes/FlutterInappPurchasePlugin.swift(19 hunks)lib/flutter_inapp_purchase.dart(8 hunks)lib/helpers.dart(2 hunks)test/subscription_info_parse_test.dart(1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.dart
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.dart: Use OpenIAP ProductRequest with iap.fetchProducts and handle the FetchProductsResult union
Use helper extensions from flutter_inapp_purchase.dart (inAppProducts(), subscriptionProducts(), allProducts()) to unwrap FetchProductsResult safely
Use getAvailablePurchases with PurchaseOptions for OpenIAP compliance; prefer defaults unless specific iOS options are needed
Do not use getPurchaseHistories (deprecated); replace with getAvailablePurchases and options as needed
When calling generic functions (e.g., showModalBottomSheet), specify explicit type arguments (e.g., showModalBottomSheet)
iOS-related types/functions use IOS suffix (e.g., PurchaseIOS, SubscriptionOfferIOS)
When iOS is not the final suffix, use Ios (e.g., IosManager, IosHelper)
For field names with iOS in the middle, place Id before IOS (e.g., subscriptionGroupIdIOS, webOrderLineItemIdIOS)
Android-related types/functions use Android suffix (e.g., PurchaseAndroid, SubscriptionOfferAndroid)
When IAP is not the final suffix, use Iap (e.g., IapPurchase, not IAPPurchase)
Use Id (not ID) in identifiers (e.g., productId, transactionId, offerId); when combined with platform suffixes, use Id before the suffix (e.g., subscriptionGroupIdIOS, obfuscatedAccountIdAndroid)
Exception: iOS-only fields that end with ID use ID (e.g., transactionID, webOrderLineItemID)
Functions that depend on event results must use the request prefix (e.g., requestPurchase, requestPurchaseWithBuilder)
Follow OpenIAP terminology for API method naming; avoid generic prefixes like get or find
Files:
test/subscription_info_parse_test.dartexample/lib/src/screens/subscription_flow_screen.dartexample/lib/src/screens/available_purchases_screen.dartexample/lib/src/screens/purchase_flow_screen.dartlib/helpers.dartlib/flutter_inapp_purchase.dart
🧠 Learnings (16)
📓 Common learnings
Learnt from: hyochan
PR: hyochan/flutter_inapp_purchase#531
File: docs/versioned_docs/version-6.3/faq.md:164-172
Timestamp: 2025-08-21T02:01:17.634Z
Learning: When reviewing PRs for flutter_inapp_purchase, focus on the current version being developed rather than versioned documentation files, as those are historical snapshots.
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : iOS-related types/functions use IOS suffix (e.g., PurchaseIOS, SubscriptionOfferIOS)
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : Use getAvailablePurchases with PurchaseOptions for OpenIAP compliance; prefer defaults unless specific iOS options are needed
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : Android-related types/functions use Android suffix (e.g., PurchaseAndroid, SubscriptionOfferAndroid)
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : Use OpenIAP ProductRequest with iap.fetchProducts and handle the FetchProductsResult union
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : Use helper extensions from flutter_inapp_purchase.dart (inAppProducts(), subscriptionProducts(), allProducts()) to unwrap FetchProductsResult safely
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : For field names with iOS in the middle, place Id before IOS (e.g., subscriptionGroupIdIOS, webOrderLineItemIdIOS)
📚 Learning: 2025-09-20T07:27:44.884Z
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : iOS-related types/functions use IOS suffix (e.g., PurchaseIOS, SubscriptionOfferIOS)
Applied to files:
test/subscription_info_parse_test.dartios/Classes/FlutterInappPurchasePlugin.swiftlib/helpers.dartlib/flutter_inapp_purchase.dart
📚 Learning: 2025-09-20T07:27:44.884Z
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : Use helper extensions from flutter_inapp_purchase.dart (inAppProducts(), subscriptionProducts(), allProducts()) to unwrap FetchProductsResult safely
Applied to files:
test/subscription_info_parse_test.dartexample/lib/src/screens/subscription_flow_screen.dartios/Classes/FlutterInappPurchasePlugin.swiftexample/lib/src/screens/purchase_flow_screen.dartios/Classes/FlutterIapHelper.swiftlib/flutter_inapp_purchase.dart
📚 Learning: 2025-09-20T07:27:44.884Z
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : Use OpenIAP ProductRequest with iap.fetchProducts and handle the FetchProductsResult union
Applied to files:
test/subscription_info_parse_test.dartexample/lib/src/screens/subscription_flow_screen.dartios/Classes/FlutterInappPurchasePlugin.swiftexample/lib/src/screens/purchase_flow_screen.dartios/Classes/FlutterIapHelper.swiftlib/flutter_inapp_purchase.dart
📚 Learning: 2025-09-20T07:27:44.884Z
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : Use getAvailablePurchases with PurchaseOptions for OpenIAP compliance; prefer defaults unless specific iOS options are needed
Applied to files:
test/subscription_info_parse_test.dartios/Classes/FlutterInappPurchasePlugin.swiftexample/lib/src/screens/available_purchases_screen.dartexample/lib/src/screens/purchase_flow_screen.dartios/Classes/FlutterIapHelper.swiftlib/flutter_inapp_purchase.dart
📚 Learning: 2025-09-20T07:27:44.884Z
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to lib/types.dart : Do not edit lib/types.dart (generated from OpenIAP schema)
Applied to files:
test/subscription_info_parse_test.dart
📚 Learning: 2025-09-09T06:04:46.743Z
Learnt from: hyochan
PR: hyochan/flutter_inapp_purchase#545
File: lib/flutter_inapp_purchase.dart:1665-1665
Timestamp: 2025-09-09T06:04:46.743Z
Learning: In flutter_inapp_purchase project, prefer using iap_err.getCurrentPlatform() in error handling over hardcoded platform constants to ensure accurate runtime platform reporting in error messages and debugging.
Applied to files:
ios/Classes/FlutterInappPurchasePlugin.swiftexample/lib/src/screens/available_purchases_screen.dartlib/flutter_inapp_purchase.dart
📚 Learning: 2025-08-21T01:20:28.525Z
Learnt from: hyochan
PR: hyochan/flutter_inapp_purchase#531
File: docs/versioned_docs/version-6.3/api/overview.md:128-131
Timestamp: 2025-08-21T01:20:28.525Z
Learning: The flutter_inapp_purchase library follows the OpenIAP specification for parameter naming. According to the specification at https://www.openiap.dev/docs/types#request-types, the parameter name should be "skus" (not "productIds") when requesting products, as confirmed by the project maintainer hyochan.
Applied to files:
ios/Classes/FlutterInappPurchasePlugin.swift
📚 Learning: 2025-08-21T01:20:28.525Z
Learnt from: hyochan
PR: hyochan/flutter_inapp_purchase#531
File: docs/versioned_docs/version-6.3/api/overview.md:128-131
Timestamp: 2025-08-21T01:20:28.525Z
Learning: The flutter_inapp_purchase library follows the OpenIAP specification at https://www.openiap.dev/docs/types#request-types for parameter naming conventions. According to hyochan, "skus" is the correct parameter name based on this specification, not "productIds".
Applied to files:
ios/Classes/FlutterInappPurchasePlugin.swift
📚 Learning: 2025-08-21T01:20:28.525Z
Learnt from: hyochan
PR: hyochan/flutter_inapp_purchase#531
File: docs/versioned_docs/version-6.3/api/overview.md:128-131
Timestamp: 2025-08-21T01:20:28.525Z
Learning: According to the OpenIAP specification at https://www.openiap.dev/docs/types#request-types, the parameter name for requesting products should be "skus" rather than "productIds" to maintain compliance with the OpenIAP standard.
Applied to files:
ios/Classes/FlutterInappPurchasePlugin.swift
📚 Learning: 2025-08-21T02:25:46.402Z
Learnt from: hyochan
PR: hyochan/flutter_inapp_purchase#531
File: docs/versioned_docs/version-6.3/api/index.md:65-69
Timestamp: 2025-08-21T02:25:46.402Z
Learning: When reviewing flutter_inapp_purchase documentation, `purchaseUpdatedListener` and `purchaseErrorListener` are the correct listener names in the public API, not `purchaseUpdated` and `purchaseError`.
Applied to files:
ios/Classes/FlutterInappPurchasePlugin.swift
📚 Learning: 2025-09-20T07:27:44.884Z
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : Do not use getPurchaseHistories (deprecated); replace with getAvailablePurchases and options as needed
Applied to files:
example/lib/src/screens/available_purchases_screen.dartexample/lib/src/screens/purchase_flow_screen.dartlib/flutter_inapp_purchase.dart
📚 Learning: 2025-09-20T07:27:44.884Z
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : Android-related types/functions use Android suffix (e.g., PurchaseAndroid, SubscriptionOfferAndroid)
Applied to files:
lib/flutter_inapp_purchase.dart
📚 Learning: 2025-09-20T07:27:44.884Z
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : When IAP is not the final suffix, use Iap (e.g., IapPurchase, not IAPPurchase)
Applied to files:
lib/flutter_inapp_purchase.dart
📚 Learning: 2025-09-20T07:27:44.884Z
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : Exception: iOS-only fields that end with ID use ID (e.g., transactionID, webOrderLineItemID)
Applied to files:
lib/flutter_inapp_purchase.dart
📚 Learning: 2025-08-21T02:01:17.634Z
Learnt from: hyochan
PR: hyochan/flutter_inapp_purchase#531
File: docs/versioned_docs/version-6.3/faq.md:164-172
Timestamp: 2025-08-21T02:01:17.634Z
Learning: When reviewing PRs for flutter_inapp_purchase, focus on the current version being developed rather than versioned documentation files, as those are historical snapshots.
Applied to files:
lib/flutter_inapp_purchase.dart
🧬 Code graph analysis (3)
ios/Classes/FlutterInappPurchasePlugin.swift (2)
ios/Classes/FlutterIapLog.swift (6)
debug(32-32)result(41-43)payload(37-39)error(35-35)failure(45-47)info(33-33)ios/Classes/FlutterIapHelper.swift (10)
sanitizeDictionary(7-15)jsonString(50-54)decodeProductRequest(74-90)sanitizeArray(17-19)decodePurchaseOptions(92-94)decodePurchaseOptions(96-101)decodeRequestPurchaseProps(103-141)decodePurchaseInput(143-145)fallbackPurchaseInput(147-160)decodeReceiptValidationProps(162-164)
ios/Classes/FlutterIapHelper.swift (1)
ios/Classes/FlutterIapLog.swift (2)
result(41-43)payload(37-39)
ios/Classes/FlutterIapLog.swift (1)
ios/Classes/FlutterIapHelper.swift (1)
sanitizeValue(21-48)
🔇 Additional comments (19)
example/lib/src/screens/purchase_flow_screen.dart (1)
366-366: Good: switched to result.inAppProducts().This aligns with the helper extension guidance and simplifies unwrapping.
lib/helpers.dart (2)
593-606: Solid: robust normalization for subscriptionInfoIOS.This reduces parse flakiness from Map<Object?,Object?> inputs.
618-650: Good utility: normalizeDynamicMap/Value.Works recursively and preserves string keys.
example/lib/src/screens/available_purchases_screen.dart (2)
538-590: Nice iOS-only History section and states.Clear UX with load/reload, progress, and error messaging.
169-222: No action needed — SDK supports unawaited.
pubspec.yaml requires Dart SDK ">=3.0.0 <4.0.0", so using unawaited from dart:async is safe.lib/flutter_inapp_purchase.dart (6)
239-256: Good: centralize iOS payload construction.Validates required fields and reduces branching.
411-471: Unified getAvailablePurchases flow with normalized options is solid.Filters by resolvable identifiers and avoids deprecated histories.
1078-1083: Good: include structured payload when finishing iOS transactions.Sends transactionId, purchase JSON, and consumable flag.
1259-1291: fetchProducts: correct use of normalizeDynamicMap with defensive logging.Prevents Map<Object?,Object?> pitfalls.
1000-1010: Confirmed: Android plugin exposes both handlers (acknowledgePurchase is a deprecated alias of acknowledgePurchaseAndroid).
Handlers located in android/src/main/kotlin/dev/hyo/flutterinapppurchase/AndroidInappPurchasePlugin.kt — acknowledgePurchaseAndroid (lines ~529–544); acknowledgePurchase (lines ~734–750) logs deprecation and forwards to acknowledgePurchaseAndroid.
479-529: iOS already preserves and forwards these purchase keys — no Swift change required.decodeRequestPurchaseProps copies the incoming args into iosPayload, uses sanitizeDictionary (keeps non‑nil entries), nests that under "requestPurchase"/"requestSubscription" and calls OpenIapSerialization.decode; andDangerouslyFinishTransactionAutomatically, withOffer, appAccountToken and quantity are preserved and passed into OpenIAP.
Locations: ios/Classes/FlutterIapHelper.swift (decodeRequestPurchaseProps), ios/Classes/FlutterInappPurchasePlugin.swift (requestPurchase).ios/Classes/FlutterIapLog.swift (1)
14-21: LGTM: sensible defaults and override pointsDebug‑only default enablement and a pluggable handler look good.
Also applies to: 24-35
ios/Classes/FlutterInappPurchasePlugin.swift (4)
260-283: fetchProducts flow is clean and OpenIAP‑compliantDecoding via
FlutterIapHelper.decodeProductRequest, handlingPurchaseError, and returning sanitized arrays looks solid.
363-384: finishTransaction fallback is robustGraceful fallback to a transactionId‑based
PurchaseInputcovers edge cases. Good logging and error surfacing.Also applies to: 386-399
200-237: Confirm Dart-side handlers match iOS channel namesiOS emits "purchase-updated", "purchase-error", and "iap-promoted-product" (ios/Classes/FlutterInappPurchasePlugin.swift). Example code uses purchaseUpdatedListener / purchaseErrorListener, but I couldn't find literal handlers for those method-channel event names in lib/ — verify the Dart plugin maps incoming method calls with those exact names to the listeners, or align the names on one side.
49-53:canMakePaymentsmust not returntrueunconditionally — delegate to OpenIAP or fall back to StoreKit.Update ios/Classes/FlutterInappPurchasePlugin.swift (case "canMakePayments") to call OpenIapModule.shared.canMakePayments() if that API is available; otherwise return SKPaymentQueue.canMakePayments() gated under #if canImport(StoreKit).
example/lib/src/screens/subscription_flow_screen.dart (2)
295-304: Good: OpenIAP fetch with union unwrappingUsing
ProductRequest+result.subscriptionProducts()aligns with guidelines.Also applies to: 317-318
685-717: Type argument specified — niceUsing
showModalBottomSheet<void>matches the guideline for generic calls.ios/Classes/FlutterIapHelper.swift (1)
74-90: Decoders look solid and aligned with OpenIAPHandling
skus, indexed keys, and normalization torequestPurchase/requestSubscriptionis clean.Also applies to: 103-141
There was a problem hiding this comment.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
example/lib/src/screens/subscription_flow_screen.dart (2)
1082-1086: Fix RangeError risk from substring on purchaseTokensubstring assumes length >= N and will throw when shorter. Use a safe preview helper instead.
Apply this diff:
- label: Text( - 'Token: ${_currentSubscription!.purchaseToken?.substring(0, 10)}...', - style: const TextStyle(fontSize: 10), - ), + label: Text( + 'Token: ${_previewToken(_currentSubscription!.purchaseToken, max: 10)}', + style: const TextStyle(fontSize: 10), + ),- debugPrint('Using test token: ${testToken.substring(0, 20)}...'); + debugPrint('Using test token: ${_previewToken(testToken, max: 20)}');Add this helper in the State class (e.g., below _formatReadableDate):
String _previewToken(String? token, {int max = 20}) { if (token == null || token.isEmpty) return 'null'; final end = token.length < max ? token.length : max; final preview = token.substring(0, end); return token.length > end ? '$preview...' : preview; }Also applies to: 623-623
101-103: Redact purchase tokens in logs/UIAvoid printing full tokens; they’re sensitive. Use the same preview helper consistently.
Apply this diff:
- debugPrint(' Purchase token: ${purchase.purchaseToken}'); + debugPrint(' Purchase token: ${_previewToken(purchase.purchaseToken, max: 20)}');- debugPrint( - ' Purchase token: ${_currentSubscription!.purchaseToken}', - ); + debugPrint(' Purchase token: ${_previewToken(_currentSubscription!.purchaseToken, max: 20)}');- debugPrint( - 'Restored: ${purchase.productId}, Token: ${purchase.purchaseToken}', - ); + debugPrint('Restored: ${purchase.productId}, Token: ${_previewToken(purchase.purchaseToken, max: 20)}');Also applies to: 439-441, 672-674
🧹 Nitpick comments (2)
example/lib/src/screens/subscription_flow_screen.dart (1)
618-619: Fix misleading “Empty Token” text; code sends a non-empty tokenUpdate copy to reflect behavior (non-empty test token). Keeps developer UX clear.
Apply this diff:
- debugPrint('Testing proration mode with EMPTY string purchaseToken'); + debugPrint('Testing proration mode with a non-empty test token'); - child: const Tooltip( - message: 'Test proration with empty token', + child: const Tooltip( + message: 'Test proration with a non-empty test token', child: Text('Empty Token', style: TextStyle(fontSize: 11)), ),Also applies to: 1046-1049
ios/Classes/FlutterIapHelper.swift (1)
51-74: Optional: tighten/trim sanitizer pathsYou can streamline sanitizeValue by casting dictionaries once to [String: Any?] and handling arrays via a single branch; behavior stays identical, code shrinks.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
.vscode/launch.json(1 hunks)example/lib/src/screens/subscription_flow_screen.dart(7 hunks)ios/Classes/FlutterIapHelper.swift(1 hunks)ios/Classes/FlutterIapLog.swift(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- .vscode/launch.json
🧰 Additional context used
📓 Path-based instructions (1)
**/*.dart
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.dart: Use OpenIAP ProductRequest with iap.fetchProducts and handle the FetchProductsResult union
Use helper extensions from flutter_inapp_purchase.dart (inAppProducts(), subscriptionProducts(), allProducts()) to unwrap FetchProductsResult safely
Use getAvailablePurchases with PurchaseOptions for OpenIAP compliance; prefer defaults unless specific iOS options are needed
Do not use getPurchaseHistories (deprecated); replace with getAvailablePurchases and options as needed
When calling generic functions (e.g., showModalBottomSheet), specify explicit type arguments (e.g., showModalBottomSheet)
iOS-related types/functions use IOS suffix (e.g., PurchaseIOS, SubscriptionOfferIOS)
When iOS is not the final suffix, use Ios (e.g., IosManager, IosHelper)
For field names with iOS in the middle, place Id before IOS (e.g., subscriptionGroupIdIOS, webOrderLineItemIdIOS)
Android-related types/functions use Android suffix (e.g., PurchaseAndroid, SubscriptionOfferAndroid)
When IAP is not the final suffix, use Iap (e.g., IapPurchase, not IAPPurchase)
Use Id (not ID) in identifiers (e.g., productId, transactionId, offerId); when combined with platform suffixes, use Id before the suffix (e.g., subscriptionGroupIdIOS, obfuscatedAccountIdAndroid)
Exception: iOS-only fields that end with ID use ID (e.g., transactionID, webOrderLineItemID)
Functions that depend on event results must use the request prefix (e.g., requestPurchase, requestPurchaseWithBuilder)
Follow OpenIAP terminology for API method naming; avoid generic prefixes like get or find
Files:
example/lib/src/screens/subscription_flow_screen.dart
🧠 Learnings (4)
📓 Common learnings
Learnt from: hyochan
PR: hyochan/flutter_inapp_purchase#531
File: docs/versioned_docs/version-6.3/faq.md:164-172
Timestamp: 2025-08-21T02:01:17.634Z
Learning: When reviewing PRs for flutter_inapp_purchase, focus on the current version being developed rather than versioned documentation files, as those are historical snapshots.
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : iOS-related types/functions use IOS suffix (e.g., PurchaseIOS, SubscriptionOfferIOS)
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : Use getAvailablePurchases with PurchaseOptions for OpenIAP compliance; prefer defaults unless specific iOS options are needed
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : Android-related types/functions use Android suffix (e.g., PurchaseAndroid, SubscriptionOfferAndroid)
📚 Learning: 2025-09-20T07:27:44.884Z
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : Use helper extensions from flutter_inapp_purchase.dart (inAppProducts(), subscriptionProducts(), allProducts()) to unwrap FetchProductsResult safely
Applied to files:
example/lib/src/screens/subscription_flow_screen.dartios/Classes/FlutterIapHelper.swift
📚 Learning: 2025-09-20T07:27:44.884Z
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : Use OpenIAP ProductRequest with iap.fetchProducts and handle the FetchProductsResult union
Applied to files:
example/lib/src/screens/subscription_flow_screen.dartios/Classes/FlutterIapHelper.swift
📚 Learning: 2025-09-20T07:27:44.884Z
Learnt from: CR
PR: hyochan/flutter_inapp_purchase#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-20T07:27:44.884Z
Learning: Applies to **/*.dart : Use getAvailablePurchases with PurchaseOptions for OpenIAP compliance; prefer defaults unless specific iOS options are needed
Applied to files:
ios/Classes/FlutterIapHelper.swift
🧬 Code graph analysis (2)
ios/Classes/FlutterIapHelper.swift (1)
ios/Classes/FlutterIapLog.swift (2)
result(41-43)payload(37-39)
ios/Classes/FlutterIapLog.swift (2)
ios/Classes/FlutterInappPurchasePlugin.swift (2)
iOS(404-419)iOS(421-436)ios/Classes/FlutterIapHelper.swift (1)
sanitizeValue(51-74)
🔇 Additional comments (7)
example/lib/src/screens/subscription_flow_screen.dart (3)
303-303: Good: using OpenIAP FetchProductsResult helpers, PurchaseOptions, and typed modal
- result.subscriptionProducts() used correctly to unwrap the union.
- getAvailablePurchases with PurchaseOptions aligns with guidance.
- showModalBottomSheet uses explicit type arg (nice).
- List.of(..., growable: false) avoids an extra copy later.
Also applies to: 344-347, 685-716, 317-318
200-207: Consider unblocking UI on Pending stateCurrently only _purchaseResult is set; _isProcessing remains true, which may freeze actions if the state stays Pending. Confirm intended UX.
835-841: Timestamps are milliseconds — no normalization requiredPer the flutter_inapp_purchase changelog and API, Purchase.transactionDate and ActiveSubscription.transactionDate/expirationDateIOS are milliseconds since the Unix epoch; DateTime.fromMillisecondsSinceEpoch is correct — drop the proposed sec→ms diff and keep matchTolerance as-is. Applies to example/lib/src/screens/subscription_flow_screen.dart (lines 835–841 and 389–392).
ios/Classes/FlutterIapHelper.swift (2)
20-25: Sanitization looks solid and comprehensiveCovers tokens/receipts/JWS/signature, recurses through nested maps/arrays, and stringifies identifier numerics—good balance of safety and utility.
Also applies to: 27-45, 51-74
118-127: Nice normalization for request decodingThe normalized shapes before delegating to OpenIapSerialization make the API tolerant to multiple payload forms and improve log traceability with sanitized payloads.
Also applies to: 129-167, 173-186
ios/Classes/FlutterIapLog.swift (2)
57-76: Runtime-guarded os.Logger usage — LGTMAvailability check prevents issues on < iOS 14; fallback to NSLog is correct.
79-97: Good integration with sanitizer for payload/result loggingstringify() leverages FlutterIapHelper.sanitizeValue before serialization—prevents leaking sensitive fields.
Refactors the iOS plugin, improves purchase flow, and updates the example app.
Summary by CodeRabbit
New Features
Improvements
Bug Fixes
Tests
Chores