diff --git a/.commitmsg b/.commitmsg new file mode 100644 index 0000000..eba5d2a --- /dev/null +++ b/.commitmsg @@ -0,0 +1,29 @@ +feat: support Amazon DSP content URL on bid requests + +Amazon DSP now requires in-app bid requests to carry the public web URL of the +content being viewed, so AmazonAdBot can crawl the page and verify what +surrounds the ad. Inventory it cannot verify progressively loses eligibility +with Amazon advertisers. + +Adds an optional `contentUrl` to AdLoaderOptions, applied per bid request. + +iOS: forwarded to +[APS setContentUrl:], called immediately before loadAd and +inside the same native method. That setter is a class method, so the value is +process-global; setting it there means another slot cannot overwrite it between +the two, since React Native serialises module methods on a single queue. An +empty value is skipped rather than forwarded — the SDK header states it throws +an NSException in development builds otherwise. + +Android: ignored, and documented as such. The Android APS SDK +(com.amazon.android:aps-sdk 11.1.1) exposes no equivalent of setContentUrl — +verified against every public member of AdRegistration and DTBAdRequest. +Rejecting the promise would break the bidding chain for callers that +legitimately pass the option for iOS, so the option is accepted and dropped. + +Two known limitations are documented in the README and have been raised with +Amazon APS: the Android gap above, and the fact that the iOS setter is sticky — +it cannot be cleared, so the last URL set stays attached to later requests until +another one replaces it. + +Validation rejects a non-string contentUrl, consistent with the other options. +36 tests green. diff --git a/README.md b/README.md index fc45104..d3b556c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,45 @@ - [Guides](https://AdversportTeam.github.io/react-native-aps/docs/guides) - [API Reference](https://AdversportTeam.github.io/react-native-aps/docs/api) +## Content URL (Amazon DSP) + +Amazon DSP requires in-app bid requests to carry the public web URL of the +content the user is viewing, so that AmazonAdBot can crawl the page and verify +what surrounds the ad. Inventory it cannot verify progressively loses +eligibility with Amazon advertisers. + +Pass it per bid request through `contentUrl`: + +```ts +const adLoader = AdLoader.createBannerAdLoader({ + slotUUID: 'your-slot-uuid', + size: '320x50', + contentUrl: 'https://yourdomain.com/article/12345', +}); +``` + +The URL must match the in-app content — not a deep link, not a store URL, not +the app home page — and must be publicly reachable. Omit the option entirely +when you have no reliable URL: a missing field is neutral, a wrong one is an +active non-compliance signal. + +### Platform support + +| Platform | Behaviour | +| --- | --- | +| iOS | Applied through `+[APS setContentUrl:]`, immediately before `loadAd` and inside the same native call. | +| Android | **Ignored.** The Android APS SDK exposes no equivalent. | + +Two caveats worth knowing on iOS: + +- `+[APS setContentUrl:]` is a class method, so the value is process-global and + **sticky** — it cannot be cleared, and the last URL set stays attached to + subsequent requests until another one replaces it. +- The SDK throws an `NSException` in development builds when given an empty or + nil value, so an empty `contentUrl` is skipped rather than forwarded. + +Both the Android gap and the iOS stickiness have been raised with Amazon APS. + ## Contributing See the [contributing guide](CONTRIBUTING.md) to learn how to contribute to the repository and the development workflow. diff --git a/android/src/main/java/com/adversport/rnaps/RNAPSAdLoaderModule.java b/android/src/main/java/com/adversport/rnaps/RNAPSAdLoaderModule.java index 7412c69..65e1ffb 100644 --- a/android/src/main/java/com/adversport/rnaps/RNAPSAdLoaderModule.java +++ b/android/src/main/java/com/adversport/rnaps/RNAPSAdLoaderModule.java @@ -254,6 +254,18 @@ public void loadAd(int loaderId, String adType, ReadableMap options, Promise pro } adLoaders.put(loaderId, adLoader); + // NOTE: options.contentUrl is intentionally ignored here. + // + // Amazon DSP asks for the public web URL of the content being viewed, but + // the Android APS SDK (com.amazon.android:aps-sdk 11.1.1) exposes no + // equivalent of the iOS +[APS setContentUrl:] — verified by inspecting every + // public member of AdRegistration and DTBAdRequest. There is no supported + // way to attach it to an Android bid request today. + // + // Ignoring it is deliberate: rejecting the promise would break the whole + // bidding chain for callers that legitimately pass the option for iOS. The + // limitation has been raised with Amazon APS; revisit when they ship an API. + adLoader.loadAd(new AdCallback(loaderId, promise)); } diff --git a/ios/RNAPS/RNAPSAdLoaderModule.swift b/ios/RNAPS/RNAPSAdLoaderModule.swift index 6d19ec9..a0329eb 100644 --- a/ios/RNAPS/RNAPSAdLoaderModule.swift +++ b/ios/RNAPS/RNAPSAdLoaderModule.swift @@ -192,6 +192,25 @@ class RNAPSAdLoaderModule: RCTEventEmitter { adLoader.setAutoRefresh(refreshInterval) } + // Amazon DSP requires the public web URL of the content being viewed, so + // that AmazonAdBot can crawl it and verify the surroundings of the ad. + // + // +[APS setContentUrl:] is a CLASS method, i.e. process-global state. We set + // it here, immediately before loadAd and inside the same native call, so it + // cannot be overwritten by another slot between the two — React Native + // serialises module methods on a single queue. + // + // Empty or nil is skipped on purpose: the SDK header states it "will throw + // an NSException in development" in that case. Callers with no reliable URL + // simply omit the option. + // + // Known limitation: the setter is sticky and cannot be cleared, so the last + // URL set stays attached to subsequent requests until another one replaces + // it. Raised with Amazon APS. + if let contentUrl = options["contentUrl"] as? String, !contentUrl.isEmpty { + APS.setContentUrl(contentUrl) + } + adLoaders.updateValue(adLoader, forKey: loaderId) adLoader.loadAd(AdLoadCallback(adLoaderModule: self, loaderId: loaderId, resolve: resolve, reject: reject)) } diff --git a/package.json b/package.json index e2dbe3d..50e4f14 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "react-native-aps", "packageManager": "yarn@4.3.1", - "version": "2.2.1", + "version": "2.3.0", "author": "AdversportTeam (https://github.com/AdversportTeam)", "contributors": [ "Jay Kim (https://github.com/wjaykim)" diff --git a/src/__tests__/AdLoader.test.ts b/src/__tests__/AdLoader.test.ts index f6d6467..8629bf7 100644 --- a/src/__tests__/AdLoader.test.ts +++ b/src/__tests__/AdLoader.test.ts @@ -55,6 +55,50 @@ describe('AdLoader', function () { }); }); + describe('contentUrl', function () { + it('throws if contentUrl is not a string', function () { + expect(() => + AdLoader.createBannerAdLoader({ + slotUUID: 'uuid', + size: '320x50', + // @ts-ignore + contentUrl: 123, + }) + ).toThrowError( + "AdLoader.createBannerAdLoader(*) 'adLoaderOptions.contentUrl' expected a string value" + ); + }); + + it('accepts a valid contentUrl and keeps it on the loader options', function () { + const adLoader = AdLoader.createBannerAdLoader({ + slotUUID: 'uuid', + size: '320x50', + contentUrl: 'https://www.example.com/article/12345', + }); + expect(adLoader.adLoaderOptions.contentUrl).toBe( + 'https://www.example.com/article/12345' + ); + }); + + it('is optional — omitting it stays valid', function () { + const adLoader = AdLoader.createBannerAdLoader({ + slotUUID: 'uuid', + size: '320x50', + }); + expect(adLoader.adLoaderOptions.contentUrl).toBeUndefined(); + }); + + it('is accepted on interstitial loaders too', function () { + const adLoader = AdLoader.createInterstitialAdLoader({ + slotUUID: 'uuid', + contentUrl: 'https://www.example.com/article/12345', + }); + expect(adLoader.adLoaderOptions.contentUrl).toBe( + 'https://www.example.com/article/12345' + ); + }); + }); + describe('addListener', function () { const adLoader = AdLoader.createBannerAdLoader({ slotUUID: TestIds.APS_SLOT_BANNER_320x50, diff --git a/src/types/AdLoaderOptions.ts b/src/types/AdLoaderOptions.ts index ec13c6e..09db851 100644 --- a/src/types/AdLoaderOptions.ts +++ b/src/types/AdLoaderOptions.ts @@ -25,6 +25,20 @@ export interface AdLoaderOptions { * The optional custom targeting key value pairs for the bid request. */ customTargeting?: { [key: string]: string }; + /** + * The public web URL of the content the user is currently viewing. + * + * Amazon DSP requires in-app bid requests to carry it so that AmazonAdBot can + * crawl the page and verify the content surrounding the ad. Inventory it + * cannot verify progressively loses eligibility with Amazon advertisers. + * + * It must be the web URL matching the in-app content — not a deep link, not a + * store URL, not the app home page — and it must be publicly reachable. + * + * iOS only: the Android APS SDK exposes no equivalent, where the value is + * ignored. See the README for the current status of that limitation. + */ + contentUrl?: string; } export interface BannerAdLoaderOptions extends AdLoaderOptions { @@ -53,6 +67,12 @@ export function validateAdLoaderOptions(adLoaderOptions: AdLoaderOptions) { if (typeof adLoaderOptions.slotUUID !== 'string') { throw new Error("'adLoaderOptions.slotUUID' expected a string value"); } + if ( + adLoaderOptions.contentUrl !== undefined && + typeof adLoaderOptions.contentUrl !== 'string' + ) { + throw new Error("'adLoaderOptions.contentUrl' expected a string value"); + } } export function validateBannerAdLoaderOptions(