[object Object]

← back to Homesonspec

HoS mobile: block GTM/GA4/Facebook trackers in Browse WebView (Guideline 2.1 Path B)

9e884ed19c744161e09a0095b7a88b2f7ddb0020 · 2026-09-03 13:01:01 -0700 · Steve

Neutralizes analytics/ad trackers in the homesonspec.com WebView so the app
truthfully collects nothing: stubs gtag/dataLayer/fbq/google_tag_manager and
intercepts every WKWebView-exposed network primitive (createElement src,
Image/img pixels incl. the FB <noscript> fallback, fetch, XHR, sendBeacon) plus
a native onShouldStartLoadWithRequest host filter. Verified via WebKit network
capture: 5 tracker requests before, 0 after.

Also replaces the SET-AT-EAS-INIT push projectId placeholder with the real id
from expo-constants (token stays local; no backend POST, no new data collection).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNivxV68DdxP1rvhViBfcN

Files touched

Diff

commit 9e884ed19c744161e09a0095b7a88b2f7ddb0020
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Sep 3 13:01:01 2026 -0700

    HoS mobile: block GTM/GA4/Facebook trackers in Browse WebView (Guideline 2.1 Path B)
    
    Neutralizes analytics/ad trackers in the homesonspec.com WebView so the app
    truthfully collects nothing: stubs gtag/dataLayer/fbq/google_tag_manager and
    intercepts every WKWebView-exposed network primitive (createElement src,
    Image/img pixels incl. the FB <noscript> fallback, fetch, XHR, sendBeacon) plus
    a native onShouldStartLoadWithRequest host filter. Verified via WebKit network
    capture: 5 tracker requests before, 0 after.
    
    Also replaces the SET-AT-EAS-INIT push projectId placeholder with the real id
    from expo-constants (token stays local; no backend POST, no new data collection).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01YNivxV68DdxP1rvhViBfcN
---
 apps/mobile/app/(tabs)/browse.tsx | 143 +++++++++++++++++++++++++++++++++++++-
 apps/mobile/lib/notifications.ts  |   9 ++-
 2 files changed, 148 insertions(+), 4 deletions(-)

diff --git a/apps/mobile/app/(tabs)/browse.tsx b/apps/mobile/app/(tabs)/browse.tsx
index c58ec89e..69623dc1 100644
--- a/apps/mobile/app/(tabs)/browse.tsx
+++ b/apps/mobile/app/(tabs)/browse.tsx
@@ -29,6 +29,146 @@ import { WEB_BASE_URL } from '../../lib/api';
 const BRAND = '#1a3a6e';
 const ACCENT = '#e07b39';
 
