Phase 4 §1/§5: notif banner detection on API <33 + chat-delivery WS→FCM lifecycle

§1 notif banner: permission_handler v11 returns granted unconditionally
for Permission.notification on Android <13 because POST_NOTIFICATIONS
didn't exist as a runtime permission. Result: SHome1st amber "notifikasi
off" banner never showed on API 24-32 even when the user toggled
notifications off in Settings → Apps. Add a
NotificationManagerCompat.areNotificationsEnabled() pre-check via
flutter_local_notifications (works from API 19+) so the banner reflects
the real OS state on older Android.

§5 chat delivery: the contract is "WS when foreground, FCM when
background", but the previous build only honoured (1) — Android keeps
the TCP socket alive after the Dart isolate is paused, so backend's
`socket.readyState === 1` check returned true and FCM never fired.
Fix has five parts (all required together):

 1. Customer-side lifecycle observer in client_app/main.dart closes
    chatProvider's WS on paused/detached, reconnects on resumed.
 2. `_appPaused` gate in main.dart suppresses the activeSessionProvider
    listener's auto-reconnect (15s poll in active_session_notifier
    would otherwise re-open the WS the next tick after the observer
    closed it — defeating the fallback).
 3. Mitra-side lifecycle observer in mitra_app/main.dart stashes
    `_pausedChatSessionId`, calls mitraChatProvider.disconnect(), and
    re-issues connect(saved) on resumed.
 4. MitraChat gains a `_connectedSessionId` field + getter so the
    observer in step 3 can read it back across disconnect (disconnect
    clears it; the next connect overwrites it).
 5. SearchingScreen resets pairingProvider when entering with a new
    draft.paymentId — previously it retained PairingActiveData with
    the *old* sessionId after a session ended, and the next pairing
    flow navigated straight to that completed session showing
    "Sesi sudah berakhir".

