Skip to content

Commit

Permalink
feat(consent): add method that returns consent choices
Browse files Browse the repository at this point in the history
  • Loading branch information
birgernass authored and mikehardy committed Apr 16, 2022
1 parent e0f53b0 commit be967bd
Show file tree
Hide file tree
Showing 15 changed files with 631 additions and 32 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ module.exports = {
},
globals: {
__DEV__: true,
console: true,
should: true,
Utils: true,
window: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,20 @@
* limitations under the License.
*
*/

import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableMap;
import com.google.android.ump.ConsentDebugSettings;
import com.google.android.ump.ConsentInformation;
import com.google.android.ump.ConsentRequestParameters;
import com.google.android.ump.UserMessagingPlatform;
import io.invertase.googlemobileads.common.ReactNativeModule;
import java.util.List;
import javax.annotation.Nonnull;

public class ReactNativeGoogleMobileAdsConsentModule extends ReactNativeModule {
Expand Down Expand Up @@ -63,10 +64,10 @@ public void requestInfoUpdate(@Nonnull final ReadableMap options, final Promise
new ConsentDebugSettings.Builder(getApplicationContext());

if (options.hasKey("testDeviceIdentifiers")) {
List<Object> devices = options.getArray("testDeviceIdentifiers").toArrayList();
ReadableArray devices = options.getArray("testDeviceIdentifiers");

for (Object device : devices) {
debugSettingsBuilder.addTestDeviceHashedId((String) device);
for (int i = 0; i < devices.size(); i++) {
debugSettingsBuilder.addTestDeviceHashedId(devices.getString(i));
}
}

Expand Down Expand Up @@ -152,4 +153,17 @@ public void showForm(final Promise promise) {
public void reset() {
consentInformation.reset();
}

@ReactMethod
public void getTCString(Promise promise) {
try {
SharedPreferences prefs =
PreferenceManager.getDefaultSharedPreferences(getReactApplicationContext());
// https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md#in-app-details
String tcString = prefs.getString("IABTCF_TCString", null);
promise.resolve(tcString);
} catch (Exception e) {
rejectPromiseWithCodeAndMessage(promise, "consent-string-error", e.toString());
}
}
}
32 changes: 32 additions & 0 deletions docs/european-user-consent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,38 @@ if (consentInfo.isConsentFormAvailable && consentInfo.status === AdsConsentStatu

> Do not persist the status. You could however store this locally in application state (e.g. React Context) and update the status on every app launch as it may change.
### Inspecting consent choices

The AdsConsentStatus tells you if you should show the modal to a user or not. Often times you want to run logic based on the user's choices though.
Especially since the Google Mobile Ads SDK won't show any ads if the user didn't give consent to store and/or access information on the device.
This library exports a method that returns some of the most relevant consent flags to handle common use cases.

```js
import { AdsConsent } from 'react-native-google-mobile-ads';

const {
activelyScanDeviceCharacteristicsForIdentification,
applyMarketResearchToGenerateAudienceInsights,
createAPersonalisedAdsProfile,
createAPersonalisedContentProfile,
developAndImproveProducts,
measureAdPerformance,
measureContentPerformance,
selectBasicAds,
selectPersonalisedAds,
selectPersonalisedContent,
storeAndAccessInformationOnDevice,
usePreciseGeolocationData,
} = await AdsConsent.getUserChoices();

if (storeAndAccessInformationOnDevice === false) {
/**
* The user declined consent for purpose 1,
* the Google Mobile Ads SDK won't serve ads.
*/
}
```

### Testing

When developing the consent flow, the behavior of the `AdsConsent` responses may not be reliable due to the environment
Expand Down
11 changes: 10 additions & 1 deletion docs/migrating-to-v5.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ Please refer to the following links for more information:

Previously it was possible to request the ad providers and to update the consent status. This is no longer the case.
Also, while the old Consent SDK provided information about user preferences, the new `AdsConsentStatus` only tells you if you should show the modal to a user or not.
You can read the `IABTCF_TCString` key from standardUserDefaults / SharedPreferences and decode it with a library like [@iabtcf/core](https://github.com/InteractiveAdvertisingBureau/iabtcf-es/tree/master/modules/core#iabtcfcore) though.

- `requestInfoUpdate` does now accept an optional `AdsConsentInfoOptions` object instead of expecting `publisherIds` and returns a changed `AdsConsentInfo` interface
- `showForm` does not expect any parameters any longer and now only returns the changed `AdsConsentStatus` as part of it's `AdsConsentFormResult` interface
- `getAdProviders`, `getStatus` and `setStatus` methods were removed without replacements
- `addTestDevices`, `setDebugGeography` and `setTagForUnderAgeOfConsent` methods were removed, but their functionality is available via `AdsConsentInfoOptions`
- the `user_tracking_usage_description` key is needed in your project's `app.json` if you want to handle Apple's App Tracking Transparency
- newly added `getUserChoices` can be used to inspect some of the consent choices

```diff
{
Expand Down Expand Up @@ -50,5 +50,14 @@ You can read the `IABTCF_TCString` key from standardUserDefaults / SharedPrefere
- withAdFree: true,
- });
+ const formResult = await AdsConsent.showForm()
+
+ const { storeAndAccessInformationOnDevice } = await AdsConsent.getUserChoices();
+
+ if (storeAndAccessInformationOnDevice === false) {
+ /**
+ * The user declined consent for purpose 1,
+ * the Google Mobile Ads SDK won't serve ads.
+ */
+ }
}
```
8 changes: 8 additions & 0 deletions e2e/consent.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ describe('googleAds AdsConsent', function () {
});
});

describe('getUserChoices', function () {
it('returns consent choices', async function () {
const choices = await AdsConsent.getUserChoices();
choices.storeAndAccessInformationOnDevice.should.be.Boolean();
choices.usePreciseGeolocationData.should.be.Boolean();
});
});

describe('reset', function () {
it('resets', function () {
AdsConsent.reset();
Expand Down
12 changes: 6 additions & 6 deletions example/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ import {
import {Test, TestRegistry, TestResult, TestRunner, TestType} from 'jet';

import {
AdEventType,
AdsConsent,
AdsConsentDebugGeography,
AdsConsentStatus,
AdEventType,
AppOpenAd,
InterstitialAd,
TestIds,
Expand Down Expand Up @@ -253,11 +252,12 @@ class AdConsentTest implements Test {
testDeviceIdentifiers: [],
});

if (
consentInfo.isConsentFormAvailable &&
consentInfo.status === AdsConsentStatus.REQUIRED
) {
if (consentInfo.isConsentFormAvailable) {
await AdsConsent.showForm();

const choices = await AdsConsent.getUserChoices();

console.log(JSON.stringify(choices, null, 2));
}
}}
/>
Expand Down
5 changes: 5 additions & 0 deletions example/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,11 @@
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45"
integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==

"@iabtcf/core@^1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@iabtcf/core/-/core-1.4.0.tgz#b5cd782ba86da44efaad8f26c909ec6ea68dc799"
integrity sha512-LfyscG/rNhw542MX4pNSBPzK3ddHDYB8cz7pNTOI6D664x7jJmVZjkRmw8X/h9yGQ2c0a/1AP9F8n5/UsxKjeA==

"@istanbuljs/load-nyc-config@^1.0.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced"
Expand Down
14 changes: 14 additions & 0 deletions ios/RNGoogleMobileAds/RNGoogleMobileAdsConsentModule.m
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,18 @@ - (NSString *)getConsentStatusString:(UMPConsentStatus)consentStatus {

RCT_EXPORT_METHOD(reset) { [UMPConsentInformation.sharedInstance reset]; }

RCT_EXPORT_METHOD(getTCString : (RCTPromiseResolveBlock)resolve : (RCTPromiseRejectBlock)reject) {
@try {
// https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md#in-app-details
NSString *tcString = [[NSUserDefaults standardUserDefaults] objectForKey:@"IABTCF_TCString"];
resolve(tcString);
} @catch (NSError *error) {
[RNSharedUtils rejectPromiseWithUserInfo:reject
userInfo:[@{
@"code" : @"consent-string-error",
@"message" : error.localizedDescription,
} mutableCopy]];
}
}

@end
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@
"tests:ios:test-cover-reuse": "cd example && node_modules/.bin/nyc yarn detox test --configuration ios.sim.debug --reuse --loglevel warn",
"tests:ios:pod:install": "cd example && cd ios && rm -rf example.xcworkspace && rm -f Podfile.lock && pod install --repo-update && cd .."
},
"dependencies": {
"@iabtcf/core": "^1.4.0",
"use-deep-compare-effect": "^1.8.1"
},
"devDependencies": {
"@babel/core": "^7.16.0",
"@babel/preset-env": "^7.16.4",
Expand Down
95 changes: 75 additions & 20 deletions src/AdsConsent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,24 @@
*
*/

import { hasOwnProperty, isArray, isBoolean, isObject, isString } from './common';
import { TCModel, TCString } from '@iabtcf/core';
import { NativeModules } from 'react-native';
import { AdsConsentDebugGeography } from './AdsConsentDebugGeography';
import { AdsConsentInterface } from './types/AdsConsent.interface';
import { AdsConsentPurposes } from './AdsConsentPurposes';
import { AdsConsentSpecialFeatures } from './AdsConsentSpecialFeatures';
import { hasOwnProperty, isArray, isBoolean, isObject, isString } from './common';
import {
AdsConsentFormResult,
AdsConsentInfo,
AdsConsentInfoOptions,
AdsConsentInterface,
AdsConsentUserChoices,
} from './types/AdsConsent.interface';

const native = NativeModules.RNGoogleMobileAdsConsentModule;

export const AdsConsent: AdsConsentInterface = {
/**
*
* @param {Object} [options]
* @param {AdsConsentDebugGeography} [options.debugGeography]
* @param {Boolean} [options.tagForUnderAgeOfConsent]
* @param {Array<String>} [options.testDeviceIdentifiers]
* @returns {{ status: Number, isConsentFormAvailable: Boolean }}
*/
requestInfoUpdate(options = {}) {
requestInfoUpdate(options: AdsConsentInfoOptions = {}): Promise<AdsConsentInfo> {
if (!isObject(options)) {
throw new Error("AdsConsent.requestInfoUpdate(*) 'options' expected an object value.");
}
Expand Down Expand Up @@ -75,18 +76,72 @@ export const AdsConsent: AdsConsentInterface = {
return native.requestInfoUpdate(options);
},

/**
*
* @returns {{ status: Number }}
*/
showForm() {
showForm(): Promise<AdsConsentFormResult> {
return native.showForm();
},

/**
*
*/
reset() {
reset(): void {
return native.reset();
},

getTCString(): Promise<string> {
return native.getTCString();
},

async getTCModel(): Promise<TCModel> {
const tcString = await native.getTCString();
return TCString.decode(tcString);
},

async getUserChoices(): Promise<AdsConsentUserChoices> {
const tcString = await native.getTCString();

let tcModel: TCModel;

try {
tcModel = TCString.decode(tcString);
} catch (e) {
tcModel = new TCModel();

if (__DEV__) {
// eslint-disable-next-line no-console
console.warn(`Failed to decode tcString ${tcString}:`, e);
}
}

return {
activelyScanDeviceCharacteristicsForIdentification: tcModel.specialFeatureOptins.has(
AdsConsentSpecialFeatures.ACTIVELY_SCAN_DEVICE_CHARACTERISTICS_FOR_IDENTIFICATION,
),
applyMarketResearchToGenerateAudienceInsights: tcModel.purposeConsents.has(
AdsConsentPurposes.APPLY_MARKET_RESEARCH_TO_GENERATE_AUDIENCE_INSIGHTS,
),
createAPersonalisedAdsProfile: tcModel.purposeConsents.has(
AdsConsentPurposes.CREATE_A_PERSONALISED_ADS_PROFILE,
),
createAPersonalisedContentProfile: tcModel.purposeConsents.has(
AdsConsentPurposes.CREATE_A_PERSONALISED_ADS_PROFILE,
),
developAndImproveProducts: tcModel.purposeConsents.has(
AdsConsentPurposes.DEVELOP_AND_IMPROVE_PRODUCTS,
),
measureAdPerformance: tcModel.purposeConsents.has(AdsConsentPurposes.MEASURE_AD_PERFORMANCE),
measureContentPerformance: tcModel.purposeConsents.has(
AdsConsentPurposes.MEASURE_CONTENT_PERFORMANCE,
),
selectBasicAds: tcModel.purposeConsents.has(AdsConsentPurposes.SELECT_BASIC_ADS),
selectPersonalisedAds: tcModel.purposeConsents.has(
AdsConsentPurposes.SELECT_PERSONALISED_ADS,
),
selectPersonalisedContent: tcModel.purposeConsents.has(
AdsConsentPurposes.SELECT_PERSONALISED_CONTENT,
),
storeAndAccessInformationOnDevice: tcModel.purposeConsents.has(
AdsConsentPurposes.STORE_AND_ACCESS_INFORMATION_ON_DEVICE,
),
usePreciseGeolocationData: tcModel.specialFeatureOptins.has(
AdsConsentSpecialFeatures.USE_PRECISE_GEOLOCATION_DATA,
),
};
},
};

0 comments on commit be967bd

Please sign in to comment.