@@ -239,6 +239,24 @@ export const EDIT_RATIO_BUCKETS = ['<5%', '5-15%', '15-30%', '30%+'] as const
239239/** rendered ad card size, CSS pixels */
240240export 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