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:
@@ -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',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user