Backend additions under /internal/_test/* for assertion harness:
inspectSessionWsState + GET /ws-connection-state,
POST /send-chat-message-as-mitra (with delivered_via),
POST /send-chat-message-as-customer (with delivered_via),
POST /send-fcm-chat-message (raw FCM dispatch).

Maestro coverage:
 - ts-customer-05-01: mitra → customer message when customer is
   backgrounded → delivered_via=fcm.
 - ts-customer-05-02: customer → mitra message when mitra is
   backgrounded → delivered_via=fcm.
 - ts-customer-01-01: §1 notif-denied banner on home. Documented
   precondition: mitra must be force-stopped or backgrounded on the
   chat screen before 05-02 runs (Maestro can only drive one --udid
   per run; mitra-side lifecycle observer end-to-end is deferred).

Helper scripts under client_app/.maestro/scripts/:
inspect_ws_state.js, assert_ws_state.js,
send_chat_message_as_mitra.js, assert_delivered_via.js (takes
SENDER=mitra|customer to route to the matching backend endpoint).

README_section_05.md documents the test plan, helper scripts, and the
deferred mitra-side maestro driving. Both apps tested manually on
API 28 AVDs where FCM delivery is sub-second; API 24 has 5-30 min
heartbeats that make it impractical for FCM-related testing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 21:50:46 +08:00
parent 093256ff7d
commit ad02ee252d
15 changed files with 835 additions and 5 deletions

View File

@@ -23,6 +23,20 @@ export const getSessionConnections = (sessionId) => {
return sessionConnections.get(sessionId) || {}
}
// Test-only: expose the in-memory connection map state for a session so
// Maestro flows can assert that backgrounding the customer/mitra app closed
// its WebSocket (which is what gates the FCM fallback in chat.service.js).
// Returns booleans per role based on `socket.readyState === 1`.
export const inspectSessionWsState = (sessionId) => {
const conns = sessionConnections.get(sessionId) || {}
const customerSock = conns[UserType.CUSTOMER]
const mitraSock = conns[UserType.MITRA]
return {
customer_connected: !!(customerSock && customerSock.readyState === 1),
mitra_connected: !!(mitraSock && mitraSock.readyState === 1),
}
}
const sendToSocket = (socket, data) => {
if (socket && socket.readyState === 1) {
socket.send(JSON.stringify(data))

View File

@@ -8,8 +8,11 @@
import { peekStubOtp } from '../../services/otp.service.js'
import { acceptPairingRequest, expirePairingRequest, expireTargetedPairingRequest, getPendingRequestsForMitra } from '../../services/pairing.service.js'
import { startSessionTimer, _resetThreeMinFiredForTest, _broadcastTimerResyncForTest } from '../../services/session-timer.service.js'
import { inspectSessionWsState } from '../../plugins/websocket.js'
import { sendMessage } from '../../services/chat.service.js'
import { sendPushNotification } from '../../services/notification.service.js'
import { getDb } from '../../db/client.js'
import { PairingFailureCause, SessionStatus } from '../../constants.js'
import { PairingFailureCause, SessionStatus, UserType } from '../../constants.js'
const sql = getDb()
@@ -460,4 +463,127 @@ export const internalTestRoutes = async (fastify) => {
const session = await acceptPairingRequest(latest.session_id, mitraId)
return { ok: true, session_id: latest.session_id, session }
})
// Test-only: read the in-memory websocket connection state for a session.
// Used by Maestro flows asserting that backgrounding the customer/mitra
// app closed its WebSocket (which is what gates FCM fallback per
// chat.service.js:51). Returns booleans per role.
fastify.get('/ws-connection-state', async (request, reply) => {
const sessionId = request.query?.session_id
if (!sessionId) {
return reply.code(400).send({ error: 'session_id query param required' })
}
return inspectSessionWsState(sessionId)
})
// Test-only: emulate an FCM push to a specific customer without going
// through the chat-session/WS-fallback path. Useful for poking the
// device's local-notification handler in isolation (e.g. verifying the
// `chat_messages` channel renders, FCM token validity, etc).
//
// Body: { customer_id?, latest_customer?, content? }
// - customer_id: target a specific customers.id.
// - latest_customer: true → pick the most-recently-created customer
// that has an fcm_token (handy when the maestro flow just signed in
// anonymously and you don't have the UUID).
// - content: optional override for the notification body text.
fastify.post('/send-fcm-chat-message', async (request, reply) => {
const { customer_id: customerId, latest_customer: latest, content } =
request.body ?? {}
let targetId = customerId
if (!targetId && latest === true) {
const [row] = await sql`
SELECT id FROM customers
WHERE fcm_token IS NOT NULL
ORDER BY created_at DESC
LIMIT 1
`
if (!row) {
return reply.code(404).send({ error: 'no_customer_with_fcm_token' })
}
targetId = row.id
}
if (!targetId) {
return reply.code(400).send({
error: 'customer_id or latest_customer:true required in body',
})
}
const body = content || 'Pesan baru dari bestie · ketuk buat balas'
const ok = await sendPushNotification(UserType.CUSTOMER, targetId, {
title: 'Pesan baru dari Bestie',
body,
data: { type: 'chat_message', session_id: 'test-emulated' },
})
return { ok, customer_id: targetId, body }
})
// Test-only: send a chat message AS the customer of a paired session.
// Mirrors send-chat-message-as-mitra but with senderType=CUSTOMER —
// useful for asserting the mitra-side FCM fallback when the mitra app is
// backgrounded. Returns the dispatch transport so the caller can assert
// delivered_via=fcm.
//
// Body: { session_id, content }
fastify.post('/send-chat-message-as-customer', async (request, reply) => {
const { session_id: sessionId, content } = request.body ?? {}
if (!sessionId || !content) {
return reply.code(400).send({ error: 'session_id and content required in body' })
}
const [session] = await sql`
SELECT customer_id FROM chat_sessions WHERE id = ${sessionId} LIMIT 1
`
if (!session?.customer_id) {
return reply.code(404).send({ error: 'no_session_or_customer', session_id: sessionId })
}
// Recipient here is the mitra — inspect its WS state before dispatch so
// we can answer "websocket" vs "fcm" honestly.
const wsBefore = inspectSessionWsState(sessionId)
const message = await sendMessage({
sessionId,
senderType: UserType.CUSTOMER,
senderId: session.customer_id,
content,
})
return {
ok: true,
message_id: message.id,
delivered_via: wsBefore.mitra_connected ? 'websocket' : 'fcm',
}
})
// Test-only: send a chat message AS the mitra of a paired session, using
// the real chat.service.sendMessage code path. Returns which transport
// actually carried the message — useful for asserting the WS-vs-FCM
// fallback (e.g. Maestro backgrounds the customer app, calls this, and
// expects `delivered_via: "fcm"`).
//
// Body: { session_id, content }
fastify.post('/send-chat-message-as-mitra', async (request, reply) => {
const { session_id: sessionId, content } = request.body ?? {}
if (!sessionId || !content) {
return reply.code(400).send({ error: 'session_id and content required in body' })
}
const [session] = await sql`
SELECT mitra_id FROM chat_sessions WHERE id = ${sessionId} LIMIT 1
`
if (!session?.mitra_id) {
return reply.code(404).send({ error: 'no_session_or_mitra', session_id: sessionId })
}
// Snapshot WS state BEFORE the send so we can answer "which path?"
// honestly: sendMessage tries WS first, falls back to FCM only when WS
// returned false. We inspect customer_connected here because the mitra
// is the sender — recipient is the customer.
const wsBefore = inspectSessionWsState(sessionId)
const message = await sendMessage({
sessionId,
senderType: UserType.MITRA,
senderId: session.mitra_id,
content,
})
return {
ok: true,
message_id: message.id,
delivered_via: wsBefore.customer_connected ? 'websocket' : 'fcm',
}
})
}