A missing brace in a home.html inline <script> caused an app-wide login
outage (the whole block failed to parse, so boot never ran). Nothing in the
deploy path checked HTML syntax. Add tools/check-syntax.js: parses (never
runs) every inline <script> in server/public/*.html with vm and exits non-zero
on any syntax error, reporting the file + line.
Wired in two places:
- Dockerfile RUN after COPY server/ ./ — a syntax error now FAILS the build,
so it can't deploy.
- redeploy.sh pre-flight — fast local feedback before triggering the remote
build.
Verified: passes on current pages (16 blocks / 9 files); fails with the exact
location when a brace is removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Production outage: after login the app hung forever on the splash. Chrome
showed "Uncaught SyntaxError: Unexpected token ')' at home:4398". The
callConnected listener's arrow body `()=>{ ... }` was missing its closing
brace (brace balance +1 on the line), so the whole inline <script> block
failed to parse — every boot function (loadSidebar, the boot IIFE) was never
defined, so the splash never hid. The login screen still rendered because it
lives in an earlier script block; only this block died.
The bug rode in with the Increment-2a callConnected wiring and was masked for
a while by service-worker caching (users' "quit and reopen fixed it"); once
the broken home.html was served fresh everywhere it became a total login hang,
on web and desktop alike. Verified all inline blocks parse after the fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Production outage: logins (and eventually all requests) hung forever. Root
cause: the pg Pool was max:10 with NO connectionTimeoutMillis, so once all 10
connections were busy/stuck, every new query waited indefinitely for a free
client — the "login spinner that never ends". The reject path stayed fast
(one brief query); the success path needs several writes and blocked on
acquiring a connection. Server-side only (reproduced in a desktop browser).
Add fail-fast + self-healing pool config (all env-tunable):
- connectionTimeoutMillis (10s): callers error instead of hanging on a
saturated pool.
- statement_timeout (60s): kill a runaway query so it can't pin a connection.
- idle_in_transaction_session_timeout (60s): reclaim a client left idle in a
hung transaction (the classic slow pool leak).
- max 10 -> 20 for headroom; pool 'error' handler so a broken idle client
can't crash the process.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the iOS native-video path on Android, gated by ANDROID_NATIVE_VIDEO.
- CallVideo (new): hole-punch (transparent WebView over a black parent with a
container behind) + per-tile LiveKit TextureViewRenderer, positioned from the
web's CSS-px tile rects scaled by display density. TextureView (not Surface)
so it composites behind the transparent WebView. Local camera mirrored;
camera = aspect-fill, screen = aspect-fit; stale renderers released.
- CallMedia: setCameraEnabled / switchCamera (LocalVideoTrack.switchCamera) +
cameraTrack/screenTrack lookup (by LiveKit identity = mesh peerId) +
initRenderer. All LiveKit 2.29.2 APIs verified against source.
- NativeCallPlugin: setCamera / switchCamera / setHolePunch / syncVideoTiles get
real bodies (+ CAMERA runtime-permission prompt, video teardown on endCall).
Screen-share (MediaProjection), tile zoom and #5 transcript stay no-ops.
Web needs no change: the shared NativeCall bridge already drives these for any
meetNative call; bzNativeVideoOn() enables hole-punch + the tile poll on Android
once the flag is on. Flip ANDROID_NATIVE_VIDEO=1 after a 2b APK is installed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On an Android native call the WebView turned on hole-punch (transparent
meeting grid, so native video can show BEHIND the WebView) whenever the
plugin exposed setHolePunch — but on the 2a audio-only build there is no
native video behind it, so the transparent grid revealed the white Android
window (reported: white meeting background; tiles stayed visible as they're
opaque web elements). iOS never showed this because CallKit covers the view.
Gate hole-punch on native video actually existing: new ANDROID_NATIVE_VIDEO
server flag (mirrors ANDROID_NATIVE_MEDIA, default off) exposed via
/api/meetings/config. Web: bzNativeVideoOn() — iOS always, Android only when
the flag is on (Increment 2b APK). While off, hole-punch stays off and the
grid keeps its dark background; the native-tile poll doesn't run. iOS
unchanged. Flip the flag on once the 2b native-video APK is installed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JitPack fix worked; two new failures surfaced, both fixed here:
1) checkDebugDuplicateClasses: LiveKit 2.29.2 already BUNDLES a fork
(com.github.davidliu:audioswitch) carrying the same com.twilio.audioswitch
classes at runtime, so our explicit `implementation com.twilio:audioswitch`
put both on the runtime classpath. Switch it to `compileOnly` — we get the
AudioDevice types at compile time, runtime uses only LiveKit's bundled fork.
(Also clears the "namespace used in multiple modules" merge warning.)
2) CallMedia.kt: `Unresolved reference: AudioOptions`. In 2.29.2 AudioOptions
is declared in package io.livekit.android (inside LiveKitOverrides.kt), not
io.livekit.android.audio. Corrected the import. Verified AudioSwitchHandler's
API (registerAudioDeviceChangeListener / availableAudioDevices /
selectedAudioDevice / selectDevice) against the v2.29.2 source.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codemagic build failed at :app:checkDebugAarMetadata — "Could not find
com.github.davidliu:audioswitch". LiveKit Android 2.29.2 pulls in that
JitPack-only fork (commit-hash version), and Gradle resolves the whole
:app transitive graph against the APP module's (+ allprojects) repos, not
the native-call plugin's. The plugin-level jitpack repo was therefore
ignored. android-patch.js now injects `maven { url 'https://jitpack.io' }`
into the generated root build.gradle's allprojects{} (and settings.gradle
dependencyResolutionManagement{} if a newer template uses it). Idempotent,
build-tolerant. TECHSTACK rev 3 note updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LiveKit Android 2.29.2 + audioswitch + Kotlin in the native-call plugin, the
microphone foreground service, the ANDROID_NATIVE_MEDIA flag, and minSdk 24.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces WebView call media on Android with a native LiveKit connection (iOS
parity), so call audio survives backgrounding and the output can be switched
to Speaker/Bluetooth/Earpiece — the #3 (missing speaker control) and #4
(background audio) items.
Gated behind a new, default-off server flag ANDROID_NATIVE_MEDIA (independent
of ANDROID_CALLS, which only governs the native ring). Flag off = today's
WebView media, video included; flag on = native audio (video is Increment 2b,
so enable it for VOICE testing first). No behavior change until flipped.
Native (mobile/plugins/native-call/android):
- build.gradle: add kotlin-android + io.livekit:livekit-android 2.29.2 (+ twilio
audioswitch, coroutines) + JitPack repo; minSdk 24 (LiveKit floor).
- CallMedia.kt: LiveKit Room wrapper — connect/mic/disconnect + AudioSwitchHandler
routing (auto + selectDevice) and route-change events.
- CallForegroundService.java: microphone-type foreground service for background
audio (no-ops without RECORD_AUDIO so it never crashes the call).
- NativeCallPlugin.java: real bodies for reportOutgoingCall/reconnectRoom/setMuted
/endCall + new setAudioRoute/getAudioRoute; fires callConnected/callError/audioRoute.
- AndroidManifest: FG service + FOREGROUND_SERVICE(_MICROPHONE)/RECORD_AUDIO/
MODIFY_AUDIO_SETTINGS/BLUETOOTH_CONNECT.
- android-patch.js: bump the generated app minSdk to 24.
Server:
- config.js/routes.js: ANDROID_NATIVE_MEDIA → /api/meetings/config.androidNativeMedia.
Web (home.html):
- Android flips to the native-media path when androidNativeMedia is set; the
existing outgoing/answer/mic/end code then drives the plugin unchanged.
- Speaker button wired for Android native: cycles speaker/earpiece/bluetooth via
NC.setAudioRoute and reflects the real output from the audioRoute event.
Video stays no-op on the native path (Increment 2b).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A user signed in on two devices (iPhone + Android) rang on both. When they
answered on Android, the iPhone kept ringing: markDmAnswered only emitted a
WS 'call-taken', which never reaches a backgrounded/killed device ringing on
CallKit (no live socket).
Now markDmAnswered also pushes a native cancel to the answerer's OTHER
devices. The device that actually answered is skipped via exceptToken (its own
call-push token) — critical on iOS, where a cancel would end the just-answered
CallKit call; Android's cancel branch never tears down a live call anyway.
- push.sendCallCancel(userId, uuid, { exceptToken, reason }) — skips the
answering device's token; existing 2-arg callers unchanged.
- calls.markDmAnswered(room, userId, exceptToken) — fires the cancel.
- POST /api/calls/answered now takes deviceToken.
- home.html: the native answerCall handler POSTs /api/calls/answered with this
device's own token (ios-voip or fcm), so native/LiveKit calls (which bypass
the mesh) are marked answered and siblings stop ringing.
Server + web only — no native build needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The incoming ring is driven by our own Ringer (we aren't a telecom
ConnectionService yet), so the OS doesn't route volume keys to silence
it. Capture them two ways that together cover every state:
- Foreground/locked: IncomingCallActivity.dispatchKeyEvent consumes
VOLUME_UP/DOWN/MUTE and silences.
- Unlocked/backgrounded heads-up (no activity): a MediaSession with a
remote VolumeProvider so the framework delivers volume presses to us.
Silence stops audio + vibration but keeps the call ringing visually
(notification + full-screen stay) so answer/decline still work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Full stack: plain Node (http/https + ws, custom router, no framework),
PostgreSQL 16 (SQLite retired), vanilla-JS web SPA, Capacitor 8 mobile with
custom native-call/audio/media/share plugins, two Electron clients, LiveKit
SFU + WebRTC mesh, Web Push/FCM/APNs, Docker + Nginx Proxy Manager, Codemagic
CI. Includes a maintenance rule + changelog so it's kept current.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On an unlocked Android 14 phone the OS shows our call as a heads-up and does
NOT launch the full-screen IncomingCallActivity (full-screen-intent is
restricted for non-dialer apps while unlocked), so the ring — which lived in
the activity — was silent when unlocked (it rang only when locked, where the
activity launches).
Move the ring to a call-scoped Ringer (looping ringtone + vibration) started
in CallStore.showIncoming and stopped in CallStore.cancel (which every
answer/decline/cancel/end path already calls). The notification channel stays
silent, so there's still a single ring source. IncomingCallActivity no longer
rings — it's just UI — and no longer stops the ring on destroy (backgrounding
the call screen keeps ringing like a real call until answer/decline/timeout).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sendCallNotification returned after the iOS VoIP branch, so a user signed in
on both an iPhone (ios-voip token) and an Android phone only got the call on
iOS — the Android invite was never sent, so a swiped-away Android phone never
rang (confirmed on-device: only type=cancel reached Android, never
type=invite, since sendCallCancel does fan out to all platforms). Now send to
iOS VoIP AND Android FCM, and only fall back to the banner when no native
device was waked. Server-only; no app rebuild.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On-device (OnePlus, build 5cb3d0c): new ring UI confirmed; backgrounded rang
twice (channel sound + activity ringtone, and both wake paths raised it);
battery dialog deemed unprofessional (WhatsApp/Teams don't prompt it).
- Double-ring: notification channel is now SILENT (new id bzc_incoming_calls_v2,
no sound/vibration, onlyAlertOnce) so IncomingCallActivity is the single ring
source (looping ringtone + vibrator). showIncoming dedups atomically
(if(!active.add(uuid)) return) so the FCM push and WebSocket paths can't both
ring the same call.
- Battery dialog removed: dropped requestCallReliability (web call + plugin
method) and REQUEST_IGNORE_BATTERY_OPTIMIZATIONS permission.
- Logging: Log.i(bzcall, ...) across the wake chain to diagnose swiped-away
(OnePlus force-kills on swipe and blocks FCM cold-start).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses on-device feedback from Increment 1:
- #2 Professional incoming screen: IncomingCallActivity redesigned — brand-
blue backdrop, circular avatar with the caller's initial, name + call
type, and two round icon buttons (red hang-up = Decline, green phone =
Accept) instead of the full-screen colored buttons. New vector drawables
ic_call_answer / ic_call_decline (also used on the notification actions).
- #1 Killed-state ring: the missed-call notification arriving while killed
proves FCM reaches the app, so the ring (high-priority DATA) is being
held by OEM battery management (OnePlus/Oppo Doze) until the call is over.
Added REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + a requestCallReliability()
plugin method that prompts the "allow background activity" dialog once;
the web calls it on Android call setup. (OEM auto-launch is a manual
toggle with no API — guide the user.)
#3 (speaker/Bluetooth) and #4 (background audio) ride Increment 2 (native
LiveKit Android media + foreground service) — kept separate so the LiveKit
dependency can't block these UI/reliability fixes (the iOS coupling lesson).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice of Android Phase C (full LiveKit parity), mirroring how iOS
started: native full-screen incoming call UI that wakes the app from
closed, with call MEDIA still in the WebView (foreground). Native LiveKit
media + a foreground service for true background audio are Increment 2.
Native (new Android side of the native-call plugin, Java to match the
proven official-plugin module layout):
- NativeCallPlugin: registered as window.Capacitor.Plugins.NativeCall
(same jsName as iOS, so the shared web bridge drives both). getToken
(FCM token), reportIncomingCall (ring), reportOutgoingCall, endCall,
setMuted; video/screen/hole-punch/transcript methods are safe no-ops
until later increments so the shared bridge never rejects.
- BzCallMessagingService: extends @capacitor/push-notifications'
MessagingService — handles high-priority call-wake DATA messages
(data.bzcall==1) to ring when closed, forwards all other push via super.
- IncomingCallActivity: full-screen call screen over the lock screen
(showWhenLocked/turnScreenOn), Accept/Decline, looping ringtone.
- CallStore: incoming-call notification (channel, full-screen-intent,
actions) + UUID dedup so the FCM and WebSocket paths don't double-ring.
- Plugin manifest: BzCallMessagingService + IncomingCallActivity + call
perms (USE_FULL_SCREEN_INTENT/VIBRATE/WAKE_LOCK).
- android-patch.js: removes Capacitor's MessagingService in the APP
manifest (highest merge priority) so ours is the sole MESSAGING_EVENT
receiver; ensures the tools namespace. Idempotent.
- package.json: capacitor.android.src wired.
Server (dormant until ANDROID_CALLS=1):
- push.js sendFcmData: high-priority data-only FCM (rings when closed).
sendCallNotification rings 'android' tokens via it; sendCallCancel too.
- config.js ANDROID_CALLS flag; /api/meetings/config exposes androidCall.
Web (home.html) — platform-aware, iOS/web behavior byte-for-byte unchanged:
- setupNativeCall: Android gated on cfg.androidCall, sets _callWebViewMedia
(native ring, WebView media); iOS keeps CallKit native-media path.
- answerCall + both outgoing sites branch on _callWebViewMedia so Android
joins the meeting normally while the callee still rings via the server.
Safe to deploy now: dormant until ANDROID_CALLS=1 AND an APK carrying the
plugin is installed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device logs showed Android's interactive-widget=resizes-content already shrinks
the layout viewport to above the keyboard (innerHeight 800->472 for a 328px
keyboard), yet the app also lifted the composer by --kb=328 → a keyboard-height
white gap. Now gate the manual --kb lift to iOS only (WKWebView doesn't resize);
on Android keep --kb=0 and just toggle kb-open so --sab collapses and the composer
sits flush to the keyboard. Also removes the temporary kbdiag logging.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User picked the brand-blue look for both platforms. Apply .sysbar-blue on any
native platform; set white icons via the safe-area plugin on Android and via
@capacitor/status-bar on iOS. capacitor.config statusBarStyle/navigationBarStyle
-> DARK (light icons) so native builds match before JS runs. iOS is live, so this
web change reaches it immediately — verify on an iPhone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Swap the class to sysbar-sep (thin 1.5px brand line under the status bar + above
the nav bar, bars stay white) and icons back to dark. Second of the two looks for
the user to compare; pick one after this.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fill the status/nav inset regions with brand blue (#1F3B73) via html.sysbar-blue
pseudo-elements and switch the bar icons to light so they read on blue. This is
one of two looks for the user to compare on-device; Option 2 (white + separator)
is already in the CSS and swaps in by changing the class + icon style.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Behavior-neutral console logging on keyboard show/hide (kb height, innerHeight,
clientHeight, visualViewport height, --kb) to determine whether the Android
WebView resizes (interactive-widget) or relies on the manual --kb lift, and to
spot any residual between the first and second open. Remove once fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Under Android edge-to-edge the status/nav bar icons default to light (white) and
vanished on the white app (clock/battery/nav invisible). Set SafeArea
statusBarStyle/navigationBarStyle to LIGHT (= dark content on a light background)
in capacitor.config.json, and add a runtime SafeArea.setSystemBarsStyle call in
home.html so already-installed APKs get dark icons on relaunch (no rebuild). iOS
unaffected (its status bar already shows dark content on white).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Base64 is a single env-safe token, so the FCM service-account key can live in
.env without the quoting/interpolation hazards of inline JSON. Falls back to the
existing FCM_SERVICE_ACCOUNT (inline JSON or file path). No behavior change when
neither is set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Commit the client Firebase config (bizgaze-connect, package com.bizgaze.connect)
and have the Codemagic Android step copy it into android/app/ so the google-services
Gradle plugin applies and FirebaseApp initializes — fixing the root cause of the
"Default FirebaseApp is not initialized" crash. google-services.json is client-side
(ships in the APK), safe for this private repo. The service-account key stays OUT of
git (.gitignore: *firebase-adminsdk*.json / *service-account*.json) — it goes on the
server as FCM_SERVICE_ACCOUNT separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The safe-area plugin logged: set SystemBars.insetsHandling to "disable". Under
Android edge-to-edge (targetSdk 36) this + offsetForKeyboardInsetBug were causing
a black strip between the input and the soft keyboard on the login view. Set
SystemBars.insetsHandling="disable" and SafeArea.offsetForKeyboardInsetBug=true.
Native config — takes effect on the next Android build; verify on device.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On Android, PushNotifications.register() calls FirebaseMessaging.getInstance(),
which throws "Default FirebaseApp is not initialized" when the build has no
google-services.json. Capacitor runs the plugin method on its own thread, so the
exception is an UNCAUGHT NATIVE crash a JS try/catch can't stop — the app died
right after sign-in and on every relaunch.
Gate Android push registration on a new server flag: /api/meetings/config now
returns fcm (push.fcmReady() = FCM_SERVICE_ACCOUNT present). setupNativePush skips
register() on Android unless fcm is true. Web-side fix — deploys without a rebuild;
push auto-enables once Firebase (client google-services.json + server FCM) is set up.
iOS/APNs unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@capacitor/android is ^8; Capacitor 8's Android toolchain (AGP 8.7 / Gradle 8.11)
requires JDK 21. The workflow used java: 17, which fails the Gradle build before an
APK is produced. Bump to java: 21.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The build step ended with `find … *.apk`, which exits 0 even when Gradle failed
and no APK exists — so Codemagic marked a failed Gradle run as a green build with
no artifact. Now: capture gradle output, use PIPESTATUS to detect failure, print
the "What went wrong" / FAILURE block, exit non-zero, and assert an APK exists.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The android-apk workflow requested linux_x2, which Codemagic rejected with
"selected instance type is not available with the current billing plan". Switch
to mac_mini_m2 — the same instance the iOS workflow already uses on this account;
Codemagic's macOS images include the Android SDK + JDK. Also make the SDK path
(ANDROID_SDK_ROOT -> ANDROID_HOME fallback) and the google-services base64 decode
(GNU --decode / BSD -D) portable to the macOS image.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#msgs .bubble had a blanket user-select:none (added to suppress iOS's long-press
magnifier), which also blocked mouse text-selection on desktop. Scope the
none to touch devices only (@media (hover:none)); keep it in multi-select mode.
Desktop can now highlight/copy message text. Verified: desktop=auto,
touch=none, sel-mode=none.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The conversation drop handler read dataTransfer.files[0] (a single file), so a
multi-file drag-and-drop uploaded just one. Iterate every dropped file, matching
the file-picker path. Verified in-browser: dropping 3 files queues + sends all 3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- codemagic.yaml: add an Android workflow that builds an installable debug
APK (Linux instance, no signing needed to test) — the Android equivalent of
the iOS TestFlight loop. mobile/scripts/android-patch.js injects the
camera/mic/notification permissions into the CI-generated manifest (android/
is gitignored, same as ios/).
- Delete chat (self-only): new POST /api/messages/clear bulk-hides a whole DM
(or group) for the requester via the existing per-user message_hidden
mechanism — the other party keeps their copy entirely. "Delete chat" button
added to the DM contact-info panel; chat-cleared synced to my other devices.
Verified end-to-end on Postgres (A clears -> empty for A, B untouched).
- Meetings copy: SFU is live, so drop the "coming soon" / "small group (mesh)"
wording on the welcome card and the Meetings header.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Capacitor build is universal (iPhone + iPad), so App Store Connect requires
iPad screenshots. Added three 2064x2752 (13" iPad) shots rendered on the app's
two-pane iPad layout (chat sidebar + open conversation, meetings, schedule),
anonymized. No rebuild needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
06-screenshare.png — a meeting with a shared screen (a slide + chart on the
stage, "Sharing" badge, participant tiles, controls), rendered on the real call
UI at 1320x2868 and anonymized. Updated the submission pack's screenshot order to
include it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebuild the iOS remote-support screen share the RIGHT way: keep the exact
Share/Connect UX + session (consent + symmetric session-ended), swap only the
media to LiveKit since WKWebView can't getDisplayMedia. (Replaces the earlier
"route to a meeting" detour, which was reverted.)
Flow: customer taps Share Screen -> gets a 6-digit code (unchanged UI) -> helper
enters it in Connect Screen -> on the customer's "Allow", the app publishes the
screen natively (ReplayKit -> LiveKit) into a per-session room and tells the
agent it's a LiveKit session -> connect.html joins that room and shows the screen
in its existing viewer (recording/controls intact). Chat runs over the session
socket (no P2P data channel in this mode). Either side ending fires the existing
session-ended -> both tear down (symmetric disconnect).
- signaling.js: relay 'rs-livekit' + 'rs-chat' between the two ends.
- home.html: parent bridge so the /share iframe can drive startMeetingScreenShare/
stop on the native plugin (+ a capability handshake).
- share.html (iOS): publish via native LiveKit instead of getDisplayMedia; chat
over WS; hide mic (voice = next iteration) + remote-control (impossible on iOS).
- connect.html: LiveKit viewer for iOS-shared sessions, reusing the P2P viewer.
Web-only, no new build (reuses the shipped startMeetingScreenShare). Desktop
Share/Connect P2P unchanged. Two-way voice is the planned follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fix the confusing flow: tapping Share Screen dumped the user straight into a
meeting and auto-shared, with the join code hard to find. Now it shows a clear
full-screen "Share your screen" step with the big 6-digit code + Copy + a
"Waiting for them to join…" spinner (and Cancel). The native screen broadcast
starts only when the helper actually joins with that code (meeting-peer-joined) —
matching the remote-support mental model.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The remote-support Share/Connect tabs use P2P WebRTC (screen + voice + chat) that
WKWebView can't capture, so "Share Screen" never worked on iPhone. Rather than
rebuild the whole P2P session on LiveKit, route iOS "Share Screen" to a
screen-share MEETING (chosen with the user): tapping Share Screen on iOS starts
an instant meeting, auto-starts the native ReplayKit screen share (the path just
verified on device), and toasts the join code. A helper joins by code (Meeting)
to watch — and gets voice + chat for free. Viewing already works in the webview,
so only the SHARE side is rerouted; desktop keeps the full P2P remote-support
flow, and iOS Safari (no app) is unchanged.
Web-only, no new build (reuses the shipped startMeetingScreenShare native method).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gap: on iOS, screen share only worked in native CallKit calls. In a scheduled /
code-joined SFU meeting the webview called setScreenShareEnabled() ->
getDisplayMedia(), which WKWebView does not implement, so it failed silently.
Production meetings are real LiveKit rooms, so we can publish natively instead.
Fix (native ReplayKit -> same LiveKit room as a dedicated screen participant):
- server: /api/meetings/token accepts screen:true -> mints a distinct
<peerId>-screen identity (LiveKit allows one connection per identity), so the
native publisher doesn't collide with the webview's own connection.
- native plugin: startMeetingScreenShare/stopMeetingScreenShare + connectScreenRoom
(a screen-only LiveKit connection: mic off, no camera, no callConnected) that
reuses the existing broadcast-extension publishing path.
- webview: toggleScreen routes to the native method on iOS SFU meetings; the
screenShareState listener now drives the SFU case too (and tears the screen
connection down on the system "Stop Broadcast"); sfuAttach/sfuDetach map the
'<peerId>-screen' participant's screen track onto the sharer's tile and suppress
the phantom person tile + the sharer's own self-view.
Web/server deploy now; the native method needs the next Codemagic build to test
on device. Verified: db-smoke 22/22; Swift braces balanced.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two more anonymized 1320x2868 shots of features that work on iOS: a group
conversation (sender chips, read receipts) and the Schedule-a-call flow
(participant names anonymized). Deliberately did NOT screenshot Share/Connect:
webview screen capture (getDisplayMedia) is unavailable in WKWebView, so the
remote-support screen-share tab doesn't function on iOS — advertising it would
be inaccurate metadata. (Screen share works in native calls via ReplayKit and
fully on desktop.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- mobile/appstore-screenshots/: 3 anonymized 1320x2868 screenshots generated from
the live app (Chats, a Conversation, Meetings). Real names/faces replaced with
demo identities, notification banner dismissed, internal roadmap copy removed.
- APPSTORE_SUBMISSION.md: pre-submission checklist now all green; Privacy Policy
and Support URLs filled with the live /privacy and /support pages; demo login
and export-compliance noted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
App Store submission needs a public Privacy Policy URL and Support URL, reachable
without login. Added branded, self-contained pages served at /privacy and
/support (static.js path mappings, like /home). The privacy policy accurately
describes Biz Connect: BizGaze-account login, messages/media stored on our
servers, call media via LiveKit (only recorded on explicit user action),
on-device transcription (audio never leaves the device), push via APNs/FCM, no
sale of data / no ads, retention, security, and user/GDPR rights. Contact:
support@bizgaze.com (confirm/replace if different).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tester feedback:
1. "Block contact not on the contact info." The earlier Block button was only on
the group-sender mini-profile, which a 1:1 chat never opens. A DM's info panel
is openSharedItems('dm',…) (tap the conversation header). Added a full-width
"Block contact / Unblock contact" button there.
2. "Reporter gets no notification on resolution." Delivery was actually working
(verified: the reporter receives the 'report-resolved' event over the chat WS),
but the client only added a silent bell entry — easy to miss — and a self-
resolve (same admin reported + resolved) is intentionally skipped. Made it a
visible toast + ping for an online reporter (report-new likewise toasts admins).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses tester feedback on the Report/Block feature:
1. Block/Unblock is now on the CONTACT's profile card (openMiniProfile), not only
in a message's ⋮/long-press menu — you can block someone straight from their
info popup.
2. Admin Delete/confirm popups were appearing BEHIND the Reports window. The
moderation modals used z-index 100000 (above bzConfirm's .modal-ov at 9800);
lowered them to 9750 so the confirm dialog sits on top.
3. Clarified admin action. "Block" is a PERSONAL mute (per guideline 1.2) and does
not touch login. The report view now offers a real account action instead:
"Suspend account" (deactivate -> signed out + cannot log in) with a confirm,
toggling to "Reactivate account" — both reversible in-place, driven by a new
reportedActive flag on /api/reports. (Uses the existing /api/users/manage
deactivate/activate.)
4. Resolving a report now notifies the reporter (live 'report-resolved' event +
background push), and admins get a 'report-new' activity entry.
repos: reports.byId. Verified: moderation suite 18/18 (adds reportedActive +
suspend->login-blocked->reactivate->login-restored).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause: the scheduled-meeting INVITE (routes.js) and the ~10-min REMINDER
(reminders.js) both notified only via CHAT.pushToUser — the WebSocket channel,
which only reaches an OPEN tab with a live socket. Neither called
PUSH.sendToUser, the path that produces a background APNs/FCM/web-push alert.
On iOS the webview is suspended in the background, so the WS event was simply
missed and no notification appeared. (Chat messages already call PUSH.sendToUser,
which is why chat notices arrive on iOS but meeting ones didn't.)
Fix:
- schedule invite: also PUSH.sendToUser to every invited participant + group
members (kind:'meeting', id:roomCode) so a closed app is notified.
- reminders.js: also PUSH.sendToUser to all reminder recipients.
- client: a kind:'meeting' notification tap now opens the Meeting tab + its list
(both the live-tab open-chat handler and the cold-boot openKind path), instead
of calling selectChat with an unsupported kind.
Also: APPSTORE_SUBMISSION.md §8 updated — the UGC Report/Block gate (guideline
1.2) is now implemented, with a suggested reviewer note.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apple requires user-generated-content apps to offer a way to report
objectionable content and block abusive users. The chat had neither, which is
the #1 rejection cause for messaging apps. Added both, server-enforced.
Server:
- schema: message_reports + user_blocks tables.
- repos: reports {add,listForTeam,setStatus}, blocks {add,remove,has,listFor},
users.adminsOf(); thread + threadByConversation now exclude blocked senders in
SQL (like message_hidden) so the LIMIT counts only visible rows (no pagination
stall).
- routes: POST /api/messages/report, /api/users/block|unblock, GET
/api/users/blocked, GET /api/reports + POST /api/reports/resolve (admin only).
- enforcement: a blocked sender's DM/group messages are persisted but not
delivered (no live push, no background notification) to anyone who blocked
them; blocked users can't ring you (/api/calls/dm/start + /api/calls/invite);
admins can delete reported content (delete route now allows role=admin).
Client (home.html, all platforms via the web UI — no rebuild):
- message menu gains Report (canned-reason picker) + Block/Unblock.
- profile menu: "Blocked users" manager (list + unblock) for everyone;
"Reported messages" review (delete / block / resolve) for admins.
- blocked DMs hidden from the sidebar; block list loaded on boot.
- reports route to the workspace's OWN admins (org-internal moderation).
Verified: db-smoke 22/22 + a new moderation suite 12/12 (report+admin-list,
non-admin 403, block hides post-block history but sender still sees sent,
blocked call 403, unblock restores history + calling). New flag/ban icons added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The iOS splash read as unbranded for two concrete reasons:
1. The light-mode master (resources/splash.png) was on a WHITE background
while the SplashScreen plugin paints #16294F navy -> a white->navy->app
flash on light-mode devices.
2. The logo + "Biz Connect" wordmark spanned ~82% of the 2732 square, but the
launch storyboard scales it scaleAspectFill; on tall iPhones ~27% of each
side is cropped, clipping the wordmark edges.
New splash (both light + dark masters, identical navy so there is no flash):
brand navy gradient (#20396f -> #16294f) matching the plugin background, the
C-mark as hero, and the wordmark typeset in Corbel (closest installed match to
the brand geometric wordmark) sized to 1041px -> inside the ~1260px aspectFill
safe zone, so nothing clips on any device.
Also hardened codemagic.yaml: the asset step used `|| echo skipped`, which
silently shipped Capacitor's blank default splash if generation failed. Now it
hard-verifies the generated iOS Splash.imageset exists and fails the build
otherwise. Rides the next Codemagic build (native asset; no server redeploy).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>