← 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
M apps/mobile/app/(tabs)/browse.tsxM apps/mobile/lib/notifications.ts
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 →