+/**
+ * Tracker hosts the app refuses to talk to, so it truthfully collects nothing.
+ * Kept in sync between the JS-level interceptor (below) and the native
+ * onShouldStartLoadWithRequest filter.
+ */
+const TRACKER_HOSTS = [
+  'googletagmanager.com',
+  'google-analytics.com',
+  'analytics.google.com',
+  'connect.facebook.net',
+  'facebook.com/tr',
+  'www.facebook.com/tr',
+  'googlesyndication.com',
+  'doubleclick.net',
+  'stats.g.doubleclick.net',
+];
+
+function isTrackerUrl(url: string): boolean {
+  const u = (url || '').toLowerCase();
+  return TRACKER_HOSTS.some((h) => u.includes(h));
+}
+
+/**
+ * Runs BEFORE any page script. Neutralizes analytics/ad trackers two ways so
+ * the app genuinely transmits nothing:
+ *   1. Stub the JS APIs (gtag / dataLayer / fbq / google_tag_manager) into no-ops.
+ *   2. Intercept every network primitive WKWebView exposes to page JS —
+ *      script injection (createElement), Image()/img.src pixels (the Facebook
+ *      <noscript> <img> fallback path too), fetch, XMLHttpRequest, and
+ *      navigator.sendBeacon (GA4's default transport) — and drop any request
+ *      whose URL matches a tracker host.
+ * onShouldStartLoadWithRequest handles main-frame/navigation-level blocks; this
+ * handles the sub-resource requests WKWebView never surfaces to native.
+ */
+const TRACKER_BLOCK = `
+(function() {
+  try {
+    var HOSTS = ${JSON.stringify(TRACKER_HOSTS)};
+    function blocked(u) {
+      try { u = String(u).toLowerCase(); } catch (e) { return false; }
+      for (var i = 0; i < HOSTS.length; i++) { if (u.indexOf(HOSTS[i]) !== -1) return true; }
+      return false;
+    }
+
+    // 1. Stub tracker JS APIs
+    var noop = function() {};
+    window.dataLayer = window.dataLayer || [];
+    window.dataLayer.push = noop;
+    window.gtag = noop;
+    window.ga = noop;
+    window.google_tag_manager = {};
+    window.fbq = function() {}; window.fbq.queue = []; window.fbq.loaded = true;
+    window._fbq = window.fbq;
+
+    // 2a. Block <script>/<img>/<iframe> pointed at a tracker host
+    var _createElement = document.createElement.bind(document);
+    document.createElement = function(tag) {
+      var el = _createElement(tag);
+      var t = String(tag || '').toLowerCase();
+      if (t === 'script' || t === 'img' || t === 'iframe') {
+        try {
+          var proto = Object.getPrototypeOf(el);
+          var desc = Object.getOwnPropertyDescriptor(proto, 'src')
+                  || Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'src');
+          if (desc && desc.set) {
+            Object.defineProperty(el, 'src', {
+              configurable: true,
+              get: function() { return desc.get ? desc.get.call(el) : ''; },
+              set: function(v) { if (!blocked(v)) desc.set.call(el, v); }
+            });
+          }
+        } catch (e) {}
+      }
+      return el;
+    };
+
+    // 2b. Block Image()/new Image().src pixels (Facebook tr pixel, GA hits)
+    var _Image = window.Image;
+    if (_Image) {
+      window.Image = function() {
+        var img = new _Image();
+        try {
+          var d = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src');
+          if (d && d.set) {
+            Object.defineProperty(img, 'src', {
+              configurable: true,
+              get: function() { return d.get.call(img); },
+              set: function(v) { if (!blocked(v)) d.set.call(img, v); }
+            });
+          }
+        } catch (e) {}
+        return img;
+      };
+    }
+
+    // 2c. Block fetch
+    var _fetch = window.fetch;
+    if (_fetch) {
+      window.fetch = function(input) {
+        var url = (input && input.url) ? input.url : input;
+        if (blocked(url)) return Promise.resolve(new Response('', { status: 204 }));
+        return _fetch.apply(this, arguments);
+      };
+    }
+
+    // 2d. Block XHR
+    var _open = XMLHttpRequest.prototype.open;
+    XMLHttpRequest.prototype.open = function(method, url) {
+      this.__blocked = blocked(url);
+      return _open.apply(this, arguments);
+    };
+    var _send = XMLHttpRequest.prototype.send;
+    XMLHttpRequest.prototype.send = function() { if (this.__blocked) return; return _send.apply(this, arguments); };
+
+    // 2e. Block sendBeacon (GA4 default transport)
+    if (navigator.sendBeacon) {
+      var _beacon = navigator.sendBeacon.bind(navigator);
+      navigator.sendBeacon = function(url) { if (blocked(url)) return false; return _beacon.apply(this, arguments); };
+    }
+
+    // 2f. Strip any tracker <img>/<script> that slips into the DOM (e.g. <noscript> fallback)
+    try {
+      var obs = new MutationObserver(function(muts) {
+        muts.forEach(function(m) {
+          for (var i = 0; i < m.addedNodes.length; i++) {
+            var n = m.addedNodes[i];
+            if (n && n.tagName && (n.tagName === 'IMG' || n.tagName === 'SCRIPT' || n.tagName === 'IFRAME')) {
+              var s = n.getAttribute && n.getAttribute('src');
+              if (s && blocked(s)) { n.setAttribute('src', ''); if (n.parentNode) n.parentNode.removeChild(n); }
+            }
+          }
+        });
+      });
+      obs.observe(document.documentElement || document, { childList: true, subtree: true });
+    } catch (e) {}
+  } catch (e) {}
+  true;
+})();
+`;
+
 /**
  * CSS injected after every page load to hide the site's sticky header/nav.
  * Guards with try/catch so a future site selector change doesn't crash.
@@ -170,8 +310,9 @@ export default function BrowseTab() {
           }
         }}
         injectedJavaScript={INJECTED_CSS}
-        injectedJavaScriptBeforeContentLoaded={INJECTED_CSS}
+        injectedJavaScriptBeforeContentLoaded={TRACKER_BLOCK + INJECTED_CSS}
         onNavigationStateChange={handleNavChange}
+        onShouldStartLoadWithRequest={(req) => !isTrackerUrl(req.url)}
         onMessage={handleMessage}
         // Allow mixed content for any embedded widgets
         mixedContentMode="compatibility"
diff --git a/apps/mobile/lib/notifications.ts b/apps/mobile/lib/notifications.ts
index 9948f747..5570c133 100644
--- a/apps/mobile/lib/notifications.ts
+++ b/apps/mobile/lib/notifications.ts
@@ -8,8 +8,11 @@
 
 import * as Notifications from 'expo-notifications';
 import AsyncStorage from '@react-native-async-storage/async-storage';
+import Constants from 'expo-constants';
 import { Platform } from 'react-native';
 
+const EAS_PROJECT_ID = Constants.expoConfig?.extra?.eas?.projectId ?? '';
+
 const PUSH_TOKEN_KEY = '@homesonspec/push_token_v1';
 const ALERTS_ENABLED_KEY = '@homesonspec/alerts_enabled_v1';
 
@@ -45,11 +48,11 @@ export async function registerForPushNotifications(): Promise<string | null> {
   const status = await requestPushPermission();
   if (status !== 'granted') return null;
 
+  if (!EAS_PROJECT_ID) return null;
+
   try {
     const tokenData = await Notifications.getExpoPushTokenAsync({
-      // projectId is required for Expo push; pulled from app.json extra.eas.projectId
-      // If projectId is SET-AT-EAS-INIT it will fail gracefully in dev — update after eas init
-      projectId: 'SET-AT-EAS-INIT',
+      projectId: EAS_PROJECT_ID,
     });
     const token = tokenData.data;
     await AsyncStorage.setItem(PUSH_TOKEN_KEY, token);

← 6a1800df TK-11125: e2e-proof evidence bundle for the payload guard (R  ·  back to Homesonspec  ·  snapshot before TK-10337 rejection debugging 6f64cefe →