Skip to content

Commit 1039e9a

Browse files
feat(analytics, ios): add support for logTransaction (invertase#8921)
1 parent e085ddd commit 1039e9a

10 files changed

Lines changed: 266 additions & 8 deletions

File tree

packages/analytics/RNFBAnalytics.podspec

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ Pod::Spec.new do |s|
2727
s.ios.deployment_target = firebase_ios_target
2828
s.macos.deployment_target = firebase_macos_target
2929
s.tvos.deployment_target = firebase_tvos_target
30-
s.source_files = 'ios/**/*.{h,m}'
30+
s.source_files = 'ios/**/*.{h,m,mm,cpp,swift}'
31+
s.swift_version = '5.0'
3132

3233
s.dependency 'RNFBApp'
3334

packages/analytics/__tests__/analytics.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import analytics, {
99
initializeAnalytics,
1010
getGoogleAnalyticsClientId,
1111
logEvent,
12+
logTransaction,
1213
setAnalyticsCollectionEnabled,
1314
setSessionTimeoutDuration,
1415
getAppInstanceId,
@@ -1001,6 +1002,10 @@ describe('Analytics', function () {
10011002
it('`settings` function is properly exposed to end user', function () {
10021003
expect(settings).toBeDefined();
10031004
});
1005+
1006+
it('`logTransaction` function is properly exposed to end user', function () {
1007+
expect(logTransaction).toBeDefined();
1008+
});
10041009
});
10051010

10061011
describe('test `console.warn` is called for RNFB v8 API & not called for v9 API', function () {

packages/analytics/e2e/analytics.e2e.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,6 +1172,68 @@ describe('analytics()', function () {
11721172
});
11731173
});
11741174

1175+
describe('logTransaction()', function () {
1176+
it('throws when transactionId is not a valid numeric string', async function () {
1177+
if (!Platform.ios) {
1178+
this.skip();
1179+
}
1180+
try {
1181+
const { getAnalytics, logTransaction } = analyticsModular;
1182+
await logTransaction(getAnalytics(), 'not_a_number');
1183+
fail('Should have thrown an error');
1184+
} catch (e) {
1185+
if (!(e && e.message && e.message.includes('Invalid transactionId'))) {
1186+
throw e;
1187+
}
1188+
}
1189+
});
1190+
1191+
it('throws when transactionId is valid format but transaction not found in StoreKit', async function () {
1192+
if (!Platform.ios) {
1193+
this.skip();
1194+
}
1195+
try {
1196+
const { getAnalytics, logTransaction } = analyticsModular;
1197+
await logTransaction(getAnalytics(), '12345');
1198+
fail('Should have thrown an error');
1199+
} catch (e) {
1200+
if (!(e && e.message && e.message.includes('Transaction not found'))) {
1201+
throw e;
1202+
}
1203+
}
1204+
});
1205+
1206+
it('rejects with unimplemented on Android', async function () {
1207+
if (!Platform.android) {
1208+
this.skip();
1209+
}
1210+
try {
1211+
const { getAnalytics, logTransaction } = analyticsModular;
1212+
await logTransaction(getAnalytics(), '12345');
1213+
fail('Should have thrown an error');
1214+
} catch (e) {
1215+
if (!(e && e.message && e.message.includes('logTransaction is only available on iOS'))) {
1216+
throw e;
1217+
}
1218+
}
1219+
});
1220+
1221+
it('rejects with unimplemented on web (other platform)', async function () {
1222+
if (!Platform.other) {
1223+
this.skip();
1224+
}
1225+
try {
1226+
const { getAnalytics, logTransaction } = analyticsModular;
1227+
await logTransaction(getAnalytics(), '12345');
1228+
fail('Should have thrown an error');
1229+
} catch (e) {
1230+
if (!(e && e.message && e.message.includes('logTransaction is only available on iOS'))) {
1231+
throw e;
1232+
}
1233+
}
1234+
});
1235+
});
1236+
11751237
describe('getGoogleAnalyticsClientId()', function () {
11761238
it('Error for getGoogleAnalyticsClientId() on non-other platforms', async function () {
11771239
if (Platform.other) {

packages/analytics/ios/RNFBAnalytics.xcodeproj/project.pbxproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
/* Begin PBXBuildFile section */
1010
2744B98621F45429004F8E3F /* RNFBAnalyticsModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 2744B98521F45429004F8E3F /* RNFBAnalyticsModule.m */; };
11+
2744B99021F45429004F8E3F /* RNFBAnalyticsLogTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2744B98F21F45429004F8E3F /* RNFBAnalyticsLogTransaction.swift */; };
1112
/* End PBXBuildFile section */
1213

1314
/* Begin PBXCopyFilesBuildPhase section */
@@ -26,6 +27,7 @@
2627
2744B98221F45429004F8E3F /* libRNFBAnalytics.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libRNFBAnalytics.a; sourceTree = BUILT_PRODUCTS_DIR; };
2728
2744B98421F45429004F8E3F /* RNFBAnalyticsModule.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNFBAnalyticsModule.h; path = RNFBAnalytics/RNFBAnalyticsModule.h; sourceTree = SOURCE_ROOT; };
2829
2744B98521F45429004F8E3F /* RNFBAnalyticsModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = RNFBAnalyticsModule.m; path = RNFBAnalytics/RNFBAnalyticsModule.m; sourceTree = SOURCE_ROOT; };
30+
2744B98F21F45429004F8E3F /* RNFBAnalyticsLogTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = RNFBAnalyticsLogTransaction.swift; path = RNFBAnalytics/RNFBAnalyticsLogTransaction.swift; sourceTree = SOURCE_ROOT; };
2931
/* End PBXFileReference section */
3032

3133
/* Begin PBXFrameworksBuildPhase section */
@@ -54,6 +56,7 @@
5456
2744B98C21F45C64004F8E3F /* common */,
5557
2744B98421F45429004F8E3F /* RNFBAnalyticsModule.h */,
5658
2744B98521F45429004F8E3F /* RNFBAnalyticsModule.m */,
59+
2744B98F21F45429004F8E3F /* RNFBAnalyticsLogTransaction.swift */,
5760
);
5861
path = RNFBAnalytics;
5962
sourceTree = "<group>";
@@ -125,6 +128,7 @@
125128
buildActionMask = 2147483647;
126129
files = (
127130
2744B98621F45429004F8E3F /* RNFBAnalyticsModule.m in Sources */,
131+
2744B99021F45429004F8E3F /* RNFBAnalyticsLogTransaction.swift in Sources */,
128132
);
129133
runOnlyForDeploymentPostprocessing = 0;
130134
};
@@ -166,6 +170,7 @@
166170
PRODUCT_NAME = "$(TARGET_NAME)";
167171
SDKROOT = iphoneos;
168172
SKIP_INSTALL = YES;
173+
SWIFT_VERSION = 5.0;
169174
TARGETED_DEVICE_FAMILY = "1,2";
170175
};
171176
name = Debug;
@@ -200,6 +205,7 @@
200205
PRODUCT_NAME = "$(TARGET_NAME)";
201206
SDKROOT = iphoneos;
202207
SKIP_INSTALL = YES;
208+
SWIFT_VERSION = 5.0;
203209
TARGETED_DEVICE_FAMILY = "1,2";
204210
VALIDATE_PRODUCT = YES;
205211
};
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Copyright (c) 2016-present Invertase Limited & Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this library except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
18+
import Foundation
19+
import FirebaseAnalytics
20+
import StoreKit
21+
22+
/// Swift wrapper for logging a verified StoreKit 2 transaction to Firebase Analytics.
23+
/// Accessible from Objective-C; necessary because StoreKit 2 and Analytics.logTransaction use Swift async APIs.
24+
/// Call from ObjC only when @available(iOS 15.0, *) (see RNFBFunctionsStreamHandler pattern).
25+
@available(iOS 15.0, macOS 12.0, *)
26+
@objcMembers public class RNFBAnalyticsLogTransaction: NSObject {
27+
28+
private static let kCode = "firebase_analytics"
29+
private var logTask: Task<Void, Never>?
30+
31+
/// Resolve/reject types matching RCTPromiseResolveBlock / RCTPromiseRejectBlock for React Native bridge.
32+
@objc public func logTransaction(
33+
transactionId: String,
34+
resolve: @escaping (Any?) -> Void,
35+
reject: @escaping (String, String, NSError?) -> Void
36+
) {
37+
logTask = Task {
38+
await performLogTransaction(transactionId: transactionId, resolve: resolve, reject: reject)
39+
logTask = nil
40+
}
41+
}
42+
43+
private func performLogTransaction(
44+
transactionId: String,
45+
resolve: @escaping (Any?) -> Void,
46+
reject: @escaping (String, String, NSError?) -> Void
47+
) async {
48+
guard let id = UInt64(transactionId) else {
49+
await MainActor.run { reject(Self.kCode, "Invalid transactionId", nil) }
50+
return
51+
}
52+
53+
var foundTransaction: StoreKit.Transaction?
54+
for await result in StoreKit.Transaction.all {
55+
switch result {
56+
case let .verified(transaction):
57+
if transaction.id == id {
58+
foundTransaction = transaction
59+
break
60+
}
61+
case .unverified:
62+
continue
63+
}
64+
}
65+
66+
guard let transaction = foundTransaction else {
67+
await MainActor.run { reject(Self.kCode, "Transaction not found", nil) }
68+
return
69+
}
70+
71+
Analytics.logTransaction(transaction)
72+
await MainActor.run { resolve(NSNull()) }
73+
}
74+
}

packages/analytics/ios/RNFBAnalytics/RNFBAnalyticsModule.m

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@
1818
#import <Firebase/Firebase.h>
1919
#import <React/RCTUtils.h>
2020

21+
#if __has_include(<RNFBAnalytics/RNFBAnalytics-Swift.h>)
22+
// This import will work in situations where `use_frameworks!` is in use
23+
#import <RNFBAnalytics/RNFBAnalytics-Swift.h>
24+
#elif __has_include("RNFBAnalytics-Swift.h")
25+
// If `use_frameworks!` is not in use (for example, while using pre-built
26+
// react-native core) then header imports based on frameworks assumptions fail.
27+
// So, if frameworks are not available, fall back to importing the header directly, it
28+
// should be findable from a header search path pointing to the build
29+
// directory. See firebase-ios-sdk#12611 for more context.
30+
#import "RNFBAnalytics-Swift.h"
31+
#endif
2132
#import <RNFBApp/RNFBSharedUtils.h>
2233
#import "RNFBAnalyticsModule.h"
2334

@@ -212,6 +223,18 @@ - (dispatch_queue_t)methodQueue {
212223
return resolve([NSNull null]);
213224
}
214225

226+
RCT_EXPORT_METHOD(logTransaction
227+
: (NSString *)transactionId resolver
228+
: (RCTPromiseResolveBlock)resolve rejecter
229+
: (RCTPromiseRejectBlock)reject) {
230+
if (@available(iOS 15.0, macOS 12.0, *)) {
231+
RNFBAnalyticsLogTransaction *handler = [[RNFBAnalyticsLogTransaction alloc] init];
232+
[handler logTransactionWithTransactionId:transactionId resolve:resolve reject:reject];
233+
} else {
234+
reject(@"firebase_analytics", @"logTransaction() is only supported on iOS 15.0 or newer", nil);
235+
}
236+
}
237+
215238
RCT_EXPORT_METHOD(setConsent
216239
: (NSDictionary *)consentSettings resolver
217240
: (RCTPromiseResolveBlock)resolve rejecter

packages/analytics/lib/modular.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { MODULAR_DEPRECATION_ARG } from '@react-native-firebase/app/dist/module/common';
22
import { getApp } from '@react-native-firebase/app';
33
import type { Analytics } from './types/analytics';
4+
import type { AnalyticsInternal } from './types/internal';
45
import { Platform } from 'react-native';
56
import type { ReactNativeFirebase } from '@react-native-firebase/app';
67
import type {
@@ -416,6 +417,18 @@ export function logEvent(
416417
return analytics.logEvent.call(analytics, name, params, options, MODULAR_DEPRECATION_ARG);
417418
}
418419

420+
/** Logs verified in-app purchase events in Google Analytics for Firebase
421+
* after a purchase is successful.
422+
* Modular API only; iOS only (StoreKit 2). Throws on Android and web before reaching native.
423+
*/
424+
export function logTransaction(analytics: Analytics, transaction_id: string): Promise<void> {
425+
if (Platform.OS !== 'ios') {
426+
return Promise.reject(new Error('logTransaction is only available on iOS'));
427+
}
428+
429+
return (analytics as AnalyticsInternal).native.logTransaction(transaction_id);
430+
}
431+
419432
/**
420433
* If true, allows the device to collect analytical data and send it to Firebase. Useful for GDPR.
421434
*/

packages/analytics/lib/namespaced.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import {
3434
getFirebaseRoot,
3535
} from '@react-native-firebase/app/dist/module/internal';
3636

37+
import './types/internal';
38+
3739
// Internal types are now available through module declarations in app package
3840
import { setReactNativeModule } from '@react-native-firebase/app/dist/module/internal/nativeModule';
3941
import { isBoolean } from '@react-native-firebase/app/dist/module/common';
@@ -122,7 +124,7 @@ const namespace = 'analytics';
122124

123125
const nativeModuleName = 'RNFBAnalyticsModule';
124126

125-
class FirebaseAnalyticsModule extends FirebaseModule {
127+
class FirebaseAnalyticsModule extends FirebaseModule<typeof nativeModuleName> {
126128
logEvent(
127129
name: string,
128130
params: { [key: string]: any } = {},
@@ -800,7 +802,10 @@ class FirebaseAnalyticsModule extends FirebaseModule {
800802
return Promise.resolve();
801803
}
802804

803-
return this.native.initiateOnDeviceConversionMeasurementWithEmailAddress(emailAddress);
805+
return (
806+
this.native.initiateOnDeviceConversionMeasurementWithEmailAddress?.(emailAddress) ??
807+
Promise.resolve()
808+
);
804809
}
805810

806811
initiateOnDeviceConversionMeasurementWithHashedEmailAddress(
@@ -816,8 +821,10 @@ class FirebaseAnalyticsModule extends FirebaseModule {
816821
return Promise.resolve();
817822
}
818823

819-
return this.native.initiateOnDeviceConversionMeasurementWithHashedEmailAddress(
820-
hashedEmailAddress,
824+
return (
825+
this.native.initiateOnDeviceConversionMeasurementWithHashedEmailAddress?.(
826+
hashedEmailAddress,
827+
) ?? Promise.resolve()
821828
);
822829
}
823830

@@ -832,7 +839,10 @@ class FirebaseAnalyticsModule extends FirebaseModule {
832839
return Promise.resolve();
833840
}
834841

835-
return this.native.initiateOnDeviceConversionMeasurementWithPhoneNumber(phoneNumber);
842+
return (
843+
this.native.initiateOnDeviceConversionMeasurementWithPhoneNumber?.(phoneNumber) ??
844+
Promise.resolve()
845+
);
836846
}
837847

838848
initiateOnDeviceConversionMeasurementWithHashedPhoneNumber(
@@ -854,8 +864,9 @@ class FirebaseAnalyticsModule extends FirebaseModule {
854864
return Promise.resolve();
855865
}
856866

857-
return this.native.initiateOnDeviceConversionMeasurementWithHashedPhoneNumber(
858-
hashedPhoneNumber,
867+
return (
868+
this.native.initiateOnDeviceConversionMeasurementWithHashedPhoneNumber?.(hashedPhoneNumber) ??
869+
Promise.resolve()
859870
);
860871
}
861872
}

packages/analytics/lib/types/analytics.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -939,6 +939,7 @@ export interface Analytics extends ReactNativeFirebase.FirebaseModule {
939939
logShare(object: ShareEventParameters): Promise<void>;
940940
logSignUp(object: SignUpEventParameters): Promise<void>;
941941
logSpendVirtualCurrency(object: SpendVirtualCurrencyEventParameters): Promise<void>;
942+
logTransaction(transaction_id: string): Promise<void>;
942943
logTutorialBegin(): Promise<void>;
943944
logTutorialComplete(): Promise<void>;
944945
logUnlockAchievement(object: UnlockAchievementEventParameters): Promise<void>;

0 commit comments

Comments
 (0)