Skip to content

Commit 33743e2

Browse files
feat(analytics): add initiateOnDeviceConversionMeasurement for iOS (#852)
1 parent 1227d0d commit 33743e2

File tree

11 files changed

+412
-2
lines changed

11 files changed

+412
-2
lines changed

.changeset/witty-balloons-punch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@capacitor-firebase/analytics': minor
3+
---
4+
5+
feat(analytics): support `initiateOnDeviceConversionMeasurement` on iOS

packages/analytics/CapacitorFirebaseAnalytics.podspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Pod::Spec.new do |s|
2323

2424
s.subspec 'Analytics' do |analytics|
2525
analytics.dependency 'FirebaseAnalytics', '~> 11.7.0'
26+
analytics.dependency 'GoogleAppMeasurementOnDeviceConversion', '~> 11.7.0'
2627
end
2728

2829
s.subspec 'AnalyticsWithoutAdIdSupport' do |analyticsWithoutAdIdSupport|

packages/analytics/Package.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ let package = Package(
2020
.product(name: "Capacitor", package: "capacitor-swift-pm"),
2121
.product(name: "Cordova", package: "capacitor-swift-pm"),
2222
.product(name: "FirebaseAnalytics", package: "firebase-ios-sdk"),
23-
.product(name: "FirebaseCore", package: "firebase-ios-sdk")
23+
.product(name: "FirebaseCore", package: "firebase-ios-sdk"),
24+
.product(name: "GoogleAppMeasurementOnDeviceConversion", package: "firebase-ios-sdk")
2425
],
2526
path: "ios/Plugin"),
2627
.testTarget(

packages/analytics/README.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ const resetAnalyticsData = async () => {
134134
* [`setEnabled(...)`](#setenabled)
135135
* [`isEnabled()`](#isenabled)
136136
* [`resetAnalyticsData()`](#resetanalyticsdata)
137+
* [`initiateOnDeviceConversionMeasurementWithEmailAddress(...)`](#initiateondeviceconversionmeasurementwithemailaddress)
138+
* [`initiateOnDeviceConversionMeasurementWithPhoneNumber(...)`](#initiateondeviceconversionmeasurementwithphonenumber)
139+
* [`initiateOnDeviceConversionMeasurementWithHashedEmailAddress(...)`](#initiateondeviceconversionmeasurementwithhashedemailaddress)
140+
* [`initiateOnDeviceConversionMeasurementWithHashedPhoneNumber(...)`](#initiateondeviceconversionmeasurementwithhashedphonenumber)
137141
* [Interfaces](#interfaces)
138142
* [Enums](#enums)
139143

@@ -314,6 +318,82 @@ Only available for Android and iOS.
314318
--------------------
315319

316320

321+
### initiateOnDeviceConversionMeasurementWithEmailAddress(...)
322+
323+
```typescript
324+
initiateOnDeviceConversionMeasurementWithEmailAddress(options: InitiateOnDeviceConversionMeasurementWithEmailAddressOptions) => Promise<void>
325+
```
326+
327+
Initiates on-device conversion measurement with an email address.
328+
329+
Only available for iOS.
330+
331+
| Param | Type |
332+
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
333+
| **`options`** | <code><a href="#initiateondeviceconversionmeasurementwithemailaddressoptions">InitiateOnDeviceConversionMeasurementWithEmailAddressOptions</a></code> |
334+
335+
**Since:** 7.2.0
336+
337+
--------------------
338+
339+
340+
### initiateOnDeviceConversionMeasurementWithPhoneNumber(...)
341+
342+
```typescript
343+
initiateOnDeviceConversionMeasurementWithPhoneNumber(options: InitiateOnDeviceConversionMeasurementWithPhoneNumberOptions) => Promise<void>
344+
```
345+
346+
Initiates on-device conversion measurement with a phone number.
347+
348+
Only available for iOS.
349+
350+
| Param | Type |
351+
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
352+
| **`options`** | <code><a href="#initiateondeviceconversionmeasurementwithphonenumberoptions">InitiateOnDeviceConversionMeasurementWithPhoneNumberOptions</a></code> |
353+
354+
**Since:** 7.2.0
355+
356+
--------------------
357+
358+
359+
### initiateOnDeviceConversionMeasurementWithHashedEmailAddress(...)
360+
361+
```typescript
362+
initiateOnDeviceConversionMeasurementWithHashedEmailAddress(options: InitiateOnDeviceConversionMeasurementWithHashedEmailAddressOptions) => Promise<void>
363+
```
364+
365+
Initiates on-device conversion measurement with a hashed email address.
366+
367+
Only available for iOS.
368+
369+
| Param | Type |
370+
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
371+
| **`options`** | <code><a href="#initiateondeviceconversionmeasurementwithhashedemailaddressoptions">InitiateOnDeviceConversionMeasurementWithHashedEmailAddressOptions</a></code> |
372+
373+
**Since:** 7.2.0
374+
375+
--------------------
376+
377+
378+
### initiateOnDeviceConversionMeasurementWithHashedPhoneNumber(...)
379+
380+
```typescript
381+
initiateOnDeviceConversionMeasurementWithHashedPhoneNumber(options: InitiateOnDeviceConversionMeasurementWithHashedPhoneNumberOptions) => Promise<void>
382+
```
383+
384+
Initiates on-device conversion measurement with a hashed phone number.
385+
386+
Only available for iOS.
387+
388+
| Param | Type |
389+
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
390+
| **`options`** | <code><a href="#initiateondeviceconversionmeasurementwithhashedphonenumberoptions">InitiateOnDeviceConversionMeasurementWithHashedPhoneNumberOptions</a></code> |
391+
392+
**Since:** 7.2.0
393+
394+
--------------------
395+
396+
317397
### Interfaces
318398

319399

@@ -384,6 +464,34 @@ Only available for Android and iOS.
384464
| **`enabled`** | <code>boolean</code> | 0.1.0 |
385465

386466

467+
#### InitiateOnDeviceConversionMeasurementWithEmailAddressOptions
468+
469+
| Prop | Type | Description | Since |
470+
| ------------------ | ------------------- | -------------------------------------------------------------------- | ----- |
471+
| **`emailAddress`** | <code>string</code> | The email address to initiate on-device conversion measurement with. | 7.2.0 |
472+
473+
474+
#### InitiateOnDeviceConversionMeasurementWithPhoneNumberOptions
475+
476+
| Prop | Type | Description | Since |
477+
| ----------------- | ------------------- | ------------------------------------------------------------------- | ----- |
478+
| **`phoneNumber`** | <code>string</code> | The phone number to initiate on-device conversion measurement with. | 7.2.0 |
479+
480+
481+
#### InitiateOnDeviceConversionMeasurementWithHashedEmailAddressOptions
482+
483+
| Prop | Type | Description | Since |
484+
| ------------------------ | ------------------- | -------------------------------------------------------------------- | ----- |
485+
| **`emailAddressToHash`** | <code>string</code> | The email address to initiate on-device conversion measurement with. | 7.2.0 |
486+
487+
488+
#### InitiateOnDeviceConversionMeasurementWithHashedPhoneNumberOptions
489+
490+
| Prop | Type | Description | Since |
491+
| ----------------------- | ------------------- | ------------------------------------------------------------------- | ----- |
492+
| **`phoneNumberToHash`** | <code>string</code> | The phone number to initiate on-device conversion measurement with. | 7.2.0 |
493+
494+
387495
### Enums
388496

389497

packages/analytics/android/src/main/java/io/capawesome/capacitorjs/plugins/firebase/analytics/FirebaseAnalyticsPlugin.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,24 @@ public void resetAnalyticsData(PluginCall call) {
177177
call.reject(exception.getMessage());
178178
}
179179
}
180+
181+
@PluginMethod
182+
public void initiateOnDeviceConversionMeasurementWithEmailAddress(PluginCall call) {
183+
call.unimplemented("Not implemented on Android.");
184+
}
185+
186+
@PluginMethod
187+
public void initiateOnDeviceConversionMeasurementWithPhoneNumber(PluginCall call) {
188+
call.unimplemented("Not implemented on Android.");
189+
}
190+
191+
@PluginMethod
192+
public void initiateOnDeviceConversionMeasurementWithHashedEmailAddress(PluginCall call) {
193+
call.unimplemented("Not implemented on Android.");
194+
}
195+
196+
@PluginMethod
197+
public void initiateOnDeviceConversionMeasurementWithHashedPhoneNumber(PluginCall call) {
198+
call.unimplemented("Not implemented on Android.");
199+
}
180200
}

packages/analytics/ios/Plugin/FirebaseAnalytics.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,20 @@ import FirebaseAnalytics
5959
@objc public func resetAnalyticsData() {
6060
Analytics.resetAnalyticsData()
6161
}
62+
63+
@objc public func initiateOnDeviceConversionMeasurement(email: String) {
64+
Analytics.initiateOnDeviceConversionMeasurement(emailAddress: email)
65+
}
66+
67+
@objc public func initiateOnDeviceConversionMeasurement(phone: String) {
68+
Analytics.initiateOnDeviceConversionMeasurement(phoneNumber: phone)
69+
}
70+
71+
@objc public func initiateOnDeviceConversionMeasurement(hashedEmail: Data) {
72+
Analytics.initiateOnDeviceConversionMeasurement(hashedEmailAddress: hashedEmail)
73+
}
74+
75+
@objc public func initiateOnDeviceConversionMeasurement(hashedPhone: Data) {
76+
Analytics.initiateOnDeviceConversionMeasurement(hashedPhoneNumber: hashedPhone)
77+
}
6278
}

packages/analytics/ios/Plugin/FirebaseAnalyticsHelper.swift

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Foundation
22
import FirebaseAnalytics
3+
import CommonCrypto
34

45
public class FirebaseAnalyticsHelper {
56
public static func mapStringToConsentType(_ value: String?) -> ConsentType? {
@@ -27,4 +28,80 @@ public class FirebaseAnalyticsHelper {
2728
return nil
2829
}
2930
}
31+
32+
/**
33+
* Normalizes email addresses according to Google Analytics API requirements before SHA256 hashing.
34+
* See: https://firebase.google.com/docs/tutorials/ads-ios-on-device-measurement/step-3#use-hashed-credentials
35+
*
36+
* The normalization process:
37+
* 1. Converts email to lowercase
38+
* 2. Replaces @googlemail.com with @gmail.com
39+
* 3. For @gmail.com addresses:
40+
* - Removes all periods from username
41+
* - Replaces i, I, 1 with l
42+
* - Replaces 0 with o
43+
* - Replaces 2 with z
44+
* - Replaces 5 with s
45+
*
46+
* Examples:
47+
48+
49+
*/
50+
public static func normalizeEmail(_ email: String) -> String {
51+
var normalized = email.lowercased()
52+
53+
if normalized.hasSuffix("@googlemail.com") {
54+
normalized = normalized.replacingOccurrences(of: "@googlemail.com", with: "@gmail.com")
55+
}
56+
57+
if normalized.hasSuffix("@gmail.com") {
58+
let components = normalized.components(separatedBy: "@")
59+
if components.count == 2 {
60+
var username = components[0]
61+
62+
username = username.replacingOccurrences(of: ".", with: "")
63+
64+
username = username.replacingOccurrences(of: "i", with: "l")
65+
username = username.replacingOccurrences(of: "I", with: "l")
66+
username = username.replacingOccurrences(of: "1", with: "l")
67+
username = username.replacingOccurrences(of: "0", with: "o")
68+
username = username.replacingOccurrences(of: "2", with: "z")
69+
username = username.replacingOccurrences(of: "5", with: "s")
70+
71+
normalized = username + "@gmail.com"
72+
}
73+
}
74+
75+
return normalized
76+
}
77+
78+
public static func isValidEmail(_ email: String) -> Bool {
79+
let pattern = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
80+
let regex = try? NSRegularExpression(pattern: pattern)
81+
let range = NSRange(location: 0, length: email.utf16.count)
82+
return regex?.firstMatch(in: email, range: range) != nil
83+
}
84+
85+
/**
86+
* See: https://firebase.google.com/docs/tutorials/ads-ios-on-device-measurement/step-3#use-hashed-credentials
87+
* Note: For phone numbers, they must be in E.164 format before hashing:
88+
* - Must start with +
89+
* - 1-3 digits for country code
90+
* - Max 12 digits for subscriber number
91+
*/
92+
public static func isValidE164PhoneNumber(_ phoneNumber: String) -> Bool {
93+
let pattern = "^\\+[0-9]{1,3}[0-9]{1,12}$"
94+
let regex = try? NSRegularExpression(pattern: pattern)
95+
let range = NSRange(location: 0, length: phoneNumber.utf16.count)
96+
return regex?.firstMatch(in: phoneNumber, range: range) != nil
97+
}
98+
99+
public static func sha256(_ string: String) -> Data {
100+
let data = string.data(using: .utf8)!
101+
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
102+
data.withUnsafeBytes {
103+
_ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash)
104+
}
105+
return Data(hash)
106+
}
30107
}

packages/analytics/ios/Plugin/FirebaseAnalyticsPlugin.swift

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ public class FirebaseAnalyticsPlugin: CAPPlugin, CAPBridgedPlugin {
2121
CAPPluginMethod(name: "setSessionTimeoutDuration", returnType: CAPPluginReturnPromise),
2222
CAPPluginMethod(name: "setEnabled", returnType: CAPPluginReturnPromise),
2323
CAPPluginMethod(name: "isEnabled", returnType: CAPPluginReturnPromise),
24-
CAPPluginMethod(name: "resetAnalyticsData", returnType: CAPPluginReturnPromise)
24+
CAPPluginMethod(name: "resetAnalyticsData", returnType: CAPPluginReturnPromise),
25+
CAPPluginMethod(name: "initiateOnDeviceConversionMeasurementWithEmailAddress", returnType: CAPPluginReturnPromise),
26+
CAPPluginMethod(name: "initiateOnDeviceConversionMeasurementWithPhoneNumber", returnType: CAPPluginReturnPromise),
27+
CAPPluginMethod(name: "initiateOnDeviceConversionMeasurementWithHashedEmailAddress", returnType: CAPPluginReturnPromise),
28+
CAPPluginMethod(name: "initiateOnDeviceConversionMeasurementWithHashedPhoneNumber", returnType: CAPPluginReturnPromise)
2529
]
2630
public let errorUserIdMissing = "userId must be provided."
2731
public let errorKeyMissing = "key must be provided."
@@ -30,6 +34,12 @@ public class FirebaseAnalyticsPlugin: CAPPlugin, CAPBridgedPlugin {
3034
public let errorEnabledMissing = "enabled must be provided."
3135
public let errorConsentTypeMissing = "consentType must be provided."
3236
public let errorConsentStatusMissing = "consentStatus must be provided."
37+
public let errorEmailAddressMissing = "emailAddress must be provided."
38+
public let errorInvalidEmailFormat = "Invalid email format. Please provide a valid email address."
39+
public let errorPhoneNumberMissing = "phoneNumber must be provided."
40+
public let errorEmailAddressToHashMissing = "emailAddressToHash must be provided."
41+
public let errorPhoneNumberToHashMissing = "phoneNumberToHash must be provided."
42+
public let errorInvalidPhoneNumberFormat = "Invalid phone number format. Please provide a valid E.164 formatted phone number."
3343
private var implementation: FirebaseAnalytics?
3444

3545
override public func load() {
@@ -114,4 +124,59 @@ public class FirebaseAnalyticsPlugin: CAPPlugin, CAPBridgedPlugin {
114124
implementation?.resetAnalyticsData()
115125
call.resolve()
116126
}
127+
128+
@objc func initiateOnDeviceConversionMeasurementWithEmailAddress(_ call: CAPPluginCall) {
129+
guard let emailAddress = call.getString("emailAddress") else {
130+
call.reject(errorEmailAddressMissing)
131+
return
132+
}
133+
if !FirebaseAnalyticsHelper.isValidEmail(emailAddress) {
134+
call.reject(errorInvalidEmailFormat)
135+
return
136+
}
137+
implementation?.initiateOnDeviceConversionMeasurement(email: emailAddress)
138+
call.resolve()
139+
}
140+
141+
@objc func initiateOnDeviceConversionMeasurementWithPhoneNumber(_ call: CAPPluginCall) {
142+
guard let phoneNumber = call.getString("phoneNumber") else {
143+
call.reject(errorPhoneNumberMissing)
144+
return
145+
}
146+
if !FirebaseAnalyticsHelper.isValidE164PhoneNumber(phoneNumber) {
147+
call.reject(errorInvalidPhoneNumberFormat)
148+
return
149+
}
150+
implementation?.initiateOnDeviceConversionMeasurement(phone: phoneNumber)
151+
call.resolve()
152+
}
153+
154+
@objc func initiateOnDeviceConversionMeasurementWithHashedEmailAddress(_ call: CAPPluginCall) {
155+
guard let email = call.getString("emailAddressToHash") else {
156+
call.reject(errorEmailAddressToHashMissing)
157+
return
158+
}
159+
if !FirebaseAnalyticsHelper.isValidEmail(email) {
160+
call.reject(errorInvalidEmailFormat)
161+
return
162+
}
163+
let normalizedEmail = FirebaseAnalyticsHelper.normalizeEmail(email)
164+
let hashedEmail = FirebaseAnalyticsHelper.sha256(normalizedEmail)
165+
implementation?.initiateOnDeviceConversionMeasurement(hashedEmail: hashedEmail)
166+
call.resolve()
167+
}
168+
169+
@objc func initiateOnDeviceConversionMeasurementWithHashedPhoneNumber(_ call: CAPPluginCall) {
170+
guard let phone = call.getString("phoneNumberToHash") else {
171+
call.reject(errorPhoneNumberToHashMissing)
172+
return
173+
}
174+
if !FirebaseAnalyticsHelper.isValidE164PhoneNumber(phone) {
175+
call.reject(errorInvalidPhoneNumberFormat)
176+
return
177+
}
178+
let hashedPhone = FirebaseAnalyticsHelper.sha256(phone)
179+
implementation?.initiateOnDeviceConversionMeasurement(hashedPhone: hashedPhone)
180+
call.resolve()
181+
}
117182
}

packages/analytics/ios/Podfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ end
1010
target 'Plugin' do
1111
capacitor_pods
1212
pod 'FirebaseAnalytics', '11.7.0'
13+
pod 'GoogleAppMeasurementOnDeviceConversion', '11.7.0'
1314
end
1415

1516
target 'PluginTests' do

0 commit comments

Comments
 (0)