Skip to content

Commit 103d7ff

Browse files
authored
feat(analytics, iOS): add support for logTransaction (#17995)
* feat(analytics, iOS): add support for `logTransaction` * chore: add test for `logTransaction` method * chore: update logTransaction to require iOS 15+ or macOS 12+ * chore: update logTransaction to require iOS 15+ or macOS 12+ * chore: fix formatting * chore: add availability check for fetchTransaction method on iOS 15+ and macOS 12+ * chore: enhance logTransaction method with platform-specific availability checks and improved transaction retrieval * chore: update test skip condition to include macOS platform * chore: update logTransaction method to support macOS in addition to iOS * chore: enhance logTransaction tests with error handling for invalid transactionId and missing transactions * chore: update analytics example app to include manual test for `logTransaction` * chore: implement logTransaction method with unimplemented error for Android * chore: update skip conditions for Firebase Analytics E2E tests to include web platform
1 parent adef187 commit 103d7ff

16 files changed

Lines changed: 445 additions & 0 deletions

File tree

packages/firebase_analytics/firebase_analytics/android/src/main/kotlin/io/flutter/plugins/firebase/analytics/FlutterFirebaseAnalyticsPlugin.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,4 +443,16 @@ class FlutterFirebaseAnalyticsPlugin : FlutterFirebasePlugin,
443443
)
444444
)
445445
}
446+
447+
override fun logTransaction(transactionId: String, callback: (Result<Unit>) -> Unit) {
448+
callback(
449+
Result.failure(
450+
FlutterError(
451+
"unimplemented",
452+
"logTransaction is only available on iOS.",
453+
null
454+
)
455+
)
456+
)
457+
}
446458
}

packages/firebase_analytics/firebase_analytics/android/src/main/kotlin/io/flutter/plugins/firebase/analytics/GeneratedAndroidFirebaseAnalytics.g.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ interface FirebaseAnalyticsHostApi {
147147
fun getAppInstanceId(callback: (Result<String?>) -> Unit)
148148
fun getSessionId(callback: (Result<Long?>) -> Unit)
149149
fun initiateOnDeviceConversionMeasurement(arguments: Map<String, String?>, callback: (Result<Unit>) -> Unit)
150+
fun logTransaction(transactionId: String, callback: (Result<Unit>) -> Unit)
150151

151152
companion object {
152153
/** The codec used by FirebaseAnalyticsHostApi. */
@@ -363,6 +364,25 @@ interface FirebaseAnalyticsHostApi {
363364
channel.setMessageHandler(null)
364365
}
365366
}
367+
run {
368+
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.firebase_analytics_platform_interface.FirebaseAnalyticsHostApi.logTransaction$separatedMessageChannelSuffix", codec)
369+
if (api != null) {
370+
channel.setMessageHandler { message, reply ->
371+
val args = message as List<Any?>
372+
val transactionIdArg = args[0] as String
373+
api.logTransaction(transactionIdArg) { result: Result<Unit> ->
374+
val error = result.exceptionOrNull()
375+
if (error != null) {
376+
reply.reply(GeneratedAndroidFirebaseAnalyticsPigeonUtils.wrapError(error))
377+
} else {
378+
reply.reply(GeneratedAndroidFirebaseAnalyticsPigeonUtils.wrapResult(null))
379+
}
380+
}
381+
}
382+
} else {
383+
channel.setMessageHandler(null)
384+
}
385+
}
366386
}
367387
}
368388
}

packages/firebase_analytics/firebase_analytics/example/lib/main.dart

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import 'package:firebase_analytics/firebase_analytics.dart';
88
import 'package:firebase_core/firebase_core.dart';
99
import 'package:flutter/foundation.dart';
1010
import 'package:flutter/material.dart';
11+
import 'package:in_app_purchase/in_app_purchase.dart';
1112

