// qa/qa_push.js — send a REAL notification+data FCM to a user's token (faithful to the app's own // pushes) so a KILLED app shows it in the shade and tapping it cold-starts via the real OS splash // handover. This is the ONE path that reproduces the "opens-and-closes" crash class (e.g. the // splash-exit iconView NPE) — `adb am start --es …` does NOT (different launch path), and // `am force-stop` can't even receive FCM. See ClaudeQAPlan.md (Pass E crash-triage / reproduction // fidelity) and memory/project_notifications.md. // // Usage: NODE_PATH=functions/node_modules node qa/qa_push.js [key=value ...] // node qa/qa_push.js partner_started_game game_type=this_or_that // node qa/qa_push.js partner_finished_game game_type=wheel # auto-resolves a completed session // node qa/qa_push.js chat_message conversation_id=main // node qa/qa_push.js partner_answered // Env overrides: SA_JSON, COUPLE_ID. const admin = require('firebase-admin') const path = require('path') const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json') const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ' admin.initializeApp({ credential: admin.credential.cert(require(SA)) }) const db = admin.firestore() const [uid, type, ...rest] = process.argv.slice(2) if (!uid || !type) { console.error('usage: qa_push.js [key=value ...]') process.exit(2) } const extras = {} for (const kv of rest) { const i = kv.indexOf('=') if (i > 0) extras[kv.slice(0, i)] = kv.slice(i + 1) } // Channel must exist in the app (NotificationChannelSetup). game_* → game_activity, else partner_activity. const channelId = type.includes('game') ? 'game_activity' : 'partner_activity' ;(async () => { // A results-ready push needs a completed session id; auto-resolve one if not supplied. if (type === 'partner_finished_game' && !extras.game_session_id && extras.game_type) { const q = await db.collection('couples').doc(COUPLE).collection(extras.game_type).get() let pick = null q.forEach((d) => { const a = d.data().answers || {} if (Object.keys(a).length >= 2 && !pick) pick = d.id }) if (pick) extras.game_session_id = pick } const tk = await db.collection('users').doc(uid).collection('fcmTokens').get() const token = tk.docs[0] && tk.docs[0].data().token if (!token) { console.error('NO_TOKEN for ' + uid + ' (launch the app once so it registers a token)') process.exit(3) } const data = { type, couple_id: COUPLE, ...extras } // Distinct body so the smoke can find + tap exactly this notification (not a grouped summary). const marker = 'QA-SMOKE:' + type const res = await admin.messaging().send({ token, notification: { title: 'Closer', body: marker }, android: { priority: 'high', notification: { channelId } }, data, }) console.log('SENT ' + type + ' data=' + JSON.stringify(data) + ' -> ' + res) process.exit(0) })().catch((e) => { console.error('ERR ' + e.message) process.exit(1) })