-
Notifications
You must be signed in to change notification settings - Fork 14
feat(MSDK-3172): Consume and expose DPS metadata in AppSDK #193
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -85,6 +85,16 @@ internal class RNUsercentricsModule( | |
| promise.resolve(usercentricsProxy.instance.getABTestingVariant()) | ||
| } | ||
|
|
||
| @ReactMethod | ||
| override fun getDpsMetadata(templateId: String, promise: Promise) { | ||
| val metadata = usercentricsProxy.instance.getDpsMetadata(templateId) | ||
| if (metadata == null) { | ||
| promise.resolve(null) | ||
| } else { | ||
| promise.resolve(metadata.toWritableMap()) | ||
| } | ||
| } | ||
|
Comment on lines
+88
to
+96
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [CRITICAL_BUG] Wrap the new getDpsMetadata implementation in a try/catch and reject the promise on failure. Converting native Maps to WritableMap (metadata.toWritableMap()) can throw if values are not directly serializable to the bridge. Example actionable fixes: (1) surround the conversion with try { ... } catch (e: Exception) { promise.reject(e) } or (2) defensively validate/normalize the map values before calling toWritableMap(). This prevents unhandled exceptions that would crash the bridge or leave the JS promise unresolved. @ReactMethod
override fun getDpsMetadata(templateId: String, promise: Promise) {
try {
val metadata = usercentricsProxy.instance.getDpsMetadata(templateId)
if (metadata == null) {
promise.resolve(null)
} else {
promise.resolve(metadata.toWritableMap())
}
} catch (e: Exception) {
promise.reject("usercentrics_getDpsMetadata_error", e.message, e)
}
}
Comment on lines
+88
to
+96
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Search for any documentation or usage of getDpsMetadata in the codebase
rg -n "getDpsMetadata" --type-add 'kotlin:*.kt' --type kotlinRepository: Usercentrics/react-native-sdk Length of output: 529 🏁 Script executed: find . -name "build.gradle*" -type f | head -20Repository: Usercentrics/react-native-sdk Length of output: 288 🏁 Script executed: rg -n "usercentrics" --type-add 'gradle:*.gradle' --type gradle -i | head -30Repository: Usercentrics/react-native-sdk Length of output: 1278 🏁 Script executed: rg -n "2\.25\." --type-add 'gradle:*.gradle' --type gradleRepository: Usercentrics/react-native-sdk Length of output: 138 🏁 Script executed: cat -n android/build.gradle.kts | head -100Repository: Usercentrics/react-native-sdk Length of output: 3728 🏁 Script executed: rg -n "usercentrics-ui\|usercentrics_version" android/build.gradle.ktsRepository: Usercentrics/react-native-sdk Length of output: 55 🏁 Script executed: rg -n "usercentricsProxy" --type kotlinRepository: Usercentrics/react-native-sdk Length of output: 20394 🏁 Script executed: find . -name "UsercentricsProxy*" -type fRepository: Usercentrics/react-native-sdk Length of output: 150 🏁 Script executed: rg -n "interface UsercentricsProxy\|class.*UsercentricsProxy" --type kotlin -A 5Repository: Usercentrics/react-native-sdk Length of output: 55 🏁 Script executed: rg -n "getDpsMetadata" --type kotlin -B 2 -A 2Repository: Usercentrics/react-native-sdk Length of output: 1292 🏁 Script executed: cat -n android/src/main/java/com/usercentrics/reactnative/api/UsercentricsProxy.ktRepository: Usercentrics/react-native-sdk Length of output: 2289 🏁 Script executed: rg -n "getDpsMetadata" --type kotlin -B 5 -A 5Repository: Usercentrics/react-native-sdk Length of output: 2599 🏁 Script executed: find . -name "*Test*.kt" -type f -exec grep -l "getDpsMetadata" {} \;Repository: Usercentrics/react-native-sdk Length of output: 55 🌐 Web query:
💡 Result: No specific changelog entry found for the getDpsMetadata API in Usercentrics Android SDK version 2.25.1. The release notes for 2.25.1 (released March 2, 2026) mention only "UI improvements and fixes" and "Patch with security fixes". No mention of getDpsMetadata or DPS-related API changes in 2.25.1, 2.25.0, or recent versions. Official API documentation lists methods like getCMPData, getConsents, but no getDpsMetadata. DPS (Data Processing Services) metadata is likely accessed via UsercentricsService.deviceStorage in getCMPData response, which received TCF Vendor Device Storage enhancements in 2.25.1 (new fields: sdks, specialPurposes, optOut in disclosures). Citations:
🌐 Web query:
💡 Result: No official documentation for a "getDpsMetadata" method was found in Usercentrics SDKs across browser, mobile (iOS/Android/Flutter/Unity/React Native), or related APIs after extensive searches of official docs.usercentrics.com pages, pub.dev packages, and GitHub repos. Usercentrics SDKs provide similar functionality through methods like: - getServices or getServicesBaseInfo / getServicesFullInfo in browser SDK (returns Service[] with data processing service details) - getCMPData in Apps SDK Core API (includes services: [UsercentricsService]) - consents (Future<List>) in Flutter SDK "DPS" refers to Data Processing Services in Usercentrics terminology. If this is from a private/internal SDK version or custom wrapper, check your specific implementation or contact Usercentrics support. Primary docs: https://docs.usercentrics.com/ Citations:
Remove The 🤖 Prompt for AI Agents |
||
|
|
||
| @ReactMethod | ||
| override fun getConsents(promise: Promise) { | ||
| promise.resolve(usercentricsProxy.instance.getConsents().toWritableArray()) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -60,6 +60,9 @@ abstract class RNUsercentricsModuleSpec internal constructor(context: ReactAppli | |
| @ReactMethod | ||
| abstract fun getABTestingVariant(promise: Promise) | ||
|
|
||
| @ReactMethod | ||
| abstract fun getDpsMetadata(templateId: String, promise: Promise) | ||
|
|
||
|
Comment on lines
+63
to
+65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [REFACTORING] You added a new abstract method to the TurboModule spec. Make sure to run the native codegen/prepare steps so generated JNI and TurboModule bindings are updated accordingly (e.g. run the project prepare/script that triggers generate-codegen-jni or run yarn prepare). Without regenerating, native binding mismatches can cause runtime crashes or missing methods in the bridge. |
||
| @ReactMethod | ||
| abstract fun setCMPId(id: Double) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -159,6 +159,11 @@ class RNUsercentricsModule: RCTEventEmitter { | |
| resolve(usercentricsManager.getABTestingVariant()) | ||
| } | ||
|
|
||
| @objc func getDpsMetadata(_ templateId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void { | ||
| let metadata = usercentricsManager.getDpsMetadata(templateId: templateId) | ||
| resolve(metadata as NSDictionary?) | ||
| } | ||
|
Comment on lines
+162
to
+165
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [CRITICAL_BUG] Avoid force/unsafe bridging when resolving the metadata. resolve(metadata as NSDictionary?) may crash or produce unexpected results if the dictionary contains values that aren't bridgeable to Objective-C. Convert safely, e.g. guard let metadata = usercentricsManager.getDpsMetadata(templateId: templateId) else { resolve(nil); return } then create an NSDictionary via NSDictionary(dictionary: metadata) or use JSONSerialization to ensure bridgeable types. Also consider catching exceptions and rejecting with an error when conversion fails. @objc func getDpsMetadata(_ templateId: String,
resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock) -> Void {
guard let metadata = usercentricsManager.getDpsMetadata(templateId: templateId) else {
resolve(nil)
return
}
// Ensure only bridgeable Foundation types are passed to React Native
if JSONSerialization.isValidJSONObject(metadata) {
resolve(metadata as NSDictionary)
} else {
// Fallback: attempt to sanitize via JSON round‑trip
do {
let data = try JSONSerialization.data(withJSONObject: metadata, options: [])
if let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
resolve(jsonObject as NSDictionary)
} else {
reject("usercentrics_reactNative_getDpsMetadata_error",
"Failed to serialize DPS metadata",
nil)
}
} catch {
reject("usercentrics_reactNative_getDpsMetadata_error",
"Failed to serialize DPS metadata: \(error.localizedDescription)",
error)
}
}
} |
||
|
|
||
| @objc func getAdditionalConsentModeData(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void { | ||
| resolve(usercentricsManager.getAdditionalConsentModeData().toDictionary()) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -564,6 +564,43 @@ class RNUsercentricsModuleTests: XCTestCase { | |
| } | ||
| } | ||
|
|
||
| func testGetDpsMetadataWithValidData() { | ||
| fakeUsercentrics.getDpsMetadataResponse = ["partner": "appsflyer", "source": "campaign_1"] | ||
| module.getDpsMetadata("template123") { result in | ||
| guard let result = result as? NSDictionary else { | ||
| XCTFail() | ||
| return | ||
|
Comment on lines
+570
to
+572
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add assertion messages to Two Suggested patch- guard let result = result as? NSDictionary else {
- XCTFail()
+ guard let result = result as? NSDictionary else {
+ XCTFail("Expected NSDictionary result for non-empty DPS metadata")
return
}
@@
- guard let result = result as? NSDictionary else {
- XCTFail()
+ guard let result = result as? NSDictionary else {
+ XCTFail("Expected NSDictionary result for empty DPS metadata map")
return
}Also applies to: 594-596 🧰 Tools🪛 SwiftLint (0.63.2)[Warning] 571-571: An XCTFail call should include a description of the assertion (xctfail_message) 🤖 Prompt for AI Agents |
||
| } | ||
| XCTAssertEqual("appsflyer", result["partner"] as! String) | ||
| XCTAssertEqual("campaign_1", result["source"] as! String) | ||
| } reject: { _, _, _ in | ||
| XCTFail("Should not go here") | ||
| } | ||
| XCTAssertEqual("template123", fakeUsercentrics.getDpsMetadataTemplateId) | ||
| } | ||
|
|
||
| func testGetDpsMetadataWhenNull() { | ||
| fakeUsercentrics.getDpsMetadataResponse = nil | ||
| module.getDpsMetadata("nonExistent") { result in | ||
| XCTAssertNil(result) | ||
| } reject: { _, _, _ in | ||
| XCTFail("Should not go here") | ||
| } | ||
| } | ||
|
|
||
| func testGetDpsMetadataWithEmptyMap() { | ||
| fakeUsercentrics.getDpsMetadataResponse = [:] | ||
| module.getDpsMetadata("template123") { result in | ||
| guard let result = result as? NSDictionary else { | ||
| XCTFail() | ||
| return | ||
| } | ||
| XCTAssertEqual(0, result.count) | ||
| } reject: { _, _, _ in | ||
| XCTFail("Should not go here") | ||
| } | ||
| } | ||
|
|
||
| func testGetAdditionalConsentModeData() { | ||
| let expected = AdditionalConsentModeData(acString: "2~43.46.55~dv.", | ||
| adTechProviders: [AdTechProvider(id: 43, name: "AdPredictive", privacyPolicyUrl: "https://adpredictive.com/privacy", consent: true)]) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,6 +40,7 @@ export interface Spec extends TurboModule { | |
| getGPPData(): Promise<GppData>; | ||
| getGPPString(): Promise<string | null>; | ||
| getABTestingVariant(): Promise<string>; | ||
| getDpsMetadata(templateId: string): Promise<Record<string, unknown> | null>; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [REFACTORING] Type mismatch/consistency: you added getDpsMetadata(templateId: string): Promise<Record<string, unknown> | null>. The fabric TurboModule spec uses Promise<Object | null>. Align the types across both files to a single, specific shape (prefer Record<string, unknown> | null) to keep type safety consistent and avoid confusion when consuming the API. // src/fabric/NativeUsercentricsModule.ts
export interface Spec extends TurboModule {
// ...existing methods...
- getDpsMetadata(templateId: string): Promise<Object | null>;
+ getDpsMetadata(templateId: string): Promise<Record<string, unknown> | null>;
} |
||
|
|
||
| // Configuration Setters | ||
| setCMPId(id: number): void; | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -100,6 +100,11 @@ export const Usercentrics = { | |||||||||
| return RNUsercentricsModule.getAdditionalConsentModeData(); | ||||||||||
| }, | ||||||||||
|
|
||||||||||
| getDpsMetadata: async (templateId: string): Promise<Record<string, unknown> | null> => { | ||||||||||
| await RNUsercentricsModule.isReady(); | ||||||||||
| return RNUsercentricsModule.getDpsMetadata(templateId); | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: This new call assumes Severity Level: Major
|
||||||||||
| return RNUsercentricsModule.getDpsMetadata(templateId); | |
| if (typeof RNUsercentricsModule.getDpsMetadata !== 'function') { | |
| throw new Error('Usercentrics React Native SDK: getDpsMetadata is not available in this native build.'); | |
| } |
Steps of Reproduction ✅
1. Build iOS with legacy bridge (non-new-architecture); JS resolves module through
fallback at `src/Usercentrics.tsx:25` and `src/NativeUsercentrics.ts:74`
(`TurboModuleRegistry.get(...) || NativeModules.RNUsercentricsModule`).
2. Call exported API `Usercentrics.getDpsMetadata("template123")` (public export at
`src/index.tsx:1`, implementation at `src/Usercentrics.tsx:103-105`).
3. On iOS legacy bridge, `ios/RNUsercentricsModule.mm:10-118` defines exported methods via
`RCT_EXTERN_METHOD(...)`, and this file has no `getDpsMetadata` export entry.
4. Because legacy `NativeModules.RNUsercentricsModule` lacks that function, call site
`src/Usercentrics.tsx:105` invokes `undefined`, causing runtime `TypeError:
...getDpsMetadata is not a function`.Prompt for AI Agent 🤖
This is a comment left during a code review.
**Path:** src/Usercentrics.tsx
**Line:** 105:105
**Comment:**
*Possible Bug: This new call assumes `getDpsMetadata` always exists on the native module, but on iOS legacy bridge builds the method is not exported, so this will throw `undefined is not a function` at runtime. Guard the method before calling it and fail with a controlled error (or safe fallback) instead of invoking an undefined function.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[REFACTORING] Public wrapper getDpsMetadata is added — ensure its returned runtime type is documented in public typings and that callers know it can be null. Also consider normalizing the returned value (e.g. return {} instead of null) only if that matches product requirements; otherwise keep current null semantics but document them.
// src/Usercentrics.tsx
export interface UsercentricsPublicAPI {
// ...existing methods
/**
* Returns DPS metadata for the given template.
*
* When metadata is available, a flat key/value object is returned.
* When no metadata exists for the given templateId, this resolves to `null`.
*/
getDpsMetadata(templateId: string): Promise<Record<string, unknown> | null>;
}
export const Usercentrics: UsercentricsPublicAPI = {
// ...existing methods
getDpsMetadata: async (
templateId: string,
): Promise<Record<string, unknown> | null> => {
await RNUsercentricsModule.isReady();
const metadata = await RNUsercentricsModule.getDpsMetadata(templateId);
// keep native null semantics; callers must handle `null`
return metadata;
},
};| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,6 +61,7 @@ jest.mock("react-native", () => { | |
| setGPPConsent: jest.fn(), | ||
| track: jest.fn(), | ||
| reset: jest.fn(), | ||
| getDpsMetadata: jest.fn(), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [VALIDATION] You added getDpsMetadata to the NativeModules mock. The JS implementation awaits RNUsercentricsModule.isReady() before calling the method; please ensure RNUsercentricsModule.isReady is mocked to a resolved value (e.g. jest.fn().mockResolvedValue(undefined)) in tests that call getDpsMetadata so the await doesn't produce surprising behavior. Also, if your production code prefers TurboModuleRegistry.get over NativeModules, make sure tests mock the TurboModule path as well (or explicitly import the same module path your code uses). // At top-level in src/__tests__/index.test.ts, after defining RNUsercentricsModule mock
beforeEach(() => {
RNUsercentricsModule.isReady.mockResolvedValue(undefined);
});
// Or, inside each DPS metadata test:
RNUsercentricsModule.isReady.mockResolvedValueOnce(undefined);
// Additionally, if your production code ever switches to TurboModuleRegistry:
jest.mock('../fabric/NativeUsercentricsModule', () => {
return {
__esModule: true,
default: {
isReady: jest.fn().mockResolvedValue(undefined),
getDpsMetadata: jest.fn(),
// ...other mocked methods as needed
},
};
}); |
||
| clearUserSession: jest.fn(), | ||
| addListener: jest.fn(), | ||
| removeListeners: jest.fn() | ||
|
|
@@ -455,6 +456,37 @@ describe('Test Usercentrics Module', () => { | |
| expect(data).toStrictEqual(response) | ||
| }) | ||
|
|
||
| test('testGetDpsMetadataWithValidData', async () => { | ||
| const metadata = { partner: "appsflyer", source: "campaign_1" }; | ||
| RNUsercentricsModule.getDpsMetadata.mockImplementationOnce( | ||
| (): Promise<any> => Promise.resolve(metadata) | ||
| ) | ||
|
|
||
| const data = await Usercentrics.getDpsMetadata("templateId123"); | ||
| expect(data).toStrictEqual(metadata); | ||
|
|
||
| const call = RNUsercentricsModule.getDpsMetadata.mock.calls[0][0]; | ||
| expect(call).toBe("templateId123"); | ||
| }) | ||
|
|
||
| test('testGetDpsMetadataWhenNull', async () => { | ||
| RNUsercentricsModule.getDpsMetadata.mockImplementationOnce( | ||
| (): Promise<any> => Promise.resolve(null) | ||
| ) | ||
|
|
||
| const data = await Usercentrics.getDpsMetadata("nonExistentId"); | ||
| expect(data).toBe(null); | ||
| }) | ||
|
|
||
| test('testGetDpsMetadataWithEmptyObject', async () => { | ||
| RNUsercentricsModule.getDpsMetadata.mockImplementationOnce( | ||
| (): Promise<any> => Promise.resolve({}) | ||
| ) | ||
|
|
||
| const data = await Usercentrics.getDpsMetadata("templateId123"); | ||
| expect(data).toStrictEqual({}); | ||
| }) | ||
|
|
||
| test('testClearUserSession', async () => { | ||
| const readyStatus = new UsercentricsReadyStatus( | ||
| true, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ export interface Spec extends TurboModule { | |
| getGPPData(): Promise<Object>; | ||
| getGPPString(): Promise<string | null>; | ||
| getABTestingVariant(): Promise<string>; | ||
| getDpsMetadata(templateId: string): Promise<Object | null>; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [REFACTORING] Use a consistent, explicit type for the new method signature instead of generic Object. Change getDpsMetadata(templateId: string): Promise<Object | null> to use the same type as the JS spec (e.g. Promise<Record<string, unknown> | null>) so TS types match between the TurboModule spec and the public TS declaration. // src/fabric/NativeUsercentricsModule.ts
export interface Spec extends TurboModule {
// ...
getABTestingVariant(): Promise<string>;
getDpsMetadata(templateId: string): Promise<Record<string, unknown> | null>;
// ...
} |
||
|
|
||
| // Configuration Setters | ||
| setCMPId(id: number): void; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1. Metadata serialization loses fields
🐞 Bug✓ CorrectnessAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools