diff --git a/packages/firebase_analytics/firebase_analytics/android/src/main/kotlin/io/flutter/plugins/firebase/analytics/GeneratedAndroidFirebaseAnalytics.g.kt b/packages/firebase_analytics/firebase_analytics/android/src/main/kotlin/io/flutter/plugins/firebase/analytics/GeneratedAndroidFirebaseAnalytics.g.kt index 0a8351824439..fdfc88dc8f42 100644 --- a/packages/firebase_analytics/firebase_analytics/android/src/main/kotlin/io/flutter/plugins/firebase/analytics/GeneratedAndroidFirebaseAnalytics.g.kt +++ b/packages/firebase_analytics/firebase_analytics/android/src/main/kotlin/io/flutter/plugins/firebase/analytics/GeneratedAndroidFirebaseAnalytics.g.kt @@ -147,6 +147,7 @@ interface FirebaseAnalyticsHostApi { fun getAppInstanceId(callback: (Result) -> Unit) fun getSessionId(callback: (Result) -> Unit) fun initiateOnDeviceConversionMeasurement(arguments: Map, callback: (Result) -> Unit) + fun logTransaction(transactionId: String, callback: (Result) -> Unit) companion object { /** The codec used by FirebaseAnalyticsHostApi. */ @@ -363,6 +364,25 @@ interface FirebaseAnalyticsHostApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.firebase_analytics_platform_interface.FirebaseAnalyticsHostApi.logTransaction$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val transactionIdArg = args[0] as String + api.logTransaction(transactionIdArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeneratedAndroidFirebaseAnalyticsPigeonUtils.wrapError(error)) + } else { + reply.reply(GeneratedAndroidFirebaseAnalyticsPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/packages/firebase_analytics/firebase_analytics/ios/firebase_analytics/Sources/firebase_analytics/FirebaseAnalyticsMessages.g.swift b/packages/firebase_analytics/firebase_analytics/ios/firebase_analytics/Sources/firebase_analytics/FirebaseAnalyticsMessages.g.swift index f8124ea1dc43..d8b175ba6e20 100644 --- a/packages/firebase_analytics/firebase_analytics/ios/firebase_analytics/Sources/firebase_analytics/FirebaseAnalyticsMessages.g.swift +++ b/packages/firebase_analytics/firebase_analytics/ios/firebase_analytics/Sources/firebase_analytics/FirebaseAnalyticsMessages.g.swift @@ -220,6 +220,7 @@ protocol FirebaseAnalyticsHostApi { func getSessionId(completion: @escaping (Result) -> Void) func initiateOnDeviceConversionMeasurement(arguments: [String: String?], completion: @escaping (Result) -> Void) + func logTransaction(transactionId: String, completion: @escaping (Result) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -459,5 +460,26 @@ class FirebaseAnalyticsHostApiSetup { } else { initiateOnDeviceConversionMeasurementChannel.setMessageHandler(nil) } + let logTransactionChannel = FlutterBasicMessageChannel( + name: "dev.flutter.pigeon.firebase_analytics_platform_interface.FirebaseAnalyticsHostApi.logTransaction\(channelSuffix)", + binaryMessenger: binaryMessenger, + codec: codec + ) + if let api { + logTransactionChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let transactionIdArg = args[0] as! String + api.logTransaction(transactionId: transactionIdArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case let .failure(error): + reply(wrapError(error)) + } + } + } + } else { + logTransactionChannel.setMessageHandler(nil) + } } } diff --git a/packages/firebase_analytics/firebase_analytics/ios/firebase_analytics/Sources/firebase_analytics/FirebaseAnalyticsPlugin.swift b/packages/firebase_analytics/firebase_analytics/ios/firebase_analytics/Sources/firebase_analytics/FirebaseAnalyticsPlugin.swift index fcd6bda21819..0a5ee2378f74 100644 --- a/packages/firebase_analytics/firebase_analytics/ios/firebase_analytics/Sources/firebase_analytics/FirebaseAnalyticsPlugin.swift +++ b/packages/firebase_analytics/firebase_analytics/ios/firebase_analytics/Sources/firebase_analytics/FirebaseAnalyticsPlugin.swift @@ -14,6 +14,7 @@ import firebase_core_shared #endif import FirebaseAnalytics +import StoreKit let kFLTFirebaseAnalyticsName = "name" let kFLTFirebaseAnalyticsValue = "value" @@ -28,6 +29,8 @@ let kFLTFirebaseAnalyticsUserId = "userId" let FLTFirebaseAnalyticsChannelName = "plugins.flutter.io/firebase_analytics" +extension FlutterError: Error {} + public class FirebaseAnalyticsPlugin: NSObject, FLTFirebasePluginProtocol, FlutterPlugin, FirebaseAnalyticsHostApi { public static func register(with registrar: any FlutterPluginRegistrar) { @@ -142,6 +145,79 @@ public class FirebaseAnalyticsPlugin: NSObject, FLTFirebasePluginProtocol, Flutt completion(.success(())) } + func logTransaction(transactionId: String, + completion: @escaping (Result) -> Void) { + #if os(macOS) + if #available(macOS 12.0, *) { + logTransactionWithStoreKit(transactionId: transactionId, completion: completion) + } else { + completion(.failure(FlutterError( + code: "firebase_analytics", + message: "logTransaction() is only supported on macOS 12.0 or newer", + details: nil + ))) + } + #else + if #available(iOS 15.0, *) { + logTransactionWithStoreKit(transactionId: transactionId, completion: completion) + } else { + completion(.failure(FlutterError( + code: "firebase_analytics", + message: "logTransaction() is only supported on iOS 15.0 or newer", + details: nil + ))) + } + #endif + } + + #if os(macOS) + @available(macOS 12.0, *) + #else + @available(iOS 15.0, *) + #endif + private func logTransactionWithStoreKit(transactionId: String, + completion: @escaping (Result) -> Void) { + Task { + do { + guard let id = UInt64(transactionId) else { + completion(.failure(FlutterError( + code: "firebase_analytics", + message: "Invalid transactionId", + details: nil + ))) + return + } + + var foundTransaction: Transaction? + for await result in Transaction.all { + switch result { + case let .verified(transaction): + if transaction.id == id { + foundTransaction = transaction + break + } + case .unverified: + continue + } + } + + guard let transaction = foundTransaction else { + completion(.failure(FlutterError( + code: "firebase_analytics", + message: "Transaction not found", + details: nil + ))) + return + } + + Analytics.logTransaction(transaction) + completion(.success(())) + } catch { + completion(.failure(error)) + } + } + } + public func didReinitializeFirebaseCore(_ completion: @escaping () -> Void) { completion() } diff --git a/packages/firebase_analytics/firebase_analytics/lib/src/firebase_analytics.dart b/packages/firebase_analytics/firebase_analytics/lib/src/firebase_analytics.dart index 237a972b19c7..38c5a9077977 100755 --- a/packages/firebase_analytics/firebase_analytics/lib/src/firebase_analytics.dart +++ b/packages/firebase_analytics/firebase_analytics/lib/src/firebase_analytics.dart @@ -1242,6 +1242,23 @@ class FirebaseAnalytics extends FirebasePluginPlatform { ); } + /// Logs verified in-app purchase events in Google Analytics for Firebase + /// after a purchase is successful. + /// + /// Only available on iOS. + /// + /// You can obtain the [transactionId] from the + /// [in_app_purchase](https://pub.dev/packages/in_app_purchase) package. + Future logTransaction(String transactionId) async { + if (defaultTargetPlatform != TargetPlatform.iOS && + defaultTargetPlatform != TargetPlatform.macOS) { + throw UnimplementedError( + 'logTransaction() is only supported on iOS and macOS.', + ); + } + return _delegate.logTransaction(transactionId: transactionId); + } + /// Sets the duration of inactivity that terminates the current session. /// /// The default value is 1800000 milliseconds (30 minutes). diff --git a/packages/firebase_analytics/firebase_analytics/windows/messages.g.cpp b/packages/firebase_analytics/firebase_analytics/windows/messages.g.cpp index 2402f854a82a..14a92edf1452 100644 --- a/packages/firebase_analytics/firebase_analytics/windows/messages.g.cpp +++ b/packages/firebase_analytics/firebase_analytics/windows/messages.g.cpp @@ -525,6 +525,45 @@ void FirebaseAnalyticsHostApi::SetUp( channel.SetMessageHandler(nullptr); } } + { + BasicMessageChannel<> channel( + binary_messenger, + "dev.flutter.pigeon.firebase_analytics_platform_interface." + "FirebaseAnalyticsHostApi.logTransaction" + + prepended_suffix, + &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler( + [api](const EncodableValue& message, + const flutter::MessageReply& reply) { + try { + const auto& args = std::get(message); + const auto& encodable_transaction_id_arg = args.at(0); + if (encodable_transaction_id_arg.IsNull()) { + reply(WrapError("transaction_id_arg unexpectedly null.")); + return; + } + const auto& transaction_id_arg = + std::get(encodable_transaction_id_arg); + api->LogTransaction( + transaction_id_arg, + [reply](std::optional&& output) { + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + }); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } } EncodableValue FirebaseAnalyticsHostApi::WrapError( diff --git a/packages/firebase_analytics/firebase_analytics/windows/messages.g.h b/packages/firebase_analytics/firebase_analytics/windows/messages.g.h index 69c859c6e515..b755d9ef6045 100644 --- a/packages/firebase_analytics/firebase_analytics/windows/messages.g.h +++ b/packages/firebase_analytics/firebase_analytics/windows/messages.g.h @@ -138,6 +138,9 @@ class FirebaseAnalyticsHostApi { virtual void InitiateOnDeviceConversionMeasurement( const flutter::EncodableMap& arguments, std::function reply)> result) = 0; + virtual void LogTransaction( + const std::string& transaction_id, + std::function reply)> result) = 0; // The codec used by FirebaseAnalyticsHostApi. static const flutter::StandardMessageCodec& GetCodec(); diff --git a/packages/firebase_analytics/firebase_analytics_platform_interface/lib/src/method_channel/method_channel_firebase_analytics.dart b/packages/firebase_analytics/firebase_analytics_platform_interface/lib/src/method_channel/method_channel_firebase_analytics.dart index 0ad8119fe21c..e3c5d41eaa42 100644 --- a/packages/firebase_analytics/firebase_analytics_platform_interface/lib/src/method_channel/method_channel_firebase_analytics.dart +++ b/packages/firebase_analytics/firebase_analytics_platform_interface/lib/src/method_channel/method_channel_firebase_analytics.dart @@ -195,4 +195,15 @@ class MethodChannelFirebaseAnalytics extends FirebaseAnalyticsPlatform { convertPlatformException(e, s); } } + + @override + Future logTransaction({ + required String transactionId, + }) { + try { + return _api.logTransaction(transactionId); + } catch (e, s) { + convertPlatformException(e, s); + } + } } diff --git a/packages/firebase_analytics/firebase_analytics_platform_interface/lib/src/pigeon/messages.pigeon.dart b/packages/firebase_analytics/firebase_analytics_platform_interface/lib/src/pigeon/messages.pigeon.dart index 6b74cc466a3d..003dd773639e 100644 --- a/packages/firebase_analytics/firebase_analytics_platform_interface/lib/src/pigeon/messages.pigeon.dart +++ b/packages/firebase_analytics/firebase_analytics_platform_interface/lib/src/pigeon/messages.pigeon.dart @@ -416,4 +416,30 @@ class FirebaseAnalyticsHostApi { return; } } + + Future logTransaction(String transactionId) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.firebase_analytics_platform_interface.FirebaseAnalyticsHostApi.logTransaction$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([transactionId]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } } diff --git a/packages/firebase_analytics/firebase_analytics_platform_interface/lib/src/platform_interface/platform_interface_firebase_analytics.dart b/packages/firebase_analytics/firebase_analytics_platform_interface/lib/src/platform_interface/platform_interface_firebase_analytics.dart index 8fbe90066d5b..f69c816e707d 100644 --- a/packages/firebase_analytics/firebase_analytics_platform_interface/lib/src/platform_interface/platform_interface_firebase_analytics.dart +++ b/packages/firebase_analytics/firebase_analytics_platform_interface/lib/src/platform_interface/platform_interface_firebase_analytics.dart @@ -209,4 +209,10 @@ abstract class FirebaseAnalyticsPlatform extends PlatformInterface { 'initiateOnDeviceConversionMeasurement() is not implemented', ); } + + Future logTransaction({ + required String transactionId, + }) { + throw UnimplementedError('logTransaction() is not implemented'); + } } diff --git a/packages/firebase_analytics/firebase_analytics_platform_interface/pigeons/messages.dart b/packages/firebase_analytics/firebase_analytics_platform_interface/pigeons/messages.dart index 4c1ada746e85..b7008b4d3871 100644 --- a/packages/firebase_analytics/firebase_analytics_platform_interface/pigeons/messages.dart +++ b/packages/firebase_analytics/firebase_analytics_platform_interface/pigeons/messages.dart @@ -66,4 +66,7 @@ abstract class FirebaseAnalyticsHostApi { @async void initiateOnDeviceConversionMeasurement(Map arguments); + + @async + void logTransaction(String transactionId); } diff --git a/packages/firebase_analytics/firebase_analytics_platform_interface/test/pigeon/test_api.dart b/packages/firebase_analytics/firebase_analytics_platform_interface/test/pigeon/test_api.dart index cbef0d67a9eb..591cce9f19e2 100644 --- a/packages/firebase_analytics/firebase_analytics_platform_interface/test/pigeon/test_api.dart +++ b/packages/firebase_analytics/firebase_analytics_platform_interface/test/pigeon/test_api.dart @@ -67,6 +67,8 @@ abstract class TestFirebaseAnalyticsHostApi { Future initiateOnDeviceConversionMeasurement( Map arguments); + Future logTransaction(String transactionId); + static void setUp( TestFirebaseAnalyticsHostApi? api, { BinaryMessenger? binaryMessenger, @@ -409,5 +411,37 @@ abstract class TestFirebaseAnalyticsHostApi { }); } } + { + final BasicMessageChannel< + Object?> pigeonVar_channel = BasicMessageChannel< + Object?>( + 'dev.flutter.pigeon.firebase_analytics_platform_interface.FirebaseAnalyticsHostApi.logTransaction$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, null); + } else { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, + (Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.firebase_analytics_platform_interface.FirebaseAnalyticsHostApi.logTransaction was null.'); + final List args = (message as List?)!; + final String? arg_transactionId = (args[0] as String?); + assert(arg_transactionId != null, + 'Argument for dev.flutter.pigeon.firebase_analytics_platform_interface.FirebaseAnalyticsHostApi.logTransaction was null, expected non-null String.'); + try { + await api.logTransaction(arg_transactionId!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } } } diff --git a/tests/integration_test/firebase_analytics/firebase_analytics_e2e_test.dart b/tests/integration_test/firebase_analytics/firebase_analytics_e2e_test.dart index f7d309a3b306..b446d6c87193 100644 --- a/tests/integration_test/firebase_analytics/firebase_analytics_e2e_test.dart +++ b/tests/integration_test/firebase_analytics/firebase_analytics_e2e_test.dart @@ -5,6 +5,7 @@ import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:tests/firebase_options.dart'; @@ -356,5 +357,43 @@ void main() { }, skip: kIsWeb || defaultTargetPlatform != TargetPlatform.iOS, ); + + group('logTransaction', () { + test( + 'throws when transactionId is not a valid numeric string', + () async { + await expectLater( + FirebaseAnalytics.instance.logTransaction('not_a_number'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Invalid transactionId', + ), + ), + ); + }, + skip: defaultTargetPlatform != TargetPlatform.iOS && + defaultTargetPlatform != TargetPlatform.macOS, + ); + + test( + 'throws when transactionId is valid format but transaction not found in StoreKit', + () async { + await expectLater( + FirebaseAnalytics.instance.logTransaction('12345'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Transaction not found', + ), + ), + ); + }, + skip: defaultTargetPlatform != TargetPlatform.iOS && + defaultTargetPlatform != TargetPlatform.macOS, + ); + }); }); }