Skip to content

Commit 85e3f78

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@5a2a97d0f4016c91c8d8ee0f8a85d7aba237e75d
1 parent 190f694 commit 85e3f78

9 files changed

Lines changed: 313 additions & 49 deletions

File tree

‎bun.lock‎

Lines changed: 47 additions & 45 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, test } from 'bun:test'
2+
3+
import { adSignalsAllowed, readAdSignalsConsent } from '../android-ad-signals'
4+
5+
describe('adSignalsAllowed', () => {
6+
test('outside the region, collected unless denied', () => {
7+
expect(adSignalsAllowed({ country: 'US', consent: 'unknown' })).toBe(true)
8+
expect(adSignalsAllowed({ country: 'us', consent: 'unknown' })).toBe(true)
9+
expect(adSignalsAllowed({ country: 'US', consent: 'denied' })).toBe(false)
10+
})
11+
12+
test('inside the region or unresolved, only granted collects', () => {
13+
for (const country of ['FR', 'GB', 'CH', 'IS', null, '']) {
14+
expect(adSignalsAllowed({ country, consent: 'unknown' })).toBe(false)
15+
expect(adSignalsAllowed({ country, consent: 'granted' })).toBe(true)
16+
}
17+
})
18+
19+
test('the header reads only the two explicit choices', () => {
20+
expect(readAdSignalsConsent('Granted ')).toBe('granted')
21+
expect(readAdSignalsConsent('denied')).toBe('denied')
22+
expect(readAdSignalsConsent('yes')).toBe('unknown')
23+
expect(readAdSignalsConsent(null)).toBe('unknown')
24+
})
25+
})
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* The consent rule for the Android app's extra ad signals: the bucketed
3+
* `client_context` it sends with an auction and the `landing` block of its
4+
* engagement record. Serving an ad and counting its impression and click are
5+
* not covered by this rule; they run for everyone.
6+
*
7+
* Outside the EEA, the UK and Switzerland the signals are collected without a
8+
* prompt (disclosed in the Play Data Safety form and the privacy policy).
9+
* Inside them, reading device state for advertising needs the user's consent
10+
* (ePrivacy Art. 5(3)), so the app asks once and sends nothing until the user
11+
* agrees. An unresolved country counts as inside: not knowing where someone
12+
* is is not a basis for collecting.
13+
*
14+
* The app applies the rule before it reads anything; the server applies it
15+
* again by the edge's country, so an old build or a wrong SIM hint cannot
16+
* store what the rule forbids.
17+
*/
18+
19+
/** The app's report of the user's choice. Absent means `unknown`. */
20+
export const AD_SIGNALS_CONSENT_HEADER = 'x-freebuff-ad-signals'
21+
export const AD_SIGNALS_CONSENT_VALUES = [
22+
'granted',
23+
'denied',
24+
'unknown',
25+
] as const
26+
export type AdSignalsConsent = (typeof AD_SIGNALS_CONSENT_VALUES)[number]
27+
28+
/**
29+
* EEA (27) + the EFTA members of the EEA (IS, LI, NO) + UK + CH, ISO 3166-1
30+
* alpha-2 upper case. The same set the placements identity match gates on
31+
* (`IDENTITY_GATED_COUNTRIES`); kept here because `common` cannot import the
32+
* web app.
33+
*/
34+
export const AD_SIGNALS_CONSENT_COUNTRIES: ReadonlySet<string> = new Set([
35+
'AT',
36+
'BE',
37+
'BG',
38+
'HR',
39+
'CY',
40+
'CZ',
41+
'DK',
42+
'EE',
43+
'FI',
44+
'FR',
45+
'DE',
46+
'GR',
47+
'HU',
48+
'IE',
49+
'IT',
50+
'LV',
51+
'LT',
52+
'LU',
53+
'MT',
54+
'NL',
55+
'PL',
56+
'PT',
57+
'RO',
58+
'SK',
59+
'SI',
60+
'ES',
61+
'SE',
62+
'IS',
63+
'LI',
64+
'NO',
65+
'GB',
66+
'CH',
67+
])
68+
69+
export function readAdSignalsConsent(
70+
value: string | null | undefined,
71+
): AdSignalsConsent {
72+
const normalized = value?.trim().toLowerCase()
73+
return normalized === 'granted' || normalized === 'denied'
74+
? normalized
75+
: 'unknown'
76+
}
77+
78+
/**
79+
* Whether the extra signals may be collected and stored. A `denied` choice is
80+
* honoured everywhere; inside the consent region (or with no country) only
81+
* `granted` allows them.
82+
*/
83+
export function adSignalsAllowed(params: {
84+
country: string | null | undefined
85+
consent: AdSignalsConsent
86+
}): boolean {
87+
if (params.consent === 'denied') return false
88+
if (params.consent === 'granted') return true
89+
const country = params.country?.trim().toUpperCase()
90+
if (!country) return false
91+
return !AD_SIGNALS_CONSENT_COUNTRIES.has(country)
92+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/** Native inventory has its own attribution; never alias it to a web or iOS slot. */
2+
export const ANDROID_AD_SURFACE = 'android' as const
3+
export const ANDROID_AD_PLACEMENTS = [
4+
{
5+
id: 'Android-Chat-After-Assistant-Message',
6+
label: 'Chat · after an answer',
7+
},
8+
{
9+
id: 'Android-Coding-After-Assistant-Message',
10+
label: 'Coding · after an answer',
11+
},
12+
] as const
13+
14+
export function isAndroidAdPlacement(id: string | null | undefined): boolean {
15+
return ANDROID_AD_PLACEMENTS.some((slot) => slot.id === id)
16+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { isAndroidAdPlacement } from './android-placements'
2+
import { isIOSAdPlacement } from './ios-placements'
3+
4+
export type MobileAdSurface = 'ios' | 'android'
5+
6+
/** The native app that owns a placement, or null for every web/desktop slot. */
7+
export function mobileAdPlacementSurface(
8+
id: string | null | undefined,
9+
): MobileAdSurface | null {
10+
if (isIOSAdPlacement(id)) return 'ios'
11+
if (isAndroidAdPlacement(id)) return 'android'
12+
return null
13+
}
14+
15+
export function isMobileAdPlacement(id: string | null | undefined): boolean {
16+
return mobileAdPlacementSurface(id) !== null
17+
}

‎common/src/constants/__tests__/freebuff-house-ad.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ describe('house ad catalog', () => {
172172
expect(HOUSE_AD_DISPLAY_CREATIVE).toBe(HOUSE_AD_DISPLAY_VARIATIONS[0]!)
173173
})
174174

175-
test('every sellable surface has house copy except the explicit iOS opt-out', () => {
175+
test('every sellable surface has house copy except the explicit phone opt-outs', () => {
176176
// The seed script groups slots by surface and skips a surface it has no
177177
// creatives for. Without this, adding a slot on a new surface would leave
178178
// that surface out of the campaign silently -- the run would report
@@ -183,9 +183,9 @@ describe('house ad catalog', () => {
183183
),
184184
)
185185
for (const surface of surfacesWithSlots) {
186-
// iOS deliberately has no automatic subscription promotion or seeded
187-
// house campaign. Mobile inventory requires a separate, explicit rollout.
188-
if (surface === 'ios') {
186+
// The phones deliberately have no automatic subscription promotion or
187+
// seeded house campaign. Mobile inventory is an explicit campaign choice.
188+
if (surface === 'ios' || surface === 'android') {
189189
expect(Object.hasOwn(HOUSE_AD_VARIATIONS, surface)).toBe(false)
190190
continue
191191
}

‎common/src/constants/freebuff-placements.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import {
1919

2020
import { AD_CAMPAIGN_STATUSES } from './freebuff-ads'
2121
import { IOS_AD_PLACEMENTS, IOS_AD_SURFACE } from '../ads/ios-placements'
22+
import {
23+
ANDROID_AD_PLACEMENTS,
24+
ANDROID_AD_SURFACE,
25+
} from '../ads/android-placements'
2226

2327
import type { AdCampaignStatus } from './freebuff-ads'
2428

@@ -266,6 +270,12 @@ const ORIGINAL_PLACEMENT_SLOTS = [
266270
available: true,
267271
format: 'inline' as const,
268272
})),
273+
...ANDROID_AD_PLACEMENTS.map(({ id }) => ({
274+
id,
275+
surface: ANDROID_AD_SURFACE,
276+
available: true,
277+
format: 'inline' as const,
278+
})),
269279
{
270280
id: 'waiting-room-1',
271281
surface: 'waiting_room',

‎common/src/types/__tests__/ad-client-context.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,3 +712,39 @@ describe('wave 2 collector helpers', () => {
712712
expect(gpuVendorOf('0x5143')).toBe('qualcomm')
713713
})
714714
})
715+
716+
describe('Android fields', () => {
717+
test('a full android block parses', () => {
718+
const android = {
719+
powerSave: false,
720+
charging: true,
721+
battery: '50-80',
722+
dataSaver: false,
723+
metered: true,
724+
orientation: 'portrait',
725+
windowClass: 'compact',
726+
multiWindow: false,
727+
keyboard: true,
728+
hardwareKeyboard: false,
729+
entry: 'notification',
730+
installSource: 'play',
731+
standby: 'active',
732+
notifications: true,
733+
lowRam: false,
734+
fontScale: '1',
735+
}
736+
expect(parseAdClientContext({ v: 1, android })?.android).toEqual(android)
737+
})
738+
739+
test('an unknown android value drops the whole context', () => {
740+
expect(parseAdClientContext({ v: 1, android: { entry: 'widget' } })).toBeUndefined()
741+
})
742+
743+
test('landing parses and is bounded', () => {
744+
const base = { v: 1, impUrl: 'https://freebuff.com/api/ads/first-party/impression/x' }
745+
const landing = { source: 'custom_tabs', loaded: true, loadMs: 800, maxScrollPct: 75, interacted: false, visibleMs: 30_000 }
746+
expect(parseAdEngagement({ ...base, landing })?.landing).toEqual(landing)
747+
expect(parseAdEngagement({ ...base, landing: { maxScrollPct: 101 } })).toBeUndefined()
748+
expect(parseAdEngagement({ ...base, landing: { source: 'webview' } })).toBeUndefined()
749+
})
750+
})

‎common/src/types/ad-client-context.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,24 @@ export const EDIT_RATIO_BUCKETS = ['<5%', '5-15%', '15-30%', '30%+'] as const
239239
/** rendered ad card size, CSS pixels */
240240
export const CARD_DIMENSION_BUCKETS = ['<120', '120-300', '300-600', '600+'] as const
241241

242+
// Android. Only values the app reads without a runtime permission, and never one that names the device: no
243+
// advertising id, installed-app list, usage stats, location, sensors or accessibility state
244+
// (docs/freebuff-android.md, "Ad signals").
245+
export const BATTERY_BUCKETS = ['<15', '15-50', '50-80', '80+'] as const
246+
export const ORIENTATIONS = ['portrait', 'landscape'] as const
247+
/** Material window size class of the app window, by width */
248+
export const WINDOW_CLASSES = ['compact', 'medium', 'expanded'] as const
249+
/** what brought the app to the foreground for this session */
250+
export const ANDROID_ENTRIES = ['launcher', 'notification', 'share', 'deep_link', 'recents', 'other'] as const
251+
/** who installed the app (`PackageManager.getInstallSourceInfo`), classified; never the raw package name */
252+
export const INSTALL_SOURCES = ['play', 'other_store', 'sideload', 'unknown'] as const
253+
/** `UsageStatsManager.getAppStandbyBucket()` for our own app: how engaged the OS thinks the user is with Freebuff */
254+
export const STANDBY_BUCKETS = ['active', 'working_set', 'frequent', 'rare', 'restricted', 'other'] as const
255+
export const FONT_SCALE_BUCKETS = ['<1', '1', '1-1.3', '1.3+'] as const
256+
257+
/** Where a post-click landing measurement came from. */
258+
export const LANDING_SOURCES = ['custom_tabs'] as const
259+
242260
/**
243261
* Developer-tool vendors whose adoption after a click is recorded (`postClick.packageInstalled` / `mcpAdded`). A
244262
* package or MCP server outside this list is never reported, only whether it matched the clicked ad's vendor.
@@ -450,6 +468,31 @@ export const adClientContextSchema = z.object({
450468
})
451469
.partial()
452470
.optional(),
471+
/** Android app only. Device-wide facts reuse `sys`, `net`, `ui`, `app` and `attn`; these have no other home. */
472+
android: z
473+
.object({
474+
powerSave: z.boolean(),
475+
charging: z.boolean(),
476+
battery: z.enum(BATTERY_BUCKETS),
477+
/** Data Saver restricts this app's background data */
478+
dataSaver: z.boolean(),
479+
metered: z.boolean(),
480+
orientation: z.enum(ORIENTATIONS),
481+
windowClass: z.enum(WINDOW_CLASSES),
482+
/** split screen, freeform or picture-in-picture */
483+
multiWindow: z.boolean(),
484+
/** the soft keyboard is showing, which covers the lower part of the transcript */
485+
keyboard: z.boolean(),
486+
hardwareKeyboard: z.boolean(),
487+
entry: z.enum(ANDROID_ENTRIES),
488+
installSource: z.enum(INSTALL_SOURCES),
489+
standby: z.enum(STANDBY_BUCKETS),
490+
notifications: z.boolean(),
491+
lowRam: z.boolean(),
492+
fontScale: z.enum(FONT_SCALE_BUCKETS),
493+
})
494+
.partial()
495+
.optional(),
453496
/** web only */
454497
web: z
455498
.object({
@@ -532,6 +575,29 @@ export const adEngagementSchema = z.object({
532575
})
533576
.partial()
534577
.optional(),
578+
/**
579+
* What happened on the advertiser's page after the click, as the in-app browser reports it (Android Custom Tabs
580+
* navigation and engagement callbacks). Scroll depth and interaction only, never page content or URLs. Its own key
581+
* rather than part of `postClick`, so the EEA/UK consent rule can drop it whole.
582+
*/
583+
landing: z
584+
.object({
585+
source: z.enum(LANDING_SOURCES),
586+
/** the page finished loading */
587+
loaded: z.boolean(),
588+
/** the page failed to load */
589+
failed: z.boolean(),
590+
/** ms from the click until the page finished loading */
591+
loadMs: ms,
592+
/** deepest scroll reached, percent of the page */
593+
maxScrollPct: z.number().int().min(0).max(100),
594+
/** the user tapped or typed on the page */
595+
interacted: z.boolean(),
596+
/** ms the page was open before the user left it */
597+
visibleMs: ms,
598+
})
599+
.partial()
600+
.optional(),
535601
postClick: z
536602
.object({
537603
/** the window blurred after the click, i.e. the external browser actually took focus */

0 commit comments

Comments
 (0)