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:
@@ -23,6 +23,20 @@ export const getSessionConnections = (sessionId) => {
|
|||||||
return sessionConnections.get(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) => {
|
const sendToSocket = (socket, data) => {
|
||||||
if (socket && socket.readyState === 1) {
|
if (socket && socket.readyState === 1) {
|
||||||
socket.send(JSON.stringify(data))
|
socket.send(JSON.stringify(data))
|
||||||
|
|||||||
@@ -8,8 +8,11 @@
|
|||||||
import { peekStubOtp } from '../../services/otp.service.js'
|
import { peekStubOtp } from '../../services/otp.service.js'
|
||||||
import { acceptPairingRequest, expirePairingRequest, expireTargetedPairingRequest, getPendingRequestsForMitra } from '../../services/pairing.service.js'
|
import { acceptPairingRequest, expirePairingRequest, expireTargetedPairingRequest, getPendingRequestsForMitra } from '../../services/pairing.service.js'
|
||||||
import { startSessionTimer, _resetThreeMinFiredForTest, _broadcastTimerResyncForTest } from '../../services/session-timer.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 { getDb } from '../../db/client.js'
|
||||||
import { PairingFailureCause, SessionStatus } from '../../constants.js'
|
import { PairingFailureCause, SessionStatus, UserType } from '../../constants.js'
|
||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
|
|
||||||
@@ -460,4 +463,127 @@ export const internalTestRoutes = async (fastify) => {
|
|||||||
const session = await acceptPairingRequest(latest.session_id, mitraId)
|
const session = await acceptPairingRequest(latest.session_id, mitraId)
|
||||||
return { ok: true, session_id: latest.session_id, session }
|
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',
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
93
client_app/.maestro/flows/README_section_05.md
Normal file
93
client_app/.maestro/flows/README_section_05.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# §5 — Chat Room test plan
|
||||||
|
|
||||||
|
Spec: [requirement/flow_customer.mermaid.md §5](../../../requirement/flow_customer.mermaid.md)
|
||||||
|
plus the user-stated message-delivery contract:
|
||||||
|
|
||||||
|
> 1. WebSocket when app is foreground / WS connected.
|
||||||
|
> 2. FCM when app is background / WS disconnected.
|
||||||
|
|
||||||
|
The lifecycle observers that enforce (2) live at:
|
||||||
|
- Customer side: `client_app/lib/main.dart::_AppState.didChangeAppLifecycleState`
|
||||||
|
- Mitra side: `mitra_app/lib/main.dart::_AppState.didChangeAppLifecycleState` +
|
||||||
|
`mitra_app/lib/core/chat/mitra_chat_notifier.dart` (`_connectedSessionId`
|
||||||
|
+ getter for the observer to read on `paused`).
|
||||||
|
|
||||||
|
A 15s `Timer.periodic` in `active_session_notifier.dart` would otherwise
|
||||||
|
re-open the customer WS right after the observer closed it; the
|
||||||
|
`_appPaused` gate in customer main.dart suppresses that race.
|
||||||
|
|
||||||
|
## Implemented
|
||||||
|
|
||||||
|
| File | Direction | Expected dispatch |
|
||||||
|
|---|---|---|
|
||||||
|
| `ts-customer-05-01-background_disconnects_ws_so_messages_fall_back_to_fcm.yaml` | mitra → customer, customer is backgrounded | `delivered_via=fcm` |
|
||||||
|
| `ts-customer-05-02-customer_message_to_backgrounded_mitra_falls_back_to_fcm.yaml` | customer → mitra, mitra is backgrounded | `delivered_via=fcm` |
|
||||||
|
|
||||||
|
Both flows drive the customer device only (Maestro can only target one
|
||||||
|
`--udid` per run). The mitra side is verified server-side via
|
||||||
|
[`/internal/_test/ws-connection-state`](../../../backend/src/routes/internal/_test.routes.js)
|
||||||
|
and
|
||||||
|
[`/internal/_test/send-chat-message-as-customer`](../../../backend/src/routes/internal/_test.routes.js)
|
||||||
|
(introduced 2026-05-18 with the lifecycle fix).
|
||||||
|
|
||||||
|
## Helper scripts (under `../scripts/`)
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `assert_ws_state.js` | Reads `/internal/_test/ws-connection-state?session_id=…`; throws on mismatch with `EXPECTED_CUSTOMER_CONNECTED` / `EXPECTED_MITRA_CONNECTED`. |
|
||||||
|
| `assert_delivered_via.js` | Sends a chat message via the test endpoint matching `SENDER` (`mitra` default, or `customer`); asserts the response's `delivered_via` equals `EXPECTED_DELIVERED_VIA`. |
|
||||||
|
| `inspect_ws_state.js` | Same data as `assert_ws_state.js` but exports the booleans into `output.CUSTOMER_CONNECTED` / `output.MITRA_CONNECTED` for downstream conditions without throwing. |
|
||||||
|
| `send_chat_message_as_mitra.js` | Mitra→customer one-shot send. Captures `output.MESSAGE_ID` + `output.DELIVERED_VIA`. |
|
||||||
|
|
||||||
|
## Pre-reqs for both flows
|
||||||
|
|
||||||
|
- Backend reachable; `NODE_ENV != 'production'`.
|
||||||
|
- ≥1 mitra online (the `seed_history_session.js` helper picks the
|
||||||
|
most-recently-online mitra to act as the eventual acceptor).
|
||||||
|
- For 05-01: customer device is the one Maestro drives; `pressKey: HOME`
|
||||||
|
inside the flow backgrounds it. No mitra-app interaction required.
|
||||||
|
- For 05-02: **the mitra app on `emulator-5556` must be backgrounded or
|
||||||
|
force-stopped before the flow runs**. The `assert_ws_state.js` guard
|
||||||
|
fails loudly if mitra is foreground (proving WS-over-FCM precedence —
|
||||||
|
also a correct test outcome, just the wrong side of the matrix). To
|
||||||
|
ensure FCM actually arrives on the device, the mitra account on
|
||||||
|
`emulator-5556` must have a `fcm_token` in `mitras.fcm_token` (i.e.,
|
||||||
|
the app has signed in and called `/api/shared/device-token` at some
|
||||||
|
point) — verified by `seed_history_session.js`'s output mitra ID and
|
||||||
|
the `MITRA_NAME_RE` it captures.
|
||||||
|
|
||||||
|
## Manual mitra-side verification
|
||||||
|
|
||||||
|
After 05-02 passes, optionally inspect the mitra device's notification
|
||||||
|
shade to confirm the FCM tray notification rendered:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ADB_SERVER_SOCKET=tcp:172.22.240.1:5037
|
||||||
|
adb -s emulator-5556 shell cmd statusbar expand-notifications
|
||||||
|
adb -s emulator-5556 exec-out screencap -p > /tmp/mitra_shade.png
|
||||||
|
adb -s emulator-5556 shell cmd statusbar collapse
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect to see a `Pesan baru dari Customer` notification within ~1s on an
|
||||||
|
API 28+ AVD (older AVDs queue FCM for 5-30 min — see the per-AVD
|
||||||
|
Play-Services notes in the project memory).
|
||||||
|
|
||||||
|
## Not yet automated
|
||||||
|
|
||||||
|
**Mitra-side lifecycle observer** end-to-end. To verify that the mitra
|
||||||
|
app's `didChangeAppLifecycleState(paused)` actually closes its chat WS,
|
||||||
|
the test would need to drive the mitra emulator (Maestro on
|
||||||
|
`--udid emulator-5556`) through login → chat-screen → `pressKey: HOME` →
|
||||||
|
assert backend WS state. This requires:
|
||||||
|
|
||||||
|
1. Pre-seeding an active chat session for the currently-signed-in mitra
|
||||||
|
(no existing test endpoint for "seed customer + pair + active
|
||||||
|
session"; the existing scripts assume the customer app drives it).
|
||||||
|
2. Driving the mitra OTP login + chat-request acceptance UI from
|
||||||
|
Maestro.
|
||||||
|
|
||||||
|
Both are feasible but out of scope for the current iteration. The
|
||||||
|
mitra-side fix is covered indirectly: ts-customer-05-02 asserts the
|
||||||
|
backend correctly dispatches via FCM whenever the mitra WS is closed,
|
||||||
|
and the mitra app's lifecycle observer was verified manually
|
||||||
|
(2026-05-18) to be the mechanism that closes that WS.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# ts-customer-01-01 — When OS-level notification permission is denied,
|
||||||
|
# SHome1st renders the amber "notifikasi off" banner above the
|
||||||
|
# 'aku mau curhat' CTA.
|
||||||
|
# Spec ref: requirement/flow_customer.mermaid.md §1 — `NotifCheck → no →
|
||||||
|
# HomeBanner` (line 24-26 of the mermaid).
|
||||||
|
#
|
||||||
|
# Banner widget: home_screen.dart::_NotifDeniedBanner. Gating provider:
|
||||||
|
# notifPermissionStatusProvider (core/notifications/notif_permission.dart).
|
||||||
|
# The provider reads `permission_handler::Permission.notification.status`
|
||||||
|
# which, on Android 13+, reflects the runtime POST_NOTIFICATIONS state and
|
||||||
|
# on older Android reflects the app's notification-enabled toggle.
|
||||||
|
#
|
||||||
|
# Pre-requisite (manual setup):
|
||||||
|
# The test only exercises the "denied" branch. Disable notifications for
|
||||||
|
# the customer app BEFORE invoking maestro:
|
||||||
|
# - Android 13+: maestro's `permissions: { notifications: deny }` below
|
||||||
|
# handles this (POST_NOTIFICATIONS is a runtime permission).
|
||||||
|
# - Android 7-12 (incl. the API-24 dev AVD): the runtime permission
|
||||||
|
# doesn't exist, so the maestro `permissions:` block is a no-op.
|
||||||
|
# Instead, toggle Settings → Apps → Halo Bestie → Notifications →
|
||||||
|
# Off manually. This sets the system `NotificationManagerCompat`
|
||||||
|
# state which the app's `notif_permission.dart::readStatus`
|
||||||
|
# pre-check picks up. `clearState: true` below is safe — `pm clear`
|
||||||
|
# wipes app data but not the system-level notification toggle.
|
||||||
|
# The backend FCM path still fires regardless of this branch (the OS
|
||||||
|
# simply suppresses the notification); the banner is the only
|
||||||
|
# user-visible signal that they're missing alerts, which is what we
|
||||||
|
# assert below.
|
||||||
|
appId: com.halobestie.client.client_app
|
||||||
|
env:
|
||||||
|
TEST_PHONE: "+6281234567890"
|
||||||
|
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||||
|
---
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/reset_phone.js
|
||||||
|
env:
|
||||||
|
TEST_PHONE: ${TEST_PHONE}
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
permissions:
|
||||||
|
notifications: deny
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "Mulai"
|
||||||
|
timeout: 15000
|
||||||
|
- tapOn:
|
||||||
|
text: "Mulai"
|
||||||
|
retryTapIfNoChange: true
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*aku mau curhat.*"
|
||||||
|
timeout: 30000
|
||||||
|
|
||||||
|
# §1 NotifCheck=no → SHome1st banner. Text is the unique copy from
|
||||||
|
# _NotifDeniedBanner; the icon is decorative-only and not part of the
|
||||||
|
# a11y label.
|
||||||
|
- assertVisible: "(?s).*notifikasi off.*"
|
||||||
|
- assertVisible: "(?s).*kelewat chat dari bestie.*"
|
||||||
|
- assertVisible: "(?s).*nyalain.*"
|
||||||
|
- takeScreenshot: /tmp/notif_banner_design
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# ts-customer-05-01 — When the customer app is backgrounded, the chat
|
||||||
|
# WebSocket must close so backend `sendMessage` falls back to FCM.
|
||||||
|
#
|
||||||
|
# Spec ref: requirement/flow_customer.mermaid.md §5 (Chat Room) +
|
||||||
|
# user-stated path:
|
||||||
|
# 1. WebSocket when app is foreground / WS connected.
|
||||||
|
# 2. FCM when app is background / WS disconnected.
|
||||||
|
#
|
||||||
|
# Fix under test: client_app/lib/main.dart `_AppState.didChangeAppLifecycleState`
|
||||||
|
# closes the chat WS on AppLifecycleState.paused / detached. Before the
|
||||||
|
# fix, Android kept the TCP socket alive after the Dart isolate paused, so
|
||||||
|
# backend's `socket.readyState === 1` check returned true and FCM never
|
||||||
|
# fired — the customer received no alert in background.
|
||||||
|
#
|
||||||
|
# Verification approach:
|
||||||
|
# 1. Drive customer through pairing → active chat (same harness as
|
||||||
|
# ts-customer-04-04).
|
||||||
|
# 2. Assert customer-side WS is connected (backend state check).
|
||||||
|
# 3. Press HOME to background → wait briefly for the lifecycle observer
|
||||||
|
# to fire `disconnect()` and the socket-close to round-trip.
|
||||||
|
# 4. Assert customer-side WS is now closed.
|
||||||
|
# 5. Send a message AS mitra via the real `sendMessage` code path and
|
||||||
|
# assert it was delivered via FCM (recipient.customer WS was down at
|
||||||
|
# dispatch time).
|
||||||
|
#
|
||||||
|
# Pre-reqs:
|
||||||
|
# - Backend reachable; NODE_ENV != 'production'.
|
||||||
|
# - ≥1 mitra online (the seeded mitra acts as the blast acceptor and the
|
||||||
|
# subsequent message sender).
|
||||||
|
appId: com.halobestie.client.client_app
|
||||||
|
env:
|
||||||
|
TEST_PHONE: "+6281234567890"
|
||||||
|
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||||
|
---
|
||||||
|
# ── Setup: pair customer with a mitra, land in active chat ──────────────
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/reset_phone.js
|
||||||
|
env:
|
||||||
|
TEST_PHONE: ${TEST_PHONE}
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
- runFlow: ../subflows/onboarding_returning_user.yaml
|
||||||
|
|
||||||
|
# Seed history so the SHomeReturning view renders the choice sheet (the
|
||||||
|
# seeded mitra also becomes the blast acceptor).
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/seed_history_session.js
|
||||||
|
env:
|
||||||
|
TEST_PHONE: ${TEST_PHONE}
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
- swipe:
|
||||||
|
start: "50%, 30%"
|
||||||
|
end: "50%, 80%"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*curhatan sebelumnya.*"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
# "curhat sama bestie baru" → choice sheet → "bestie baru" → method-pick
|
||||||
|
- tapOn:
|
||||||
|
text: "(?s).*curhat sama bestie baru.*"
|
||||||
|
retryTapIfNoChange: true
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*mau curhat sama siapa.*"
|
||||||
|
timeout: 5000
|
||||||
|
- tapOn: "(?s).*cari bestie baru yang siap dengerin.*"
|
||||||
|
|
||||||
|
# Payment chain → confirm → pairing accepted.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*pilih cara curhat.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
text: "(?s).*tulis dan baca dengan tenang.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*pilih durasi.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "(?s).*5 menit.*"
|
||||||
|
- tapOn: "(?s).*bayar Rp.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*cara bayar.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
text: "(?s).*bayar Rp.*"
|
||||||
|
retryTapIfNoChange: true
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "scan QRIS untuk bayar"
|
||||||
|
timeout: 10000
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/mark_latest_payment_paid.js
|
||||||
|
env:
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*biar nggak ketinggalan.*"
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
text: "(?s).*nanti aja.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*lagi nyari bestie.*"
|
||||||
|
timeout: 20000
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/mitra_accept_latest_internal.js
|
||||||
|
env:
|
||||||
|
MITRA_ID: ${output.MITRA_ID}
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*online.*"
|
||||||
|
timeout: 20000
|
||||||
|
|
||||||
|
# ── Assertion 1: WS is connected when chat screen is in foreground ──────
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/assert_ws_state.js
|
||||||
|
env:
|
||||||
|
SESSION_ID: ${output.ACCEPTED_SESSION_ID}
|
||||||
|
EXPECTED_CUSTOMER_CONNECTED: "true"
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
|
||||||
|
# ── Background the app: customer's WS should close ──────────────────────
|
||||||
|
- pressKey: HOME
|
||||||
|
|
||||||
|
# Give the lifecycle observer + socket-close round-trip a moment. The
|
||||||
|
# disconnect() call posts a close frame; the backend reacts on
|
||||||
|
# `socket.on('close')`. ~1s is usually sufficient on the dev backend.
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
|
||||||
|
# ── Assertion 2: backend now reports customer_connected=false ───────────
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/assert_ws_state.js
|
||||||
|
env:
|
||||||
|
SESSION_ID: ${output.ACCEPTED_SESSION_ID}
|
||||||
|
EXPECTED_CUSTOMER_CONNECTED: "false"
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
|
||||||
|
# ── Assertion 3: a message sent now is delivered via FCM, not WebSocket ─
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/assert_delivered_via.js
|
||||||
|
env:
|
||||||
|
SESSION_ID: ${output.ACCEPTED_SESSION_ID}
|
||||||
|
CONTENT: "halo, balasan dari mitra sementara kamu di background"
|
||||||
|
EXPECTED_DELIVERED_VIA: "fcm"
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# ts-customer-05-02 — Symmetric to 05-01: customer sends a message and the
|
||||||
|
# mitra is in background → backend `sendMessage` falls back to FCM.
|
||||||
|
#
|
||||||
|
# Spec ref: requirement/flow_customer.mermaid.md §5 (Chat Room) +
|
||||||
|
# the user-stated path:
|
||||||
|
# 1. WebSocket when app is foreground / WS connected.
|
||||||
|
# 2. FCM when app is background / WS disconnected.
|
||||||
|
#
|
||||||
|
# Fix under test: mitra_app/lib/main.dart `_AppState.didChangeAppLifecycleState`
|
||||||
|
# closes the chat WS on paused/detached (and `mitra_chat_notifier.dart`
|
||||||
|
# exposes `connectedSessionId` + clears it on disconnect). Without this,
|
||||||
|
# Android keeps the TCP socket alive after the Dart isolate pauses, so
|
||||||
|
# the backend believes the mitra is online and never fires FCM — the
|
||||||
|
# mitra misses customer messages while the app is in background.
|
||||||
|
#
|
||||||
|
# Approach (Maestro can only drive one device per flow; here we drive the
|
||||||
|
# customer, and the mitra side is verified server-side):
|
||||||
|
# 1. Drive customer to active chat (same harness as ts-customer-04-04 /
|
||||||
|
# ts-customer-05-01). The acceptor is whichever mitra the
|
||||||
|
# seed_history_session.js helper picks (most-recently-online → on
|
||||||
|
# our setup that is the signed-in mitra on emulator-5556).
|
||||||
|
# 2. Assert backend says mitra_connected=false. This is true when:
|
||||||
|
# (a) the mitra app is force-stopped / not running, OR
|
||||||
|
# (b) the mitra app is on the chat screen AND backgrounded — the
|
||||||
|
# lifecycle observer should have closed the WS.
|
||||||
|
# Either condition fulfils the precondition for FCM fallback.
|
||||||
|
# 3. Send a message AS customer via /internal/_test/send-chat-message-as-customer
|
||||||
|
# and assert delivered_via="fcm".
|
||||||
|
#
|
||||||
|
# Pre-reqs (HARD):
|
||||||
|
# - Backend reachable; NODE_ENV != 'production'.
|
||||||
|
# - emulator-5556 mitra app: either force-stopped before the test, or
|
||||||
|
# signed in with TestMitra-1501 (the seed_history_session pick) and
|
||||||
|
# currently backgrounded. If the mitra is foreground in the chat
|
||||||
|
# screen, mitra_connected=true and this test asserts will fail —
|
||||||
|
# which is the correct failure mode (proves WS-over-FCM precedence).
|
||||||
|
# - The currently-signed-in mitra must have a `fcm_token` row in
|
||||||
|
# `mitras.fcm_token`; otherwise the FCM dispatch succeeds at the
|
||||||
|
# backend code path but never reaches a device.
|
||||||
|
appId: com.halobestie.client.client_app
|
||||||
|
env:
|
||||||
|
TEST_PHONE: "+6281234567890"
|
||||||
|
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||||
|
---
|
||||||
|
# ── Setup: drive customer through onboarding → pair via blast ───────────
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/reset_phone.js
|
||||||
|
env:
|
||||||
|
TEST_PHONE: ${TEST_PHONE}
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
- runFlow: ../subflows/onboarding_returning_user.yaml
|
||||||
|
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/seed_history_session.js
|
||||||
|
env:
|
||||||
|
TEST_PHONE: ${TEST_PHONE}
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
- swipe:
|
||||||
|
start: "50%, 30%"
|
||||||
|
end: "50%, 80%"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*curhatan sebelumnya.*"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
text: "(?s).*curhat sama bestie baru.*"
|
||||||
|
retryTapIfNoChange: true
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*mau curhat sama siapa.*"
|
||||||
|
timeout: 5000
|
||||||
|
- tapOn: "(?s).*cari bestie baru yang siap dengerin.*"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*pilih cara curhat.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
text: "(?s).*tulis dan baca dengan tenang.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*pilih durasi.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "(?s).*5 menit.*"
|
||||||
|
- tapOn: "(?s).*bayar Rp.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*cara bayar.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
text: "(?s).*bayar Rp.*"
|
||||||
|
retryTapIfNoChange: true
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "scan QRIS untuk bayar"
|
||||||
|
timeout: 10000
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/mark_latest_payment_paid.js
|
||||||
|
env:
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*biar nggak ketinggalan.*"
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
text: "(?s).*nanti aja.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*lagi nyari bestie.*"
|
||||||
|
timeout: 20000
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/mitra_accept_latest_internal.js
|
||||||
|
env:
|
||||||
|
MITRA_ID: ${output.MITRA_ID}
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*online.*"
|
||||||
|
timeout: 20000
|
||||||
|
|
||||||
|
# ── Assertion 1: backend says mitra is NOT connected ────────────────────
|
||||||
|
# Precondition guard: if the mitra app is foreground, this assert fails
|
||||||
|
# loud — which is what we want (it proves WS would win over FCM).
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/assert_ws_state.js
|
||||||
|
env:
|
||||||
|
SESSION_ID: ${output.ACCEPTED_SESSION_ID}
|
||||||
|
EXPECTED_CUSTOMER_CONNECTED: "true"
|
||||||
|
EXPECTED_MITRA_CONNECTED: "false"
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
|
||||||
|
# ── Assertion 2: a customer-sent message is delivered via FCM ───────────
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/assert_delivered_via.js
|
||||||
|
env:
|
||||||
|
SESSION_ID: ${output.ACCEPTED_SESSION_ID}
|
||||||
|
CONTENT: "halo bestie, ini pesan saat kamu di background"
|
||||||
|
EXPECTED_DELIVERED_VIA: "fcm"
|
||||||
|
SENDER: "customer"
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
40
client_app/.maestro/scripts/assert_delivered_via.js
Normal file
40
client_app/.maestro/scripts/assert_delivered_via.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Send a chat message AS the given side and assert the delivery transport
|
||||||
|
// matches EXPECTED_DELIVERED_VIA. Throws on mismatch.
|
||||||
|
//
|
||||||
|
// Env:
|
||||||
|
// SESSION_ID (required)
|
||||||
|
// CONTENT (required)
|
||||||
|
// EXPECTED_DELIVERED_VIA (required, "websocket" or "fcm")
|
||||||
|
// SENDER (optional, "mitra" (default) or "customer")
|
||||||
|
// BACKEND_INTERNAL_URL (optional)
|
||||||
|
const sessionId = SESSION_ID
|
||||||
|
const content = CONTENT
|
||||||
|
const expected = EXPECTED_DELIVERED_VIA
|
||||||
|
const sender = typeof SENDER !== 'undefined' && SENDER ? SENDER : 'mitra'
|
||||||
|
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
|
||||||
|
if (!sessionId) throw new Error('SESSION_ID env not set')
|
||||||
|
if (!content) throw new Error('CONTENT env not set')
|
||||||
|
if (expected !== 'websocket' && expected !== 'fcm') {
|
||||||
|
throw new Error('EXPECTED_DELIVERED_VIA must be "websocket" or "fcm"')
|
||||||
|
}
|
||||||
|
if (sender !== 'mitra' && sender !== 'customer') {
|
||||||
|
throw new Error('SENDER must be "mitra" or "customer"')
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint =
|
||||||
|
sender === 'mitra'
|
||||||
|
? '/internal/_test/send-chat-message-as-mitra'
|
||||||
|
: '/internal/_test/send-chat-message-as-customer'
|
||||||
|
const resp = http.post(`${url}${endpoint}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ session_id: sessionId, content }),
|
||||||
|
})
|
||||||
|
if (resp.status !== 200) {
|
||||||
|
throw new Error(`send-chat-message-as-mitra failed (${resp.status}): ${resp.body}`)
|
||||||
|
}
|
||||||
|
const data = json(resp.body)
|
||||||
|
if (data.delivered_via !== expected) {
|
||||||
|
throw new Error(`delivered_via mismatch: expected=${expected}, got=${data.delivered_via}`)
|
||||||
|
}
|
||||||
|
output.MESSAGE_ID = data.message_id
|
||||||
|
output.DELIVERED_VIA = data.delivered_via
|
||||||
35
client_app/.maestro/scripts/assert_ws_state.js
Normal file
35
client_app/.maestro/scripts/assert_ws_state.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Assert backend's in-memory WS connection state for a session matches
|
||||||
|
// expectations. Throws (fails the maestro flow) on mismatch.
|
||||||
|
//
|
||||||
|
// Env:
|
||||||
|
// SESSION_ID (required)
|
||||||
|
// EXPECTED_CUSTOMER_CONNECTED (required, "true" or "false")
|
||||||
|
// EXPECTED_MITRA_CONNECTED (optional, "true" or "false")
|
||||||
|
// BACKEND_INTERNAL_URL (optional, default http://localhost:3001)
|
||||||
|
const sessionId = SESSION_ID
|
||||||
|
const expCust = EXPECTED_CUSTOMER_CONNECTED
|
||||||
|
const expMitra = typeof EXPECTED_MITRA_CONNECTED !== 'undefined' ? EXPECTED_MITRA_CONNECTED : null
|
||||||
|
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
|
||||||
|
if (!sessionId) throw new Error('SESSION_ID env not set')
|
||||||
|
if (expCust !== 'true' && expCust !== 'false') {
|
||||||
|
throw new Error('EXPECTED_CUSTOMER_CONNECTED must be "true" or "false"')
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = http.get(`${url}/internal/_test/ws-connection-state?session_id=${sessionId}`)
|
||||||
|
if (resp.status !== 200) {
|
||||||
|
throw new Error(`ws-connection-state failed (${resp.status}): ${resp.body}`)
|
||||||
|
}
|
||||||
|
const data = json(resp.body)
|
||||||
|
|
||||||
|
const gotCust = String(data.customer_connected)
|
||||||
|
if (gotCust !== expCust) {
|
||||||
|
throw new Error(`customer_connected mismatch: expected=${expCust}, got=${gotCust}`)
|
||||||
|
}
|
||||||
|
if (expMitra !== null) {
|
||||||
|
const gotMitra = String(data.mitra_connected)
|
||||||
|
if (gotMitra !== expMitra) {
|
||||||
|
throw new Error(`mitra_connected mismatch: expected=${expMitra}, got=${gotMitra}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.CUSTOMER_CONNECTED = gotCust
|
||||||
|
output.MITRA_CONNECTED = String(data.mitra_connected)
|
||||||
19
client_app/.maestro/scripts/inspect_ws_state.js
Normal file
19
client_app/.maestro/scripts/inspect_ws_state.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Reads the in-memory websocket connection state for a given session via
|
||||||
|
// the dev-only /internal/_test/ws-connection-state endpoint. Used to assert
|
||||||
|
// that backgrounding the customer app closes its WS — the gate that flips
|
||||||
|
// chat.service.sendMessage to the FCM fallback path.
|
||||||
|
//
|
||||||
|
// Env:
|
||||||
|
// SESSION_ID (required)
|
||||||
|
// BACKEND_INTERNAL_URL (optional, default http://localhost:3001)
|
||||||
|
const sessionId = SESSION_ID
|
||||||
|
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
|
||||||
|
if (!sessionId) throw new Error('SESSION_ID env not set')
|
||||||
|
|
||||||
|
const resp = http.get(`${url}/internal/_test/ws-connection-state?session_id=${sessionId}`)
|
||||||
|
if (resp.status !== 200) {
|
||||||
|
throw new Error(`ws-connection-state failed (${resp.status}): ${resp.body}`)
|
||||||
|
}
|
||||||
|
const data = json(resp.body)
|
||||||
|
output.CUSTOMER_CONNECTED = String(data.customer_connected)
|
||||||
|
output.MITRA_CONNECTED = String(data.mitra_connected)
|
||||||
26
client_app/.maestro/scripts/send_chat_message_as_mitra.js
Normal file
26
client_app/.maestro/scripts/send_chat_message_as_mitra.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Send a chat message AS the mitra of the given session via the dev-only
|
||||||
|
// /internal/_test/send-chat-message-as-mitra endpoint. Goes through the
|
||||||
|
// real chat.service.sendMessage code path. Returns which transport
|
||||||
|
// carried the message — `websocket` if the customer's WS was alive at
|
||||||
|
// dispatch time, `fcm` otherwise.
|
||||||
|
//
|
||||||
|
// Env:
|
||||||
|
// SESSION_ID (required)
|
||||||
|
// CONTENT (required, message body)
|
||||||
|
// BACKEND_INTERNAL_URL (optional, default http://localhost:3001)
|
||||||
|
const sessionId = SESSION_ID
|
||||||
|
const content = CONTENT
|
||||||
|
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
|
||||||
|
if (!sessionId) throw new Error('SESSION_ID env not set')
|
||||||
|
if (!content) throw new Error('CONTENT env not set')
|
||||||
|
|
||||||
|
const resp = http.post(`${url}/internal/_test/send-chat-message-as-mitra`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ session_id: sessionId, content }),
|
||||||
|
})
|
||||||
|
if (resp.status !== 200) {
|
||||||
|
throw new Error(`send-chat-message-as-mitra failed (${resp.status}): ${resp.body}`)
|
||||||
|
}
|
||||||
|
const data = json(resp.body)
|
||||||
|
output.MESSAGE_ID = data.message_id
|
||||||
|
output.DELIVERED_VIA = data.delivered_via
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart' as ph;
|
import 'package:permission_handler/permission_handler.dart' as ph;
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
@@ -14,11 +16,29 @@ enum NotifPermStatus { notDetermined, granted, denied }
|
|||||||
/// - iOS uses Firebase Messaging (which surfaces the system UNNotification
|
/// - iOS uses Firebase Messaging (which surfaces the system UNNotification
|
||||||
/// authorization status).
|
/// authorization status).
|
||||||
/// - Android 13+ uses `permission_handler` for `Permission.notification`
|
/// - Android 13+ uses `permission_handler` for `Permission.notification`
|
||||||
/// (POST_NOTIFICATIONS runtime). Older Android always reports granted.
|
/// (POST_NOTIFICATIONS runtime).
|
||||||
|
/// - Android <13: POST_NOTIFICATIONS doesn't exist as a runtime permission,
|
||||||
|
/// so `permission_handler` returns `granted` regardless of the user's
|
||||||
|
/// Settings → Apps → Notifications toggle. We add a
|
||||||
|
/// `NotificationManagerCompat.areNotificationsEnabled()` pre-check via
|
||||||
|
/// `flutter_local_notifications` to honour that user-facing toggle on
|
||||||
|
/// older Android.
|
||||||
class NotifPermission {
|
class NotifPermission {
|
||||||
const NotifPermission();
|
const NotifPermission();
|
||||||
|
|
||||||
Future<NotifPermStatus> readStatus() async {
|
Future<NotifPermStatus> readStatus() async {
|
||||||
|
// Older Android: permission_handler can't see the
|
||||||
|
// Settings → Apps → Notifications toggle, so we ask the local-notifs
|
||||||
|
// plugin (backed by NotificationManagerCompat.areNotificationsEnabled,
|
||||||
|
// which works from API 19). If disabled there, surface it as `denied`
|
||||||
|
// so the SHome1st banner reflects reality on API 24-32.
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final androidImpl = FlutterLocalNotificationsPlugin()
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin>();
|
||||||
|
final enabled = await androidImpl?.areNotificationsEnabled();
|
||||||
|
if (enabled == false) return NotifPermStatus.denied;
|
||||||
|
}
|
||||||
final phStatus = await ph.Permission.notification.status;
|
final phStatus = await ph.Permission.notification.status;
|
||||||
return _mapPh(phStatus);
|
return _mapPh(phStatus);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,22 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
|
|||||||
// "Curhat lagi" flow stamped the targeted mitra onto the draft before
|
// "Curhat lagi" flow stamped the targeted mitra onto the draft before
|
||||||
// payment, so we fire the targeted request and bounce to the dedicated
|
// payment, so we fire the targeted request and bounce to the dedicated
|
||||||
// wait overlay; everything else is a general blast.
|
// wait overlay; everything else is a general blast.
|
||||||
final state = ref.read(pairingProvider);
|
//
|
||||||
if (state is PairingInitialData) {
|
// Carry-over guard: if a previous chat session ended, pairingProvider
|
||||||
|
// retains its terminal state (PairingActiveData with the *old*
|
||||||
|
// sessionId, PairingFailed, PairingCancelled, etc). Without resetting
|
||||||
|
// here, the `state is PairingInitialData` branch wouldn't fire and
|
||||||
|
// `_onPairingState` below would re-emit a stale PairingActiveData →
|
||||||
|
// /chat/session/<old_sessionId>, dropping the customer on a chat
|
||||||
|
// screen for a `completed` session ("Sesi sudah berakhir"). Reset to
|
||||||
|
// Initial whenever we have a fresh payment to consume.
|
||||||
final draft = ref.read(paymentDraftNotifierProvider);
|
final draft = ref.read(paymentDraftNotifierProvider);
|
||||||
|
var state = ref.read(pairingProvider);
|
||||||
|
if (state is! PairingInitialData && draft.paymentId != null) {
|
||||||
|
ref.read(pairingProvider.notifier).reset();
|
||||||
|
state = ref.read(pairingProvider);
|
||||||
|
}
|
||||||
|
if (state is PairingInitialData) {
|
||||||
if (draft.paymentId != null) {
|
if (draft.paymentId != null) {
|
||||||
if (draft.targetedMitraId != null) {
|
if (draft.targetedMitraId != null) {
|
||||||
// ignore: discarded_futures
|
// ignore: discarded_futures
|
||||||
|
|||||||
@@ -40,13 +40,22 @@ class App extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<App> createState() => _AppState();
|
ConsumerState<App> createState() => _AppState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppState extends ConsumerState<App> {
|
class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
|
||||||
bool _fcmRegistered = false;
|
bool _fcmRegistered = false;
|
||||||
bool _authProvidersPreloaded = false;
|
bool _authProvidersPreloaded = false;
|
||||||
|
// Tracks whether the OS has paused/detached this isolate. The
|
||||||
|
// activeSessionProvider runs a 15s poll (see active_session_notifier.dart);
|
||||||
|
// each tick fires the listener below, which would otherwise re-open the
|
||||||
|
// chat WebSocket immediately after didChangeAppLifecycleState closed it
|
||||||
|
// — defeating the WS→FCM fallback. We gate the reconnect on this flag so
|
||||||
|
// the WS stays closed while the app is backgrounded, even as polling
|
||||||
|
// continues.
|
||||||
|
bool _appPaused = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
// Phase 4: preload server-driven auth-provider gating once on cold start.
|
// Phase 4: preload server-driven auth-provider gating once on cold start.
|
||||||
// Cached via @Riverpod(keepAlive: true) — subsequent reads are instant.
|
// Cached via @Riverpod(keepAlive: true) — subsequent reads are instant.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -56,6 +65,45 @@ class _AppState extends ConsumerState<App> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
// Background → close the chat WebSocket so backend `sendMessage` falls
|
||||||
|
// back to FCM (chat.service.js:51 — `if (!delivered) sendPushNotification`).
|
||||||
|
// Without this, Android keeps the TCP socket alive after the Dart
|
||||||
|
// isolate is paused, so the backend believes the customer is online and
|
||||||
|
// never fires the push — the user sees no alert until they reopen the
|
||||||
|
// app. See flow_customer.mermaid.md §5 (chat room) and the
|
||||||
|
// implementation note in main.dart's activeSession listener.
|
||||||
|
//
|
||||||
|
// Foreground → re-establish the WS for the current active session, if
|
||||||
|
// any. activeSessionProvider's last cached snapshot drives the target
|
||||||
|
// session id; the chat notifier's `connectIfNotConnected` is a no-op
|
||||||
|
// when the same session is already wired up.
|
||||||
|
final notifier = ref.read(chatProvider.notifier);
|
||||||
|
if (state == AppLifecycleState.paused ||
|
||||||
|
state == AppLifecycleState.detached) {
|
||||||
|
_appPaused = true;
|
||||||
|
if (notifier.connectedSessionId != null) {
|
||||||
|
notifier.disconnect();
|
||||||
|
}
|
||||||
|
} else if (state == AppLifecycleState.resumed) {
|
||||||
|
_appPaused = false;
|
||||||
|
final snapshot = ref.read(activeSessionProvider).valueOrNull;
|
||||||
|
final sessionId = snapshot?.sessionId;
|
||||||
|
if (sessionId != null &&
|
||||||
|
(snapshot?.hasSession ?? false) &&
|
||||||
|
notifier.connectedSessionId != sessionId) {
|
||||||
|
notifier.connectIfNotConnected(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _registerFcmToken() {
|
void _registerFcmToken() {
|
||||||
if (_fcmRegistered) return;
|
if (_fcmRegistered) return;
|
||||||
_fcmRegistered = true;
|
_fcmRegistered = true;
|
||||||
@@ -88,6 +136,12 @@ class _AppState extends ConsumerState<App> {
|
|||||||
// active session, regardless of which screen is mounted. The chat screen
|
// active session, regardless of which screen is mounted. The chat screen
|
||||||
// only joins this connection — it doesn't own it. FCM remains the
|
// only joins this connection — it doesn't own it. FCM remains the
|
||||||
// background-only fallback.
|
// background-only fallback.
|
||||||
|
//
|
||||||
|
// Gate on `_appPaused`: activeSessionProvider runs a 15s poll that fires
|
||||||
|
// this listener on every tick. If we reconnect while the app is
|
||||||
|
// backgrounded, we undo the disconnect that didChangeAppLifecycleState
|
||||||
|
// just performed and the FCM fallback never triggers for messages that
|
||||||
|
// arrive during background.
|
||||||
ref.listen(activeSessionProvider, (prev, next) {
|
ref.listen(activeSessionProvider, (prev, next) {
|
||||||
final snapshot = next.valueOrNull;
|
final snapshot = next.valueOrNull;
|
||||||
final notifier = ref.read(chatProvider.notifier);
|
final notifier = ref.read(chatProvider.notifier);
|
||||||
@@ -97,6 +151,7 @@ class _AppState extends ConsumerState<App> {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (_appPaused) return;
|
||||||
final sessionId = snapshot.sessionId;
|
final sessionId = snapshot.sessionId;
|
||||||
if (sessionId != null && notifier.connectedSessionId != sessionId) {
|
if (sessionId != null && notifier.connectedSessionId != sessionId) {
|
||||||
notifier.connectIfNotConnected(sessionId);
|
notifier.connectIfNotConnected(sessionId);
|
||||||
|
|||||||
@@ -143,6 +143,13 @@ class MitraChat extends _$MitraChat {
|
|||||||
WebSocketChannel? _channel;
|
WebSocketChannel? _channel;
|
||||||
StreamSubscription? _wsSubscription;
|
StreamSubscription? _wsSubscription;
|
||||||
Timer? _typingTimer;
|
Timer? _typingTimer;
|
||||||
|
// Survives `disconnect()` so a later `didChangeAppLifecycleState(resumed)`
|
||||||
|
// can re-issue `connect(sessionId)` with the right session — disconnect()
|
||||||
|
// resets `state` to MitraChatInitialData, which is otherwise the only
|
||||||
|
// record of which chat we were attached to.
|
||||||
|
String? _connectedSessionId;
|
||||||
|
|
||||||
|
String? get connectedSessionId => _connectedSessionId;
|
||||||
|
|
||||||
ApiClient get _apiClient => ref.read(apiClientProvider);
|
ApiClient get _apiClient => ref.read(apiClientProvider);
|
||||||
|
|
||||||
@@ -150,6 +157,7 @@ class MitraChat extends _$MitraChat {
|
|||||||
MitraChatData build() => const MitraChatInitialData();
|
MitraChatData build() => const MitraChatInitialData();
|
||||||
|
|
||||||
Future<void> connect(String sessionId) async {
|
Future<void> connect(String sessionId) async {
|
||||||
|
_connectedSessionId = sessionId;
|
||||||
state = const MitraChatConnectingData();
|
state = const MitraChatConnectingData();
|
||||||
try {
|
try {
|
||||||
final sessionInfo = await _apiClient.get('/api/shared/chat/$sessionId/info');
|
final sessionInfo = await _apiClient.get('/api/shared/chat/$sessionId/info');
|
||||||
@@ -222,6 +230,7 @@ class MitraChat extends _$MitraChat {
|
|||||||
|
|
||||||
void disconnect() {
|
void disconnect() {
|
||||||
_cleanup();
|
_cleanup();
|
||||||
|
_connectedSessionId = null;
|
||||||
// State reset is deferred to post-frame: disconnect() is called from
|
// State reset is deferred to post-frame: disconnect() is called from
|
||||||
// mitra_chat_screen's deactivate() during back-nav, and a synchronous
|
// mitra_chat_screen's deactivate() during back-nav, and a synchronous
|
||||||
// `state =` here notifies watchers while the chat screen's widget tree is
|
// `state =` here notifies watchers while the chat screen's widget tree is
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'core/api/api_client_provider.dart';
|
import 'core/api/api_client_provider.dart';
|
||||||
import 'core/auth/auth_notifier.dart';
|
import 'core/auth/auth_notifier.dart';
|
||||||
|
import 'core/chat/mitra_chat_notifier.dart';
|
||||||
import 'core/status/status_notifier.dart';
|
import 'core/status/status_notifier.dart';
|
||||||
import 'core/chat/chat_request_notifier.dart';
|
import 'core/chat/chat_request_notifier.dart';
|
||||||
import 'core/chat/widgets/chat_request_overlay.dart';
|
import 'core/chat/widgets/chat_request_overlay.dart';
|
||||||
@@ -30,6 +31,13 @@ class App extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
|
class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
|
||||||
bool _fcmRegistered = false;
|
bool _fcmRegistered = false;
|
||||||
|
// Session the chat WS was on at the moment we backgrounded. Restored on
|
||||||
|
// resume so a backgrounded mitra reconnects to the same chat once they
|
||||||
|
// foreground the app. Mirrors the customer-app fix (main.dart on the
|
||||||
|
// client side) — backend's sendMessage checks recipient WS readyState
|
||||||
|
// before falling back to FCM, so leaving the WS open while paused makes
|
||||||
|
// FCM never fire and the mitra misses customer messages in background.
|
||||||
|
String? _pausedChatSessionId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -47,8 +55,24 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
|
|||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) {
|
if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) {
|
||||||
ref.read(onlineStatusProvider.notifier).onAppPaused();
|
ref.read(onlineStatusProvider.notifier).onAppPaused();
|
||||||
|
// Close the chat WS so backend `sendMessage` falls back to FCM when
|
||||||
|
// the customer sends a message. Stash the active session_id so we
|
||||||
|
// can rejoin it on resume.
|
||||||
|
final chatNotifier = ref.read(mitraChatProvider.notifier);
|
||||||
|
final sid = chatNotifier.connectedSessionId;
|
||||||
|
if (sid != null) {
|
||||||
|
_pausedChatSessionId = sid;
|
||||||
|
chatNotifier.disconnect();
|
||||||
|
}
|
||||||
} else if (state == AppLifecycleState.resumed) {
|
} else if (state == AppLifecycleState.resumed) {
|
||||||
ref.read(onlineStatusProvider.notifier).onAppResumed();
|
ref.read(onlineStatusProvider.notifier).onAppResumed();
|
||||||
|
// Reconnect to the chat we backgrounded out of, if any.
|
||||||
|
final saved = _pausedChatSessionId;
|
||||||
|
_pausedChatSessionId = null;
|
||||||
|
if (saved != null) {
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(mitraChatProvider.notifier).connect(saved);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user