1213
import 'firebase_options.dart';
1314
import 'tabs_page.dart';
@@ -62,6 +63,44 @@ class MyHomePage extends StatefulWidget {
6263

6364
class _MyHomePageState extends State<MyHomePage> {
6465
String _message = '';
66+
StreamSubscription<List<PurchaseDetails>>? _purchaseSubscription;
67+
68+
static const String _testProductId = '123456';
69+
70+
@override
71+
void initState() {
72+
super.initState();
73+
_purchaseSubscription =
74+
InAppPurchase.instance.purchaseStream.listen(_onPurchaseUpdate);
75+
}
76+
77+
@override
78+
void dispose() {
79+
_purchaseSubscription?.cancel();
80+
super.dispose();
81+
}
82+
83+
void _onPurchaseUpdate(List<PurchaseDetails> purchases) {
84+
for (final purchase in purchases) {
85+
if (purchase.pendingCompletePurchase) {
86+
InAppPurchase.instance.completePurchase(purchase);
87+
}
88+
if (purchase.status == PurchaseStatus.purchased ||
89+
purchase.status == PurchaseStatus.restored) {
90+
final transactionId = purchase.purchaseID;
91+
print('transactionId: $transactionId');
92+
if (transactionId != null) {
93+
widget.analytics.logTransaction(transactionId).then((_) {
94+
setMessage('logTransaction succeeded with ID: $transactionId');
95+
}).catchError((e) {
96+
setMessage('logTransaction failed: $e');
97+
});
98+
}
99+
} else if (purchase.status == PurchaseStatus.error) {
100+
setMessage('Purchase error: ${purchase.error?.message}');
101+
}
102+
}
103+
}
65104

66105
void setMessage(String message) {
67106
setState(() {
@@ -158,6 +197,40 @@ class _MyHomePageState extends State<MyHomePage> {
158197
setMessage('initiateOnDeviceConversionMeasurement succeeded');
159198
}
160199

200+
Future<void> _testLogTransaction() async {
201+
if (kIsWeb ||
202+
(defaultTargetPlatform != TargetPlatform.iOS &&
203+
defaultTargetPlatform != TargetPlatform.macOS)) {
204+
setMessage('logTransaction() is only supported on iOS and macOS');
205+
return;
206+
}
207+
208+
setMessage('Loading product $_testProductId...');
209+
210+
final response =
211+
await InAppPurchase.instance.queryProductDetails({_testProductId});
212+
213+
if (response.error != null) {
214+
setMessage('Failed to load product: ${response.error!.message}');
215+
return;
216+
}
217+
218+
if (response.productDetails.isEmpty) {
219+
setMessage(
220+
'Product "$_testProductId" not found. '
221+
'Make sure your StoreKit config file is set up correctly.',
222+
);
223+
return;
224+
}
225+
226+
final product = response.productDetails.first;
227+
setMessage('Initiating purchase for "${product.id}"...');
228+
229+
await InAppPurchase.instance.buyNonConsumable(
230+
purchaseParam: PurchaseParam(productDetails: product),
231+
);
232+
}
233+
161234
AnalyticsEventItem itemCreator() {
162235
return AnalyticsEventItem(
163236
affiliation: 'affil',
@@ -365,6 +438,13 @@ class _MyHomePageState extends State<MyHomePage> {
365438
onPressed: _testInitiateOnDeviceConversionMeasurement,
366439
child: const Text('Test initiateOnDeviceConversionMeasurement'),
367440
),
441+
if (!kIsWeb &&
442+
(defaultTargetPlatform == TargetPlatform.iOS ||
443+
defaultTargetPlatform == TargetPlatform.macOS))
444+
MaterialButton(
445+
onPressed: _testLogTransaction,
446+
child: const Text('Test logTransaction (product: 123456)'),
447+
),
368448
Text(
369449
_message,
370450
style: const TextStyle(color: Color.fromARGB(255, 0, 155, 0)),

packages/firebase_analytics/firebase_analytics/example/pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ dependencies:
1010
firebase_core: ^4.5.0
1111
flutter:
1212
sdk: flutter
13+
in_app_purchase: ^3.2.3
1314

1415
flutter:
1516
uses-material-design: true

packages/firebase_analytics/firebase_analytics/ios/firebase_analytics/Sources/firebase_analytics/FirebaseAnalyticsMessages.g.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ protocol FirebaseAnalyticsHostApi {
220220
func getSessionId(completion: @escaping (Result<Int64?, Error>) -> Void)
221221
func initiateOnDeviceConversionMeasurement(arguments: [String: String?],
222222
completion: @escaping (Result<Void, Error>) -> Void)
223+
func logTransaction(transactionId: String, completion: @escaping (Result<Void, Error>) -> Void)
223224
}
224225

225226
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -459,5 +460,26 @@ class FirebaseAnalyticsHostApiSetup {
459460
} else {
460461
initiateOnDeviceConversionMeasurementChannel.setMessageHandler(nil)
461462
}
463+
let logTransactionChannel = FlutterBasicMessageChannel(
464+
name: "dev.flutter.pigeon.firebase_analytics_platform_interface.FirebaseAnalyticsHostApi.logTransaction\(channelSuffix)",
465+
binaryMessenger: binaryMessenger,
466+
codec: codec
467+
)
468+
if let api {
469+
logTransactionChannel.setMessageHandler { message, reply in
470+
let args = message as! [Any?]
471+
let transactionIdArg = args[0] as! String
472+
api.logTransaction(transactionId: transactionIdArg) { result in
473+
switch result {
474+
case .success:
475+
reply(wrapResult(nil))
476+
case let .failure(error):
477+
reply(wrapError(error))
478+
}
479+
}
480+
}
481+
} else {
482+
logTransactionChannel.setMessageHandler(nil)
483+
}
462484
}
463485
}

packages/firebase_analytics/firebase_analytics/ios/firebase_analytics/Sources/firebase_analytics/FirebaseAnalyticsPlugin.swift

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import firebase_core_shared
1515
#endif
1616
import FirebaseAnalytics
17+
import StoreKit
1718

1819
let kFLTFirebaseAnalyticsName = "name"
1920
let kFLTFirebaseAnalyticsValue = "value"
@@ -28,6 +29,8 @@ let kFLTFirebaseAnalyticsUserId = "userId"
2829

2930
let FLTFirebaseAnalyticsChannelName = "plugins.flutter.io/firebase_analytics"
3031

32+
extension FlutterError: Error {}
33+
3134
public class FirebaseAnalyticsPlugin: NSObject, FLTFirebasePluginProtocol, FlutterPlugin,
3235
FirebaseAnalyticsHostApi {
3336
public static func register(with registrar: any FlutterPluginRegistrar) {
@@ -142,6 +145,79 @@ public class FirebaseAnalyticsPlugin: NSObject, FLTFirebasePluginProtocol, Flutt
142145
completion(.success(()))
143146
}
144147

148+
func logTransaction(transactionId: String,
149+
completion: @escaping (Result<Void, any Error>) -> Void) {
150+
#if os(macOS)
151+
if #available(macOS 12.0, *) {
152+
logTransactionWithStoreKit(transactionId: transactionId, completion: completion)
153+
} else {
154+
completion(.failure(FlutterError(
155+
code: "firebase_analytics",
156+
message: "logTransaction() is only supported on macOS 12.0 or newer",
157+
details: nil
158+
)))
159+
}
160+
#else
161+
if #available(iOS 15.0, *) {
162+
logTransactionWithStoreKit(transactionId: transactionId, completion: completion)
163+
} else {
164+
completion(.failure(FlutterError(
165+
code: "firebase_analytics",
166+
message: "logTransaction() is only supported on iOS 15.0 or newer",
167+
details: nil
168+
)))
169+
}
170+
#endif
171+
}
172+
173+
#if os(macOS)
174+
@available(macOS 12.0, *)
175+
#else
176+
@available(iOS 15.0, *)
177+
#endif
178+
private func logTransactionWithStoreKit(transactionId: String,
179+
completion: @escaping (Result<Void, any Error>) -> Void) {
180+
Task {
181+
do {
182+
guard let id = UInt64(transactionId) else {
183+
completion(.failure(FlutterError(
184+
code: "firebase_analytics",
185+
message: "Invalid transactionId",
186+
details: nil
187+
)))
188+
return
189+
}
190+
191+
var foundTransaction: Transaction?
192+
for await result in Transaction.all {
193+
switch result {
194+
case let .verified(transaction):
195+
if transaction.id == id {
196+
foundTransaction = transaction
197+
break
198+
}
199+
case .unverified:
200+
continue
201+
}
202+
}
203+
204+
guard let transaction = foundTransaction else {
205+
completion(.failure(FlutterError(
206+
code: "firebase_analytics",
207+
message: "Transaction not found",
208+
details: nil
209+
)))
210+
return
211+
}
212+
213+
Analytics.logTransaction(transaction)
214+
completion(.success(()))
215+
} catch {
216+
completion(.failure(error))
217+
}
218+
}
219+
}
220+
145221
private func hexStringToData(_ hexString: String) -> Data? {
146222
let length = hexString.count
147223
guard length % 2 == 0 else { return nil }
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"appPolicies" : {
3+
"eula" : "",
4+
"policies" : [
5+
{
6+
"locale" : "en_US",
7+
"policyText" : "",
8+
"policyURL" : ""
9+
}
10+
]
11+
},
12+
"identifier" : "D50F15B4",
13+
"nonRenewingSubscriptions" : [
14+
15+
],
16+
"products" : [
17+
{
18+
"displayPrice" : "0.99",
19+
"familyShareable" : false,
20+
"internalID" : "FAAD0643",
21+
"localizations" : [
22+
{
23+
"description" : "",
24+
"displayName" : "",
25+
"locale" : "en_US"
26+
}
27+
],
28+
"productID" : "123456",
29+
"referenceName" : "premium_upgrade",
30+
"type" : "NonConsumable"
31+
}
32+
],
33+
"settings" : {
34+
"_askToBuyEnabled" : false,
35+
"_billingGracePeriodEnabled" : false,
36+
"_billingIssuesEnabled" : false,
37+
"_disableDialogs" : false,
38+
"_failTransactionsEnabled" : false,
39+
"_locale" : "en_US",
40+
"_renewalBillingIssuesEnabled" : false,
41+
"_storefront" : "USA",
42+
"_storeKitErrors" : [
43+
44+
],
45+
"_timeRate" : 0
46+
},
47+
"subscriptionGroups" : [
48+
49+
],
50+
"version" : {
51+
"major" : 4,
52+
"minor" : 0
53+
}
54+
}

packages/firebase_analytics/firebase_analytics/lib/src/firebase_analytics.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1242,6 +1242,23 @@ class FirebaseAnalytics extends FirebasePluginPlatform {
12421242
);
12431243
}
12441244

1245+
/// Logs verified in-app purchase events in Google Analytics for Firebase
1246+
/// after a purchase is successful.
1247+
///
1248+
/// Only available on iOS.
1249+
///
1250+
/// You can obtain the [transactionId] from the
1251+
/// [in_app_purchase](https://pub.dev/packages/in_app_purchase) package.
1252+
Future<void> logTransaction(String transactionId) async {
1253+
if (defaultTargetPlatform != TargetPlatform.iOS &&
1254+
defaultTargetPlatform != TargetPlatform.macOS) {
1255+
throw UnimplementedError(
1256+
'logTransaction() is only supported on iOS and macOS.',
1257+
);
1258+
}
1259+
return _delegate.logTransaction(transactionId: transactionId);
1260+
}
1261+
12451262
/// Sets the duration of inactivity that terminates the current session.
12461263
///
12471264
/// The default value is 1800000 milliseconds (30 minutes).

0 commit comments

Comments
 (0)