Compare commits

..
14 Commits
Author SHA1 Message Date
SravanandClaude Opus 4.8 db6c00cbcf fix(meet): send peerId (not user id) to setTileZoom so screen zoom works
Pinch-to-zoom on a received shared screen was a no-op on Android (and iOS).
syncVideoTiles keys each native renderer by the tile's peerId (uid=id, the
LiveKit identity since #12), but the pinch handler forwarded
meetPeerUids.get(id) — the USER id — to setTileZoom. The plugin looked up the
renderer by that mismatched key and found nothing, so the zoom transform never
applied. Send `id` (the peerId), matching syncVideoTiles. Web-only fix; the
native setTileZoom was already correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-11-03 22:49:02 +05:30
SravanandClaude Opus 4.8 1e01130c6f android(call): Increment 2c — screen-share + received-screen pinch-zoom
Screen-share (send): startScreenShare requests the MediaProjection grant via
startActivityForResult, then CallMedia.setScreenShareEnabled(true,
ScreenCaptureParams(data)) publishes it over the native LiveKit room.
stopScreenShare unpublishes. Both emit screenShareState so the web button
reflects reality (incl. a declined grant). LiveKit's SDK manifest already
provides the mediaProjection foreground service + FOREGROUND_SERVICE_MEDIA_
PROJECTION (merged into the app), so no manifest change is needed.

Received shared screen: implemented setTileZoom (was a no-op) — the web
captures pinch/pan under hole-punch and forwards {scale,tx,ty}; CallVideo
applies it as a renderer transform (scale about centre + translate ×density),
scale<=1 clears it. Mirrors the iOS applyZoom contract. Screen tiles already
render aspect-fit.

APK-only: the web bridge already calls these methods and received-screen
rendering is gated on ANDROID_NATIVE_VIDEO (already on) — no server/web change.
All LiveKit 2.29.2 APIs (setScreenShareEnabled, ScreenCaptureParams) verified
against source. Clarity-when-zoomed (adaptive-stream) is a follow-up if needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-11-03 22:14:42 +05:30
SravanandClaude Opus 4.8 a1f7eef28a build: pre-deploy HTML inline-script syntax guard
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>
2026-11-03 21:33:01 +05:30
SravanandClaude Opus 4.8 c9bb9c75d1 fix(home): close callConnected arrow body — syntax error broke app boot
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>
2026-11-03 21:24:57 +05:30
SravanandClaude Opus 4.8 0129489746 fix(db): pool safety timeouts to stop login hangs (connection exhaustion)
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>
2026-11-03 21:00:18 +05:30
SravanandClaude Opus 4.8 4baace83bc android(call): Increment 2b — native camera video + hole-punch tiles
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>
2026-11-03 20:26:31 +05:30
SravanandClaude Opus 4.8 ae607f44b5 fix(android-call): no hole-punch without native video (white meeting bg)
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>
2026-11-03 20:12:21 +05:30
SravanandClaude Opus 4.8 ea4d65274e android(call): fix LiveKit audio API import + audioswitch duplicate-class
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>
2026-10-30 20:42:00 +05:30
SravanandClaude Opus 4.8 7cd3c3afff android(build): inject JitPack repo so LiveKit's audioswitch fork resolves
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>
2026-10-30 19:11:23 +05:30
SravanandClaude Opus 4.8 0b3a8b0955 docs(techstack): record Android native call media (Increment 2a)
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>
2026-10-30 17:27:01 +05:30
SravanandClaude Opus 4.8 aa4aeeaba1 android(call): Increment 2a — native LiveKit call audio, background service, speaker/BT routing
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>
2026-10-30 17:25:18 +05:30
SravanandClaude Opus 4.8 08eff043ec call: stop the ring on a user's other devices when one answers (answered-elsewhere)
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>
2026-10-30 16:38:51 +05:30
SravanandClaude Opus 4.8 aa794102c3 android(call): volume key silences the ring like a real phone
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>
2026-10-29 22:10:17 +05:30
SravanandClaude Opus 4.8 7ec6c3f227 docs(techstack): add Identity & auth section
Document the auth/identity subsystem: crypto-only (scrypt + TOTP MFA),
opaque sliding sessions over cookie/bearer/query transports, rotating
native refresh tokens, BizGaze IdP + signed SSO, roles & tenancy,
bzc_ API keys, server-side directory search, and the audit log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-27 20:29:59 +05:30
19 changed files with 1136 additions and 50 deletions

No files matched your search

+5
View File
@@ -16,6 +16,11 @@ RUN npm install --omit=dev --no-audit --no-fund
# App source # App source
COPY server/ ./ COPY server/ ./
# Pre-deploy guard: FAIL the build if any inline <script> in the served HTML pages has a JS syntax error. A
# broken inline script takes down the whole page (one missing brace in home.html once caused an app-wide login
# outage). This turns that into a failed build instead of a production incident. See tools/check-syntax.js.
RUN node tools/check-syntax.js
# Served HTTP port (NPM terminates TLS and proxies here). The DB is Postgres (DATABASE_URL, set via .env); # Served HTTP port (NPM terminates TLS and proxies here). The DB is Postgres (DATABASE_URL, set via .env);
# the /data volume holds uploads / recordings / transcripts / downloads (see docker-compose.yml). # the /data volume holds uploads / recordings / transcripts / downloads (see docker-compose.yml).
ENV PORT=8090 ENV PORT=8090
+74 -5
View File
@@ -1,6 +1,6 @@
# Tech Stack — BizGaze Connect # Tech Stack — BizGaze Connect
> Living document. **Last updated: 2026-10-27.** Update this file whenever a dependency, platform, service, or > Living document. **Last updated: 2026-11-03** (rev 6). Update this file whenever a dependency, platform, service, or
> tool is added/removed/version-bumped. See [Maintaining this file](#maintaining-this-file) and the > tool is added/removed/version-bumped. See [Maintaining this file](#maintaining-this-file) and the
> [Changelog](#changelog) at the bottom. > [Changelog](#changelog) at the bottom.
@@ -38,6 +38,45 @@ file access). Real-time via WebSockets; meeting media via WebRTC (P2P mesh, or L
- **Cache / pub-sub:** **Redis 7** (`redis` `^4.7`) for cross-instance real-time fan-out (chat/presence) — - **Cache / pub-sub:** **Redis 7** (`redis` `^4.7`) for cross-instance real-time fan-out (chat/presence) —
**optional**, only when `PUBSUB_BACKEND=redis`; default is in-memory single-instance (`pubsub.js`). **optional**, only when `PUBSUB_BACKEND=redis`; default is in-memory single-instance (`pubsub.js`).
## Identity & auth
No external auth library — Node's built-in **`crypto`** only (`server/auth.js`): scrypt password hashing (64-byte
hash, per-user salt, timing-safe compare), opaque random session tokens, SHA-256-hashed token storage, and
**TOTP MFA** (RFC 6238, SHA-1, 30s, 6 digits) with `otpauth://` enrollment URLs.
- **Sessions** (`sessions_auth` table, resolved in `server/session.js`): one opaque bearer token accepted over
three transports — a `sid` **HttpOnly cookie** (web), `Authorization: Bearer <token>` (native desktop/mobile
+ WS upgrade), or a `?access_token=`/`?token=` query param (browser WS fallback). **90-day TTL, sliding** —
any `/api/me` hit re-stamps the cookie and pushes the expiry out, so active users stay signed in indefinitely
and only lapse after 90 days of no use (or logout).
- **Native refresh tokens** (`refresh_tokens`): 90-day, one-time-use **with rotation**, stored only as a hash;
`POST /api/auth/refresh` swaps a refresh token for a fresh access token so mobile/desktop never re-prompt for
a password.
- **MFA:** optional TOTP second factor. A `requireMfa` gate in `currentUser` holds a session back until
`mfa_passed`; flow is `/api/mfa/setup` (secret/QR) → `/api/mfa/enable` (confirm) → `/api/login/mfa` (step-2
code).
- **Identity providers:**
- **Local password** accounts — dev/test and self-contained runs.
- **BizGaze as IdP** (`server/bizgaze.js`, active when `BIZGAZE_LOGIN_URL` is set): credentials are verified
against BizGaze's `ValidateAndLogin` and BizGaze becomes the **sole authority** (local passwords rejected
unless `ALLOW_LOCAL_LOGIN=1`). A local user row is provisioned/synced (name, avatar, role, tenant) on every
login, keyed on the **BizGaze person-id** (not the typed email/phone) so signing in with either identifier
resolves to one account — legacy duplicates are auto-merged.
- **BizGaze SSO** (`GET /sso`): an HMAC-SHA256-signed, time-boxed token (`SSO_SECRET`) signs an agent straight
in (auto-provisioning the user) and redirects to `/connect`.
- **Roles & tenancy:** every user belongs to a **team (tenant)**; roles are **`admin` / `technician` / `viewer`**
(default `technician`). Admins manage members (`/api/users`, `/api/users/manage`: add / reset-password /
rename / activate / deactivate / delete) and see tenant-wide views. Role is synced from BizGaze `isAdmin` each
login; `ADMIN_EMAILS` is a lockout safety-net.
- **Registration** (`POST /api/register`): first run creates the team + admin user; closed afterward unless
`ALLOW_REGISTRATION=1`.
- **Third-party API keys** (`api_keys`, `apiKeyFromReq`): `bzc_`-prefixed, stored hashed, comma-scoped (`*` =
all), presented via `X-API-Key` or `Authorization: Bearer bzc_…` — for the roadmap's 3rd-party integration.
- **Directory search** (`server/directory.js`, `/api/directory/search`): server-side BizGaze cross-tenant user
lookup; the directory auth token stays **server-side** and is never exposed to the browser.
- **Audit log** (`audit_log`): logins, SSO/BizGaze provisioning, account merges, and every admin action on a
member are recorded.
## Real-time & media ## Real-time & media
- **WebRTC** for calls/meetings: built-in **P2P mesh** by default; **LiveKit SFU** when configured. - **WebRTC** for calls/meetings: built-in **P2P mesh** by default; **LiveKit SFU** when configured.
@@ -46,7 +85,9 @@ file access). Real-time via WebSockets; meeting media via WebRTC (P2P mesh, or L
- **LiveKit clients:** - **LiveKit clients:**
- Web: self-hosted **`livekit-client` ~2.20** UMD (`server/public/vendor/livekit-client.umd.min.js`). - Web: self-hosted **`livekit-client` ~2.20** UMD (`server/public/vendor/livekit-client.umd.min.js`).
- iOS: **LiveKit Swift SDK `2.15.3`** (SPM, `client-sdk-swift`, product `LiveKit`). - iOS: **LiveKit Swift SDK `2.15.3`** (SPM, `client-sdk-swift`, product `LiveKit`).
- Android: **LiveKit Android SDK** (`io.livekit:livekit-android`) — planned for native-calling Increment 2. - Android: **LiveKit Android SDK** `io.livekit:livekit-android` **2.29.2** (+ `com.twilio:audioswitch` for
output routing, Kotlin coroutines) — native call **audio** + Speaker/Bluetooth/Earpiece (Increment 2a); native
**video** is Increment 2b. Needs the JitPack repo; raises the app to **minSdk 24**.
## Web client (`server/public/`) ## Web client (`server/public/`)
@@ -66,12 +107,21 @@ file access). Real-time via WebSockets; meeting media via WebRTC (P2P mesh, or L
- `native-call` — native calling. **iOS:** Swift, CallKit + PushKit (VoIP) + LiveKit Swift SDK (native media, - `native-call` — native calling. **iOS:** Swift, CallKit + PushKit (VoIP) + LiveKit Swift SDK (native media,
video tiles, screen-share via a ReplayKit broadcast extension), on-device live transcription video tiles, screen-share via a ReplayKit broadcast extension), on-device live transcription
(`SFSpeechRecognizer`). **Android:** Java, full-screen incoming-call UI over the lock screen + a (`SFSpeechRecognizer`). **Android:** Java, full-screen incoming-call UI over the lock screen + a
`FirebaseMessagingService` woken by high-priority FCM data messages (native LiveKit media = Increment 2, WIP). `FirebaseMessagingService` woken by high-priority FCM data messages. **Increment 2a** adds native LiveKit
**audio** (Kotlin `CallMedia` + a `microphone` foreground service for background audio + `AudioSwitchHandler`
routing), gated by the server flag `ANDROID_NATIVE_MEDIA`. **Increment 2b** adds native **video** (Kotlin
`CallVideo`: camera publish/flip + remote/local `TextureViewRenderer` tiles hole-punched behind a transparent
WebView), gated by `ANDROID_NATIVE_VIDEO`. **Increment 2c** adds **screen-share** (MediaProjection grant →
LiveKit `setScreenShareEnabled`; LiveKit's own `mediaProjection` foreground service) + **pinch-zoom** of a
received shared screen (web forwards the gesture under hole-punch → renderer transform).
- `audio-route` (iOS audio output routing), `media-library` (save to Photos/gallery), `share-inbox` (inbound - `audio-route` (iOS audio output routing), `media-library` (save to Photos/gallery), `share-inbox` (inbound
share sheet), `file-opener` (native Quick Look / open-with). share sheet), `file-opener` (native Quick Look / open-with).
- **iOS build:** Capacitor 8 + **SPM** (CocoaPods retired); Xcode 26; a share extension + a broadcast extension. - **iOS build:** Capacitor 8 + **SPM** (CocoaPods retired); Xcode 26; a share extension + a broadcast extension.
- **Android build:** JDK **21**, AGP 8.7 / Gradle 8.11, target SDK 36, edge-to-edge; manifest patched in CI - **Android build:** JDK **21**, AGP 8.7 / Gradle 8.11, **minSdk 24** (raised for LiveKit), target SDK 36,
(`mobile/scripts/android-patch.js`) since `mobile/android/` is generated fresh per build. edge-to-edge; the native-call plugin module also applies **kotlin-android** for the LiveKit media layer.
`mobile/android/` is generated fresh per build, so permissions, the **minSdk bump**, and the **JitPack
repository** (`allprojects`/`dependencyResolutionManagement`) that LiveKit Android's transitive
`com.github.davidliu:audioswitch` fork needs are all applied in CI by `mobile/scripts/android-patch.js`.
## Desktop app (`desktop/`) — Electron ## Desktop app (`desktop/`) — Electron
@@ -155,4 +205,23 @@ Bump the **Last updated** date at the top and add a line to the Changelog below.
## Changelog ## Changelog
- **2026-11-03 (rev 6)** — Android native call **screen-share** (Increment 2c): `CallMedia.setScreenShareEnabled`
(MediaProjection → LiveKit; SDK-provided `mediaProjection` foreground service) + plugin `startScreenShare`/
`stopScreenShare` (permission via `startActivityForResult`), and **pinch-zoom** for a received shared screen
(`CallVideo.setTileZoom`). APK-only — no server/web change.
- **2026-11-03 (rev 5)** — Reliability: Postgres pool safety timeouts (`connectionTimeoutMillis`,
`statement_timeout`, `idle_in_transaction_session_timeout`, max 10→20) so a saturated pool fails fast instead
of hanging forever; and a **pre-deploy HTML syntax guard** (`server/tools/check-syntax.js`) run in the Docker
build and `redeploy.sh` — a JS syntax error in an inline `<script>` now fails the build instead of reaching
production (it had caused an app-wide login hang).
- **2026-11-03 (rev 4)** — Android native call **video** (Increment 2b): `CallVideo` renders camera/screen tiles
with LiveKit `TextureViewRenderer` hole-punched behind a transparent WebView; `CallMedia` gains camera
publish/flip + track lookup; new `ANDROID_NATIVE_VIDEO` server flag. Also fixed the white meeting background
(hole-punch no longer turns on without native video).
- **2026-10-30 (rev 3)** — Android native call media (Increment 2a): added `io.livekit:livekit-android` 2.29.2 +
`com.twilio:audioswitch` + Kotlin to the native-call plugin, a `microphone` foreground service, the
`ANDROID_NATIVE_MEDIA` server flag, and raised the Android app to minSdk 24. Build fix: `android-patch.js`
now injects the **JitPack** repo (LiveKit's `com.github.davidliu:audioswitch` fork resolves nowhere else).
- **2026-10-27 (rev 2)** — Added the **Identity & auth** section (sessions/cookies + bearer, refresh tokens,
TOTP MFA, BizGaze IdP/SSO, roles & tenancy, API keys, directory, audit).
- **2026-10-27** — Initial tech-stack document created. - **2026-10-27** — Initial tech-stack document created.
@@ -1,32 +1,48 @@
// Android build for the native-call plugin. Mirrors the official @capacitor/* plugin module layout (see // Android build for the native-call plugin. Mirrors the official @capacitor/* plugin module layout (see
// node_modules/@capacitor/status-bar/android/build.gradle) so it drops into the CI-generated app exactly like // node_modules/@capacitor/status-bar/android/build.gradle) so it drops into the CI-generated app like the
// the plugins that already build. Java (not Kotlin) to match the proven official-plugin structure — no extra // plugins that already build.
// kotlin-gradle-plugin wiring in the library module, one less thing to fail when authored blind. //
// INCREMENT 2 adds native LiveKit call media (background audio + Speaker/Bluetooth routing), which is a Kotlin +
// coroutines SDK — so this module now also applies kotlin-android (the ring/plugin glue stays Java). Capacitor
// pulls the Kotlin stdlib via a BOM but does NOT apply the Kotlin Gradle plugin at the root, so we bring it here
// (the standard community-plugin pattern); the classpath is additive to the generated root's AGP.
ext { ext {
junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2' junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
androidxCoreVersion = project.hasProperty('androidxCoreVersion') ? rootProject.ext.androidxCoreVersion : '1.12.0' androidxCoreVersion = project.hasProperty('androidxCoreVersion') ? rootProject.ext.androidxCoreVersion : '1.12.0'
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1' androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1'
// Same FCM version the @capacitor/push-notifications plugin pulls in, so we don't fork the Firebase version. // Same FCM version the @capacitor/push-notifications plugin pulls in, so we don't fork the Firebase version.
firebaseMessagingVersion = project.hasProperty('firebaseMessagingVersion') ? rootProject.ext.firebaseMessagingVersion : '23.3.1' firebaseMessagingVersion = project.hasProperty('firebaseMessagingVersion') ? rootProject.ext.firebaseMessagingVersion : '23.3.1'
// Follow the project's Kotlin (Capacitor pins kotlin_version at the root); fall back to a recent stable.
bzcKotlinVersion = rootProject.hasProperty('kotlin_version') ? rootProject.ext.kotlin_version : '1.9.24'
// LiveKit Android SDK — native background call media (Increment 2). Pinned; bump deliberately.
livekitVersion = '2.29.2'
audioswitchVersion = '1.2.0'
coroutinesVersion = '1.8.1'
} }
buildscript { buildscript {
ext.bzc_kotlin = rootProject.hasProperty('kotlin_version') ? rootProject.ext.kotlin_version : '1.9.24'
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.2.1' classpath 'com.android.tools.build:gradle:8.2.1'
// Kotlin Gradle plugin so this module can compile the .kt LiveKit wrapper. Additive to the root's AGP.
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$bzc_kotlin"
} }
} }
apply plugin: 'com.android.library' apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android { android {
namespace "com.bizgaze.connect.nativecall" namespace "com.bizgaze.connect.nativecall"
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34 // LiveKit 2.29 compiles against a recent platform; honor the CI root (Capacitor 8 = 35) and floor at 35.
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
defaultConfig { defaultConfig {
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23 // LiveKit Android's floor. The app min is bumped to 24 in CI (android-patch.js) so the merge holds.
minSdkVersion 24
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34 targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
versionCode 1 versionCode 1
versionName "1.0" versionName "1.0"
@@ -44,11 +60,16 @@ android {
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
} }
kotlinOptions {
jvmTarget = '17'
}
} }
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
// LiveKit pulls transitive artifacts (its WebRTC fork) that live on JitPack.
maven { url 'https://jitpack.io' }
} }
dependencies { dependencies {
@@ -62,4 +83,15 @@ dependencies {
// RemoteMessage / FirebaseMessaging (transitive from the push plugin, but declared so this module compiles // RemoteMessage / FirebaseMessaging (transitive from the push plugin, but declared so this module compiles
// against it directly; same version → no resolution conflict). // against it directly; same version → no resolution conflict).
implementation "com.google.firebase:firebase-messaging:$firebaseMessagingVersion" implementation "com.google.firebase:firebase-messaging:$firebaseMessagingVersion"
// Kotlin + coroutines for the native LiveKit media wrapper (CallMedia.kt).
implementation "org.jetbrains.kotlin:kotlin-stdlib:$bzcKotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
// Native call media (background audio) + audio-device routing (speaker/bluetooth/earpiece).
implementation "io.livekit:livekit-android:$livekitVersion"
// AudioDevice types (com.twilio.audioswitch.AudioDevice) used by our CallMedia.kt. LiveKit already BUNDLES a
// fork (com.github.davidliu:audioswitch) that ships these exact classes at RUNTIME — so declaring our own as
// `implementation` put both forks on the runtime classpath and failed checkDebugDuplicateClasses. Use
// `compileOnly`: twilio:audioswitch gives us the types at compile time, while runtime uses only LiveKit's
// bundled fork (binary-identical package/classes) → no duplicate.
compileOnly "com.twilio:audioswitch:$audioswitchVersion"
} }
@@ -7,6 +7,16 @@
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Increment 2 (native LiveKit call media): a foreground service keeps call audio alive in the background;
the microphone type lets the call keep publishing the mic. RECORD_AUDIO/MODIFY_AUDIO_SETTINGS for capture
+ routing (also added app-side by android-patch.js — merge dedups); BLUETOOTH_CONNECT (API 31+) so the
audio-device switch can route to a Bluetooth headset. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<application> <application>
<!-- Our FirebaseMessagingService REPLACES the @capacitor/push-notifications one: it handles call-wake <!-- Our FirebaseMessagingService REPLACES the @capacitor/push-notifications one: it handles call-wake
DATA messages natively (to ring when the app is closed) and forwards everything else to Capacitor's DATA messages natively (to ring when the app is closed) and forwards everything else to Capacitor's
@@ -32,5 +42,12 @@
android:showWhenLocked="true" android:showWhenLocked="true"
android:turnScreenOn="true" android:turnScreenOn="true"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar" /> android:theme="@android:style/Theme.DeviceDefault.NoActionBar" />
<!-- Keeps the call process alive for background audio (Increment 2). The microphone type is required on
Android 14+ for a service that uses the mic. -->
<service
android:name="com.bizgaze.connect.nativecall.CallForegroundService"
android:exported="false"
android:foregroundServiceType="microphone" />
</application> </application>
</manifest> </manifest>
@@ -0,0 +1,97 @@
package com.bizgaze.connect.nativecall;
import android.Manifest;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.IBinder;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import androidx.core.content.ContextCompat;
// Keeps the call's process alive while the app is backgrounded so native LiveKit audio keeps flowing (Increment 2).
// Android 14+ requires a TYPED foreground service; we use the "microphone" type (a call publishes the mic). That
// type needs RECORD_AUDIO at startForeground time, so start() no-ops without it (the call still works in the
// foreground; the plugin requests the grant so the next call gets background audio). Stopped on call end.
public class CallForegroundService extends Service {
private static final String CHANNEL_ID = "bzc_ongoing_call";
private static final int NOTIF_ID = 2749;
// Start the ongoing-call foreground service — but only if RECORD_AUDIO is granted (the microphone FG type
// requires it; starting without it throws on Android 14+). No-op otherwise so we never crash the call.
static void start(Context ctx) {
Context app = ctx.getApplicationContext();
if (ContextCompat.checkSelfPermission(app, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
Log.i("bzcall", "FG service skipped — RECORD_AUDIO not granted yet (foreground audio only)");
return;
}
try {
ContextCompat.startForegroundService(app, new Intent(app, CallForegroundService.class));
} catch (Exception e) { Log.w("bzcall", "FG service start failed: " + e.getMessage()); }
}
static void stop(Context ctx) {
try { ctx.getApplicationContext().stopService(new Intent(ctx.getApplicationContext(), CallForegroundService.class)); }
catch (Exception ignored) {}
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
ensureChannel();
Notification n = buildNotification();
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE);
} else {
startForeground(NOTIF_ID, n);
}
Log.i("bzcall", "ongoing-call foreground service started");
} catch (Exception e) {
// Must still call startForeground or the OS kills us with an ANR; fall back to the untyped form.
Log.w("bzcall", "startForeground(microphone) failed: " + e.getMessage());
try { startForeground(NOTIF_ID, n); } catch (Exception ignored) {}
}
return START_NOT_STICKY; // don't resurrect after the call/process ends
}
private void ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
if (nm == null || nm.getNotificationChannel(CHANNEL_ID) != null) return;
NotificationChannel ch = new NotificationChannel(CHANNEL_ID, "Ongoing call", NotificationManager.IMPORTANCE_LOW);
ch.setDescription("Shown while a Biz Connect call is in progress");
ch.setShowBadge(false);
ch.setSound(null, null);
nm.createNotificationChannel(ch);
}
private Notification buildNotification() {
NotificationCompat.Builder b = new NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.sym_action_call)
.setContentTitle("Ongoing call")
.setContentText("Tap to return to the call")
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_LOW);
try {
Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName());
if (launch != null) {
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) flags |= PendingIntent.FLAG_IMMUTABLE;
b.setContentIntent(PendingIntent.getActivity(this, 0, launch, flags));
}
} catch (Exception ignored) {}
return b.build();
}
@Override public IBinder onBind(Intent intent) { return null; }
}
@@ -0,0 +1,193 @@
package com.bizgaze.connect.nativecall
import android.content.Context
import android.content.Intent
import android.util.Log
import com.twilio.audioswitch.AudioDevice
import io.livekit.android.LiveKit
import io.livekit.android.LiveKitOverrides
import io.livekit.android.AudioOptions
import io.livekit.android.audio.AudioSwitchHandler
import io.livekit.android.renderer.TextureViewRenderer
import io.livekit.android.room.Room
import io.livekit.android.room.track.LocalVideoTrack
import io.livekit.android.room.track.Track
import io.livekit.android.room.track.VideoTrack
import io.livekit.android.room.track.screencapture.ScreenCaptureParams
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
// Native LiveKit call media for Android (Increment 2) — mirrors the iOS plugin's single-Room handling. A
// process-level singleton so the connection survives a WebView reload (e.g. answering from a killed app); the
// foreground service (CallForegroundService) keeps the process alive so audio continues when backgrounded.
//
// Audio routing (speaker / bluetooth / earpiece / wired) is handled by LiveKit's AudioSwitchHandler: it
// auto-selects by priority (bluetooth > wired > speaker > earpiece) and we expose selectDevice() for the manual
// Speaker/Bluetooth control. Playback (hearing the other side) needs no permission; publishing the mic needs
// RECORD_AUDIO — but we answer MUTED (the web re-applies the real mic state on callConnected), so a user with no
// mic grant still hears audio and only needs the grant to unmute.
object CallMedia {
private const val TAG = "bzcall"
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var room: Room? = null
private var audio: AudioSwitchHandler? = null
// Java-friendly callback the plugin implements to forward to notifyListeners(...). All invoked on main.
interface Events {
fun onConnected()
fun onError(message: String)
fun onRoute(selected: String, available: List<String>)
}
@JvmStatic var events: Events? = null
@JvmStatic val isConnected: Boolean get() = room != null
// Connect (or re-home) the call's LiveKit room. startMuted mirrors the iOS "answer muted" house rule.
@JvmStatic
fun connect(context: Context, url: String, token: String, startMuted: Boolean) {
val app = context.applicationContext
scope.launch {
try {
room?.let { try { it.disconnect() } catch (_: Throwable) {} }
val handler = AudioSwitchHandler(app)
audio = handler
val r = LiveKit.create(
app,
overrides = LiveKitOverrides(audioOptions = AudioOptions(audioHandler = handler))
)
room = r
handler.registerAudioDeviceChangeListener { _, _ -> emitRoute() }
r.connect(url, token)
r.localParticipant.setMicrophoneEnabled(!startMuted)
Log.i(TAG, "native media connected (startMuted=$startMuted)")
events?.onConnected()
emitRoute()
} catch (e: Throwable) {
Log.w(TAG, "native media connect failed: ${e.message}")
events?.onError(e.message ?: "connect failed")
}
}
}
@JvmStatic
fun setMicEnabled(enabled: Boolean) {
val r = room ?: return
scope.launch {
try { r.localParticipant.setMicrophoneEnabled(enabled) }
catch (e: Throwable) { Log.w(TAG, "setMic failed: ${e.message}") }
}
}
// Manual output selection for the Speaker/Bluetooth/Earpiece control. If the requested device isn't present,
// the current (auto) selection is left alone.
@JvmStatic
fun setRoute(route: String) {
val h = audio ?: return
val target: AudioDevice? = when (route) {
"speaker" -> h.availableAudioDevices.firstOrNull { it is AudioDevice.Speakerphone }
"earpiece" -> h.availableAudioDevices.firstOrNull { it is AudioDevice.Earpiece }
"bluetooth" -> h.availableAudioDevices.firstOrNull { it is AudioDevice.BluetoothHeadset }
"wired" -> h.availableAudioDevices.firstOrNull { it is AudioDevice.WiredHeadset }
else -> null
}
if (target != null) h.selectDevice(target)
emitRoute()
}
@JvmStatic fun currentRoute(): String = routeName(audio?.selectedAudioDevice)
@JvmStatic fun availableRoutes(): List<String> = audio?.availableAudioDevices?.map { routeName(it) } ?: emptyList()
private fun emitRoute() {
val h = audio ?: return
events?.onRoute(routeName(h.selectedAudioDevice), h.availableAudioDevices.map { routeName(it) })
}
private fun routeName(d: AudioDevice?): String = when (d) {
is AudioDevice.Speakerphone -> "speaker"
is AudioDevice.Earpiece -> "earpiece"
is AudioDevice.BluetoothHeadset -> "bluetooth"
is AudioDevice.WiredHeadset -> "wired"
else -> "unknown"
}
@JvmStatic
fun disconnect() {
val r = room
room = null
audio = null
if (r != null) scope.launch { try { r.disconnect() } catch (_: Throwable) {} }
}
// ---- Native video (Increment 2b) -------------------------------------------------------------------
// The WebView owns the meeting grid but has no LiveKit connection on a native call, so it can't render any
// video. CallVideo draws native TextureViewRenderers behind the (transparent) WebView, positioned to match
// each web tile; these helpers give it the camera control + the live tracks to bind. Mirrors the iOS plugin.
// Publish/unpublish the local camera (front by default). Fire-and-forget: the web re-syncs tiles right after,
// and a tile only shows video once a live track exists — so a failure just leaves the web avatar showing.
@JvmStatic
fun setCameraEnabled(enabled: Boolean) {
val r = room ?: return
scope.launch {
try { r.localParticipant.setCameraEnabled(enabled) }
catch (e: Throwable) { Log.w(TAG, "setCamera($enabled) failed: ${e.message}") }
}
}
// Flip front/back. No-arg switchCamera() moves to the next available camera.
@JvmStatic
fun switchCamera() {
val r = room ?: return
val track = r.localParticipant.getTrackPublication(Track.Source.CAMERA)?.track as? LocalVideoTrack ?: return
try { track.switchCamera() } catch (e: Throwable) { Log.w(TAG, "switchCamera failed: ${e.message}") }
}
// The live camera track for a participant (local, or remote by LiveKit identity = mesh peerId), or null when
// the camera is off/muted so the web avatar shows through instead.
@JvmStatic
fun cameraTrack(uid: String, isLocal: Boolean): VideoTrack? {
val r = room ?: return null
val p = if (isLocal) r.localParticipant else (r.getParticipantByIdentity(uid) ?: return null)
val pub = p.getTrackPublication(Track.Source.CAMERA) ?: return null
if (pub.muted) return null
return pub.track as? VideoTrack
}
// The remote screen-share track for a participant, or null if they aren't sharing (or not subscribed yet).
@JvmStatic
fun screenTrack(uid: String): VideoTrack? {
val r = room ?: return null
val p = r.getParticipantByIdentity(uid) ?: return null
val pub = p.getTrackPublication(Track.Source.SCREEN_SHARE) ?: return null
if (pub.muted) return null
return pub.track as? VideoTrack
}
// Init a renderer against the room's EGL context before a track is attached. No-op if no active room.
@JvmStatic
fun initRenderer(renderer: TextureViewRenderer) {
try { room?.initVideoRenderer(renderer) } catch (e: Throwable) { Log.w(TAG, "initRenderer failed: ${e.message}") }
}
// Publish/unpublish this device's screen (Increment 2c). `data` is the MediaProjection permission result
// Intent from MediaProjectionManager.createScreenCaptureIntent() (the plugin requests it). LiveKit runs its
// own `mediaProjection` foreground service (declared in the SDK manifest, merged into the app), so we just
// hand it the permission result. Fire-and-forget; the plugin emits screenShareState to the web.
@JvmStatic
fun setScreenShareEnabled(enabled: Boolean, data: Intent?) {
val r = room ?: return
scope.launch {
try {
if (enabled) {
if (data == null) return@launch
r.localParticipant.setScreenShareEnabled(true, ScreenCaptureParams(data))
} else {
r.localParticipant.setScreenShareEnabled(false)
}
} catch (e: Throwable) { Log.w(TAG, "setScreenShareEnabled($enabled) failed: ${e.message}") }
}
}
}
@@ -0,0 +1,157 @@
package com.bizgaze.connect.nativecall
import android.app.Activity
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.util.Log
import android.view.ViewGroup
import android.webkit.WebView
import android.widget.FrameLayout
import io.livekit.android.renderer.TextureViewRenderer
import io.livekit.android.room.track.VideoTrack
import livekit.org.webrtc.RendererCommon
// Native video tiles for Android native calls (Increment 2b) — the Android counterpart to the iOS plugin's
// TileVideoView path. The WebView owns the meeting UI (grid, roster, controls) but, on a native call, has NO
// LiveKit connection — the media lives only in the plugin's native LiveKit room (CallMedia). So we render the
// video NATIVELY:
//
// • Hole-punch: the WebView is made transparent and its parent painted black; a container FrameLayout is
// inserted BEHIND the WebView to host the video. The web meeting UI (opaque controls, avatars for
// camera-off tiles) then paints on top, while tiles flagged `bz-hasvid` go transparent so the native
// video behind shows through. We use TextureViewRenderer (NOT SurfaceViewRenderer) because a TextureView
// composites normally in the view tree and shows through a transparent sibling on top — a SurfaceView
// punches its own window hole and can't.
// • The web polls syncVideoTiles([{uid, local, screen, x, y, w, h}]) with each tile's on-screen rect in CSS
// px; we place/size a renderer per participant that has a live camera (or screen) track, scaling CSS px →
// device px by display density, and bind the matching LiveKit track. Camera-off participants get no
// renderer, so their web avatar shows.
//
// All view work runs on the UI thread. Track lookup + renderer init go through CallMedia (which owns the room).
object CallVideo {
private const val TAG = "bzcall"
private var holePunchOn = false
private var container: FrameLayout? = null
private var savedParentBg: Int? = null
// key ("__local" | peerId) -> renderer, and the track currently bound to it (to avoid needless re-binds).
private val renderers = HashMap<String, TextureViewRenderer>()
private val bound = HashMap<String, VideoTrack>()
// One meeting tile from the web (coords are CSS px, viewport-relative — same space as getBoundingClientRect).
data class Tile(
val key: String, val uid: String, val isLocal: Boolean, val wantScreen: Boolean,
val x: Double, val y: Double, val w: Double, val h: Double
)
@JvmStatic
fun setHolePunch(activity: Activity?, webView: WebView?, on: Boolean) {
if (activity == null || webView == null) return
activity.runOnUiThread {
try {
if (on) {
webView.setBackgroundColor(Color.TRANSPARENT)
val parent = webView.parent as? ViewGroup
if (parent != null) {
if (!holePunchOn) savedParentBg = (parent.background as? ColorDrawable)?.color
parent.setBackgroundColor(Color.BLACK)
if (container == null) {
val c = FrameLayout(webView.context)
val idx = parent.indexOfChild(webView)
// Insert at the WebView's index → container sits BEHIND it (drawn first).
parent.addView(c, if (idx < 0) 0 else idx,
ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
container = c
}
}
holePunchOn = true
Log.i(TAG, "hole-punch ON (native video)")
} else {
teardown(webView)
}
} catch (e: Throwable) { Log.w(TAG, "setHolePunch($on) failed: ${e.message}") }
}
}
@JvmStatic
fun syncTiles(activity: Activity?, webView: WebView?, tiles: List<Tile>) {
if (activity == null || webView == null) return
activity.runOnUiThread {
val c = container ?: return@runOnUiThread
val density = webView.resources.displayMetrics.density
val wanted = HashSet<String>()
for (t in tiles) {
if (t.w < 2 || t.h < 2) continue
val track = (if (t.wantScreen) CallMedia.screenTrack(t.uid) else CallMedia.cameraTrack(t.uid, t.isLocal)) ?: continue
wanted.add(t.key)
var r = renderers[t.key]
if (r == null) {
r = TextureViewRenderer(webView.context)
CallMedia.initRenderer(r)
c.addView(r)
renderers[t.key] = r
}
r.setMirror(t.isLocal && !t.wantScreen) // mirror the local camera preview only
r.setScalingType(if (t.wantScreen) RendererCommon.ScalingType.SCALE_ASPECT_FIT else RendererCommon.ScalingType.SCALE_ASPECT_FILL)
val lp = FrameLayout.LayoutParams((t.w * density).toInt(), (t.h * density).toInt())
lp.leftMargin = (t.x * density).toInt()
lp.topMargin = (t.y * density).toInt()
r.layoutParams = lp
val cur = bound[t.key]
if (cur !== track) {
if (cur != null) try { cur.removeRenderer(r) } catch (_: Throwable) {}
try { track.addRenderer(r) } catch (_: Throwable) {}
bound[t.key] = track
}
}
// Drop renderers for tiles that are gone or whose camera turned off (so the web avatar shows).
val it = renderers.entries.iterator()
while (it.hasNext()) {
val e = it.next()
if (!wanted.contains(e.key)) {
val r = e.value
val tr = bound.remove(e.key)
if (tr != null) try { tr.removeRenderer(r) } catch (_: Throwable) {}
try { c.removeView(r) } catch (_: Throwable) {}
try { r.release() } catch (_: Throwable) {}
it.remove()
}
}
}
}
// Pinch-zoom for a shared screen (Increment 2c). Under hole-punch the native video sits behind the
// transparent WebView, so touches land on the web, which captures the pinch/pan and forwards the transform
// here (scale 1–5, tx/ty = pan in CSS px). We apply it to the renderer as a view transform — scale around its
// centre, then translate by tx/ty scaled to device px. scale<=1 clears it. Mirrors the iOS applyZoom().
@JvmStatic
fun setTileZoom(activity: Activity?, uid: String, isLocal: Boolean, scale: Double, tx: Double, ty: Double) {
if (activity == null) return
val key = if (isLocal) "__local" else uid
activity.runOnUiThread {
val r = renderers[key] ?: return@runOnUiThread
if (scale <= 1.001) {
r.scaleX = 1f; r.scaleY = 1f; r.translationX = 0f; r.translationY = 0f
} else {
val d = r.resources.displayMetrics.density
r.scaleX = scale.toFloat(); r.scaleY = scale.toFloat()
r.translationX = (tx * d).toFloat(); r.translationY = (ty * d).toFloat()
}
}
}
private fun teardown(webView: WebView) {
for ((key, r) in renderers) {
val tr = bound[key]
if (tr != null) try { tr.removeRenderer(r) } catch (_: Throwable) {}
try { r.release() } catch (_: Throwable) {}
}
renderers.clear()
bound.clear()
val c = container
if (c != null) { try { (c.parent as? ViewGroup)?.removeView(c) } catch (_: Throwable) {}; container = null }
try { (webView.parent as? ViewGroup)?.setBackgroundColor(savedParentBg ?: Color.TRANSPARENT) } catch (_: Throwable) {}
try { webView.setBackgroundColor(Color.WHITE) } catch (_: Throwable) {} // restore the normal (opaque) app background
holePunchOn = false
Log.i(TAG, "hole-punch OFF")
}
}
@@ -9,6 +9,7 @@ import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log; import android.util.Log;
import android.view.Gravity; import android.view.Gravity;
import android.view.KeyEvent;
import android.view.View; import android.view.View;
import android.view.WindowManager; import android.view.WindowManager;
import android.widget.LinearLayout; import android.widget.LinearLayout;
@@ -214,6 +215,20 @@ public class IncomingCallActivity extends Activity {
finish(); finish();
} }
// Volume keys silence the ring (like the system phone app) while the full-screen screen is in front. We
// consume the event so the stream volume / volume UI doesn't also react — the press only hushes the ring; the
// call keeps ringing (notification + screen stay) so the user can still answer/decline. The headless case
// (unlocked heads-up, no activity) is handled by Ringer's MediaSession VolumeProvider.
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
int k = event.getKeyCode();
if (k == KeyEvent.KEYCODE_VOLUME_UP || k == KeyEvent.KEYCODE_VOLUME_DOWN || k == KeyEvent.KEYCODE_VOLUME_MUTE) {
if (event.getAction() == KeyEvent.ACTION_DOWN) Ringer.silence();
return true; // consume down + up so the volume stream doesn't change
}
return super.dispatchKeyEvent(event);
}
@Override @Override
protected void onDestroy() { protected void onDestroy() {
// Don't stop the ring here — it's call-scoped (Ringer), not screen-scoped. If the user just backgrounds the // Don't stop the ring here — it's call-scoped (Ringer), not screen-scoped. If the user just backgrounds the
@@ -1,12 +1,27 @@
package com.bizgaze.connect.nativecall; package com.bizgaze.connect.nativecall;
import android.Manifest;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.media.projection.MediaProjectionManager;
import androidx.activity.result.ActivityResult;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject; import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin; import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall; import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod; import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.ActivityCallback;
import com.getcapacitor.annotation.CapacitorPlugin; import com.getcapacitor.annotation.CapacitorPlugin;
import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.FirebaseMessaging;
import org.json.JSONObject;
// Native calling for Biz Connect (Android). Registered by `cap sync` as window.Capacitor.Plugins.NativeCall — // Native calling for Biz Connect (Android). Registered by `cap sync` as window.Capacitor.Plugins.NativeCall —
// the SAME jsName the iOS plugin uses, so the web bridge (setupNativeCall in home.html) drives both platforms // the SAME jsName the iOS plugin uses, so the web bridge (setupNativeCall in home.html) drives both platforms
// through one code path. // through one code path.
@@ -32,6 +47,7 @@ public class NativeCallPlugin extends Plugin {
@Override @Override
public void load() { public void load() {
instance = this; instance = this;
wireMedia();
if (pendingAnswer != null) { if (pendingAnswer != null) {
JSObject a = pendingAnswer; JSObject a = pendingAnswer;
pendingAnswer = null; pendingAnswer = null;
@@ -39,6 +55,57 @@ public class NativeCallPlugin extends Plugin {
} }
} }
// Bridge CallMedia's (Kotlin, LiveKit) events to the shared web listeners. Routed through the live plugin
// instance so a WebView reload doesn't fire into a dead bridge. The event names/payloads mirror the iOS plugin
// (callConnected / callError), plus audioRoute for the Android Speaker/Bluetooth control.
private void wireMedia() {
CallMedia.setEvents(new CallMedia.Events() {
@Override public void onConnected() {
NativeCallPlugin p = instance;
if (p != null) p.notifyListeners("callConnected", new JSObject().put("ok", true));
}
@Override public void onError(String message) {
NativeCallPlugin p = instance;
if (p != null) p.notifyListeners("callError", new JSObject().put("error", message == null ? "" : message));
}
@Override public void onRoute(String selected, java.util.List<String> available) {
NativeCallPlugin p = instance;
if (p == null) return;
JSObject o = new JSObject();
o.put("output", selected == null ? "unknown" : selected);
JSArray arr = new JSArray();
if (available != null) for (String s : available) arr.put(s);
o.put("available", arr);
p.notifyListeners("audioRoute", o);
}
});
}
// LiveKit needs RECORD_AUDIO to publish the mic, and the microphone foreground service needs it to start.
// We answer MUTED (playback needs no permission), so this is best-effort: it prompts without blocking, and the
// grant persists so unmute + background audio work from this call on (or the next).
private void ensureMicPermission() {
try {
if (getContext() != null
&& ContextCompat.checkSelfPermission(getContext(), Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED
&& getActivity() != null) {
ActivityCompat.requestPermissions(getActivity(), new String[]{ Manifest.permission.RECORD_AUDIO }, 7731);
}
} catch (Exception ignored) {}
}
// Native camera capture (Increment 2b) needs the CAMERA runtime grant. Best-effort like the mic: prompt
// without blocking; the grant persists so the camera works from this toggle on (or the next).
private void ensureCameraPermission() {
try {
if (getContext() != null
&& ContextCompat.checkSelfPermission(getContext(), Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED
&& getActivity() != null) {
ActivityCompat.requestPermissions(getActivity(), new String[]{ Manifest.permission.CAMERA }, 7732);
}
} catch (Exception ignored) {}
}
@Override @Override
protected void handleOnDestroy() { protected void handleOnDestroy() {
super.handleOnDestroy(); super.handleOnDestroy();
@@ -90,41 +157,177 @@ public class NativeCallPlugin extends Plugin {
call.resolve(); call.resolve();
} }
// Outgoing call from the web app. Increment 1: media stays in the WebView, so there is nothing to ring or // Outgoing call from the web app. Increment 2 (native media): start the ongoing-call foreground service and
// connect natively here — the callee is rung by the server's FCM call-wake. Just record it so a later // connect the LiveKit room with the url/token the web minted (/api/meetings/token). Connect MUTED — the web
// endCall/cancel can match. (Increment 2 will start a foreground service + native LiveKit here.) // re-applies the real mic state on the callConnected event. The callee is rung by the server's FCM call-wake.
@PluginMethod @PluginMethod
public void reportOutgoingCall(PluginCall call) { public void reportOutgoingCall(PluginCall call) {
String uuid = call.getString("callUUID", ""); String uuid = call.getString("callUUID", "");
if (!uuid.isEmpty()) CallStore.markActive(uuid); if (!uuid.isEmpty()) CallStore.markActive(uuid);
startNativeMedia(call.getString("url", ""), call.getString("token", ""));
call.resolve(); call.resolve();
} }
// Stop ringing / clear the incoming UI for a call (remote hung up, we ended, or another device answered). // Stop ringing / clear the incoming UI AND tear down native media (remote hung up, we ended, or another device
// answered). Idempotent: disconnect()/stop() are safe no-ops when nothing is connected/running.
@PluginMethod @PluginMethod
public void endCall(PluginCall call) { public void endCall(PluginCall call) {
String uuid = call.getString("callUUID", ""); String uuid = call.getString("callUUID", "");
if (!uuid.isEmpty()) CallStore.markEnded(uuid); if (!uuid.isEmpty()) CallStore.markEnded(uuid);
CallStore.cancel(getContext()); CallStore.cancel(getContext());
IncomingCallActivity.dismissIfShowing(); IncomingCallActivity.dismissIfShowing();
try { CallVideo.setHolePunch(getActivity(), getBridge() != null ? getBridge().getWebView() : null, false); } catch (Exception ignored) {}
CallMedia.disconnect();
CallForegroundService.stop(getContext());
call.resolve(); call.resolve();
} }
// Increment 1: mute is handled by the WebView (it owns the media). No-op so the shared bridge doesn't reject. // Mute/unmute the native mic (Increment 2 — the plugin owns the media now).
@PluginMethod public void setMuted(PluginCall call) { call.resolve(); } @PluginMethod
public void setMuted(PluginCall call) {
boolean muted = Boolean.TRUE.equals(call.getBoolean("muted", false));
CallMedia.setMicEnabled(!muted);
call.resolve();
}
// --- Increment 2+ methods (video / screen-share / hole-punch / #12 reconnect / #5 transcript). Safe no-ops // Connect (answer path) or re-home (#12 multi-device) the native LiveKit room with web-minted creds. On
// for now so the shared web bridge never rejects on Android; real bodies land with the native-media build. --- // Android this is the primary connect on answer: the FCM call-wake carries no livekit creds, so the web calls
@PluginMethod public void reconnectRoom(PluginCall call) { call.resolve(); } // this (bzNativeReconnectMedia) with a peerId token once the meeting WebSocket has joined.
@PluginMethod public void setCamera(PluginCall call) { call.resolve(); } @PluginMethod
@PluginMethod public void switchCamera(PluginCall call) { call.resolve(); } public void reconnectRoom(PluginCall call) {
@PluginMethod public void startScreenShare(PluginCall call) { call.resolve(); } startNativeMedia(call.getString("url", ""), call.getString("token", ""));
@PluginMethod public void stopScreenShare(PluginCall call) { call.resolve(); } call.resolve();
}
// Select the call audio output (Speaker / Bluetooth / Earpiece / Wired) for the in-call control. Resolves the
// route actually in effect so the web button can reflect it.
@PluginMethod
public void setAudioRoute(PluginCall call) {
String route = call.getString("route", "");
if (!route.isEmpty()) CallMedia.setRoute(route);
JSObject ret = new JSObject();
ret.put("output", CallMedia.currentRoute());
call.resolve(ret);
}
// Report the current audio output + the devices available to switch to (used to seed/refresh the control).
@PluginMethod
public void getAudioRoute(PluginCall call) {
JSObject ret = new JSObject();
ret.put("output", CallMedia.currentRoute());
JSArray arr = new JSArray();
for (String s : CallMedia.availableRoutes()) arr.put(s);
ret.put("available", arr);
call.resolve(ret);
}
// Shared connect helper for the outgoing + answer paths.
private void startNativeMedia(String url, String token) {
if (url == null || url.isEmpty() || token == null || token.isEmpty()) return;
ensureMicPermission();
CallForegroundService.start(getContext());
CallMedia.connect(getContext(), url, token, true); // start muted; web re-applies on callConnected
}
// --- Increment 2b: native video tiles (camera + hole-punch rendering). ---
// Publish/unpublish the local camera. The web re-syncs tiles right after this resolves so the local tile
// picks up (or drops) the native video.
@PluginMethod
public void setCamera(PluginCall call) {
boolean on = Boolean.TRUE.equals(call.getBoolean("on", false));
if (on) ensureCameraPermission();
CallMedia.setCameraEnabled(on);
JSObject ret = new JSObject();
ret.put("on", on);
call.resolve(ret);
}
// Flip the local camera front/back.
@PluginMethod
public void switchCamera(PluginCall call) {
CallMedia.switchCamera();
call.resolve();
}
// Hole-punch: transparent WebView over native video behind it. Toggled by the web when a native call's
// meeting view is shown/left (gated on the ANDROID_NATIVE_VIDEO server flag).
@PluginMethod
public void setHolePunch(PluginCall call) {
boolean on = Boolean.TRUE.equals(call.getBoolean("on", false));
CallVideo.setHolePunch(getActivity(), getBridge() != null ? getBridge().getWebView() : null, on);
call.resolve();
}
// Position native video renderers to match the web meeting tiles. tiles = [{uid, local, screen, x, y, w, h}]
// in CSS px. Polled by the web while a native call is on screen.
@PluginMethod
public void syncVideoTiles(PluginCall call) {
java.util.List<CallVideo.Tile> list = new java.util.ArrayList<>();
JSArray arr = call.getArray("tiles");
if (arr != null) {
for (int i = 0; i < arr.length(); i++) {
JSONObject o = arr.optJSONObject(i);
if (o == null) continue;
String uid = o.optString("uid", "");
if (uid.isEmpty()) continue;
boolean local = o.optBoolean("local", false);
boolean screen = o.optBoolean("screen", false);
String key = local ? "__local" : uid;
list.add(new CallVideo.Tile(key, uid, local, screen,
o.optDouble("x", 0), o.optDouble("y", 0), o.optDouble("w", 0), o.optDouble("h", 0)));
}
}
CallVideo.syncTiles(getActivity(), getBridge() != null ? getBridge().getWebView() : null, list);
call.resolve();
}
// --- Increment 2c: screen share (MediaProjection) + pinch-zoom for a received shared screen. ---
// Share this device's screen: ask for the MediaProjection grant, then (on the result) publish it via LiveKit.
@PluginMethod
public void startScreenShare(PluginCall call) {
try {
MediaProjectionManager mpm = (MediaProjectionManager) getContext().getSystemService(Context.MEDIA_PROJECTION_SERVICE);
if (mpm == null) { call.reject("screen capture unavailable"); return; }
startActivityForResult(call, mpm.createScreenCaptureIntent(), "onScreenCaptureResult");
} catch (Exception e) { call.reject("screen share failed: " + e.getMessage()); }
}
// MediaProjection permission result. On grant, publish the screen (LiveKit runs its own mediaProjection
// foreground service). Emit screenShareState either way so the web button reflects reality (incl. a decline).
@ActivityCallback
private void onScreenCaptureResult(PluginCall call, ActivityResult result) {
if (call == null) return;
boolean ok = result != null && result.getResultCode() == Activity.RESULT_OK && result.getData() != null;
if (ok) CallMedia.setScreenShareEnabled(true, result.getData());
notifyListeners("screenShareState", new JSObject().put("sharing", ok));
call.resolve();
}
@PluginMethod
public void stopScreenShare(PluginCall call) {
CallMedia.setScreenShareEnabled(false, null);
notifyListeners("screenShareState", new JSObject().put("sharing", false));
call.resolve();
}
// Pinch-zoom a received shared screen (web captures the gesture under hole-punch and forwards the transform).
@PluginMethod
public void setTileZoom(PluginCall call) {
String uid = call.getString("uid", "");
boolean local = Boolean.TRUE.equals(call.getBoolean("local", false));
Double scale = call.getDouble("scale", 1.0);
Double tx = call.getDouble("tx", 0.0);
Double ty = call.getDouble("ty", 0.0);
CallVideo.setTileZoom(getActivity(), uid, local,
scale == null ? 1.0 : scale, tx == null ? 0.0 : tx, ty == null ? 0.0 : ty);
call.resolve();
}
// --- Still no-ops: screen share INTO a scheduled SFU meeting (iOS-only path) + #5 transcript. ---
@PluginMethod public void startMeetingScreenShare(PluginCall call) { call.resolve(); } @PluginMethod public void startMeetingScreenShare(PluginCall call) { call.resolve(); }
@PluginMethod public void stopMeetingScreenShare(PluginCall call) { call.resolve(); } @PluginMethod public void stopMeetingScreenShare(PluginCall call) { call.resolve(); }
@PluginMethod public void setHolePunch(PluginCall call) { call.resolve(); }
@PluginMethod public void setTileZoom(PluginCall call) { call.resolve(); }
@PluginMethod public void syncVideoTiles(PluginCall call) { call.resolve(); }
@PluginMethod public void startTranscription(PluginCall call) { call.resolve(); } @PluginMethod public void startTranscription(PluginCall call) { call.resolve(); }
@PluginMethod public void stopTranscription(PluginCall call) { call.resolve(); } @PluginMethod public void stopTranscription(PluginCall call) { call.resolve(); }
@PluginMethod public void feedAudio(PluginCall call) { call.resolve(); } @PluginMethod public void feedAudio(PluginCall call) { call.resolve(); }
@@ -4,6 +4,9 @@ import android.content.Context;
import android.media.AudioAttributes; import android.media.AudioAttributes;
import android.media.Ringtone; import android.media.Ringtone;
import android.media.RingtoneManager; import android.media.RingtoneManager;
import android.media.VolumeProvider;
import android.media.session.MediaSession;
import android.media.session.PlaybackState;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Vibrator; import android.os.Vibrator;
@@ -18,6 +21,10 @@ import android.util.Log;
class Ringer { class Ringer {
private static Ringtone ringtone; private static Ringtone ringtone;
private static Vibrator vibrator; private static Vibrator vibrator;
// A dummy MediaSession whose only job is to intercept the hardware volume keys so the FIRST press silences the
// ring (see startVolumeKeyCapture). We aren't a telecom ConnectionService yet (Increment 2), so the OS won't
// route volume/power to us on its own.
private static MediaSession mediaSession;
static synchronized void start(Context ctx) { static synchronized void start(Context ctx) {
if (ringtone != null) return; // already ringing if (ringtone != null) return; // already ringing
@@ -51,6 +58,44 @@ class Ringer {
vibrator = v; vibrator = v;
} }
} catch (Exception ignored) {} } catch (Exception ignored) {}
startVolumeKeyCapture(app);
}
// Make the hardware volume keys silence the ring, exactly like the system phone app. Because we are not a
// telecom ConnectionService (that's Increment 2), the OS won't deliver volume/power presses to us while
// ringing. A MediaSession flagged as actively "playing" with a *remote* VolumeProvider makes the framework
// route volume-key presses to our VolumeProvider instead of changing a stream — even when no activity of ours
// is in the foreground (the unlocked heads-up case). The foreground/locked full-screen activity also forwards
// its own key events here via dispatchKeyEvent, so every state is covered.
private static void startVolumeKeyCapture(Context app) {
try {
MediaSession s = new MediaSession(app, "BzCallRinger");
s.setPlaybackState(new PlaybackState.Builder()
.setState(PlaybackState.STATE_PLAYING, 0, 1f)
.setActions(0)
.build());
VolumeProvider vp = new VolumeProvider(VolumeProvider.VOLUME_CONTROL_RELATIVE, 100, 100) {
@Override public void onAdjustVolume(int direction) { silence(); }
@Override public void onSetVolumeTo(int volume) { silence(); }
};
s.setPlaybackToRemote(vp);
s.setActive(true);
mediaSession = s;
Log.i("bzcall", "volume-key capture armed");
} catch (Exception e) { Log.w("bzcall", "volume-key capture failed: " + e.getMessage()); }
}
// Stop the NOISE (audio + vibration) but leave the call ringing visually — the notification and the
// full-screen screen stay up so the user can still answer/decline. This is what a volume/power press does on a
// real phone: it hushes the ringtone, it does not reject the call. Idempotent.
static synchronized void silence() {
boolean wasRinging = ringtone != null || vibrator != null || mediaSession != null;
try { if (ringtone != null) ringtone.stop(); } catch (Exception ignored) {}
ringtone = null;
try { if (vibrator != null) vibrator.cancel(); } catch (Exception ignored) {}
vibrator = null;
releaseSession();
if (wasRinging) Log.i("bzcall", "ringer silenced (volume/power)");
} }
static synchronized void stop() { static synchronized void stop() {
@@ -58,5 +103,11 @@ class Ringer {
ringtone = null; ringtone = null;
try { if (vibrator != null) vibrator.cancel(); } catch (Exception ignored) {} try { if (vibrator != null) vibrator.cancel(); } catch (Exception ignored) {}
vibrator = null; vibrator = null;
releaseSession();
}
private static void releaseSession() {
try { if (mediaSession != null) { mediaSession.setActive(false); mediaSession.release(); } } catch (Exception ignored) {}
mediaSession = null;
} }
} }
+73
View File
@@ -16,6 +16,79 @@ if (!p || !fs.existsSync(p)) { console.log(' (AndroidManifest.xml not found —
try { try {
let s = fs.readFileSync(p, 'utf8'); let s = fs.readFileSync(p, 'utf8');
// (0) LiveKit Android (native call media, Increment 2) requires a higher minSdk than Capacitor's default.
// Bump it in the generated variables.gradle so the library manifest merge holds (a library minSdk above the
// app's fails the build). variables.gradle sits at android/variables.gradle (3 dirs up from app/src/main).
try {
const path = require('path');
const varsPath = path.resolve(path.dirname(p), '..', '..', '..', 'variables.gradle');
if (fs.existsSync(varsPath)) {
let v = fs.readFileSync(varsPath, 'utf8');
const m = v.match(/minSdkVersion\s*=\s*(\d+)/);
if (m && parseInt(m[1], 10) < 24) {
v = v.replace(/minSdkVersion\s*=\s*\d+/, 'minSdkVersion = 24');
fs.writeFileSync(varsPath, v);
console.log(' minSdkVersion bumped to 24 (LiveKit Android floor)');
} else {
console.log(' minSdkVersion already >= 24 or not found — no bump');
}
} else {
console.log(' variables.gradle not found — minSdk bump skipped');
}
} catch (e) { console.log(' minSdk bump skipped:', e.message); }
// (0b) JitPack repository for LiveKit Android's transitive deps (Increment 2). LiveKit 2.29.2 pulls in
// com.github.davidliu:audioswitch — a JitPack-only fork pinned to a commit-hash version. Gradle resolves
// the FULL transitive graph of :app:debugRuntimeClasspath against the APP module's (+ allprojects)
// repositories, NOT the native-call plugin's, so a jitpack repo declared only in the plugin is ignored and
// the build dies with "Could not find com.github.davidliu:audioswitch". Inject jitpack where :app sees it:
// the generated root build.gradle's allprojects{} block (Capacitor's template) and, if a newer template
// uses it instead, settings.gradle's dependencyResolutionManagement{}. Idempotent (keyed on jitpack.io),
// tolerant (any miss just logs + continues — never fails the build).
try {
const path = require('path');
const base = path.resolve(path.dirname(p), '..', '..', '..'); // android/
const jitLine = " maven { url 'https://jitpack.io' } // bzcJitpack: LiveKit Android transitive deps";
// root build.gradle → allprojects { repositories { ... } } (classic Capacitor template)
const rootGradle = path.join(base, 'build.gradle');
if (fs.existsSync(rootGradle)) {
let g = fs.readFileSync(rootGradle, 'utf8');
if (g.includes('jitpack.io')) {
console.log(' jitpack already present in build.gradle');
} else {
const re = /(allprojects\s*\{\s*repositories\s*\{)/;
if (re.test(g)) {
g = g.replace(re, '$1\n' + jitLine);
fs.writeFileSync(rootGradle, g);
console.log(' jitpack repo injected into allprojects (root build.gradle)');
} else {
console.log(' allprojects{repositories{}} not found in build.gradle — jitpack root inject skipped');
}
}
} else {
console.log(' root build.gradle not found — jitpack root inject skipped');
}
// settings.gradle → dependencyResolutionManagement { repositories { ... } } (newer templates, if any)
const settingsGradle = path.join(base, 'settings.gradle');
if (fs.existsSync(settingsGradle)) {
let s2 = fs.readFileSync(settingsGradle, 'utf8');
if (s2.includes('jitpack.io')) {
console.log(' jitpack already present in settings.gradle');
} else {
const re2 = /(dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{)/;
if (re2.test(s2)) {
s2 = s2.replace(re2, '$1\n' + jitLine);
fs.writeFileSync(settingsGradle, s2);
console.log(' jitpack repo injected into dependencyResolutionManagement (settings.gradle)');
} else {
console.log(' no dependencyResolutionManagement in settings.gradle — nothing to patch there');
}
}
}
} catch (e) { console.log(' jitpack inject skipped:', e.message); }
// (1) Native-call FirebaseMessagingService swap. The native-call plugin declares its own MESSAGING_EVENT // (1) Native-call FirebaseMessagingService swap. The native-call plugin declares its own MESSAGING_EVENT
// service (BzCallMessagingService) that handles call-wake data messages AND forwards regular push to Capacitor // service (BzCallMessagingService) that handles call-wake data messages AND forwards regular push to Capacitor
// via super. Remove @capacitor/push-notifications' own MessagingService here — the APP manifest is the highest // via super. Remove @capacitor/push-notifications' own MessagingService here — the APP manifest is the highest
+9
View File
@@ -39,5 +39,14 @@ if [ -z "$PW" ]; then
read -rsp "Server password for $USER@$HOST: " PW; echo read -rsp "Server password for $USER@$HOST: " PW; echo
fi fi
# Pre-flight: catch inline-<script> syntax errors in the served HTML BEFORE triggering a remote build (fast
# local feedback — the Docker build runs the same check authoritatively and would fail anyway). A syntax error
# in a big inline script breaks the whole page (it once caused an app-wide login outage). Skipped if node isn't
# on PATH. Checks the local tree, which matches origin/master once you've pushed.
if command -v node >/dev/null 2>&1; then
echo "==> Checking HTML inline-script syntax …"
node "$DIR/server/tools/check-syntax.js" || { echo "ERROR: HTML syntax check failed — fix before deploying (the build would fail too)."; exit 1; }
fi
echo "==> Triggering deploy on $USER@$HOST ($APPDIR) …" echo "==> Triggering deploy on $USER@$HOST ($APPDIR) …"
exec "$PLINK" -ssh -batch -hostkey "$HOSTKEY" -P "$PORT" -pw "$PW" "$USER@$HOST" "cd $APPDIR && bash deploy.sh $*" exec "$PLINK" -ssh -batch -hostkey "$HOSTKEY" -P "$PORT" -pw "$PW" "$USER@$HOST" "cd $APPDIR && bash deploy.sh $*"
+7 -1
View File
@@ -171,7 +171,7 @@ async function endDmCallByRoom(room, silent) {
// Mark a 1:1 call answered when the callee (anyone other than the caller) joins its room, so the end // Mark a 1:1 call answered when the callee (anyone other than the caller) joins its room, so the end
// message shows a real duration (from answer) and the unanswered timeout stands down. // message shows a real duration (from answer) and the unanswered timeout stands down.
function markDmAnswered(room, userId) { function markDmAnswered(room, userId, exceptToken) {
const key = roomToDmCall.get(room); if (!key) return; const key = roomToDmCall.get(room); if (!key) return;
const call = dmCalls.get(key); if (!call) return; const call = dmCalls.get(key); if (!call) return;
if (userId && userId !== call.startedBy && !call.answered) { if (userId && userId !== call.startedBy && !call.answered) {
@@ -183,6 +183,12 @@ function markDmAnswered(room, userId) {
// caller's UI from "ringing" to "connected". // caller's UI from "ringing" to "connected".
try { CHAT.pushToUser(userId, { type: 'call-taken', room, uuid: call.uuid }); } catch (_) {} try { CHAT.pushToUser(userId, { type: 'call-taken', room, uuid: call.uuid }); } catch (_) {}
try { CHAT.pushToUser(call.startedBy, { type: 'call-answered', room, uuid: call.uuid, by: userId }); } catch (_) {} try { CHAT.pushToUser(call.startedBy, { type: 'call-answered', room, uuid: call.uuid, by: userId }); } catch (_) {}
// ...but call-taken is WS-only; a backgrounded/killed sibling device ringing via CallKit/FCM has no live
// socket, so it keeps ringing. Push a native cancel to the answerer's OTHER devices (answered-elsewhere).
// exceptToken = the token of the device that actually answered — never cancel it (on iOS a cancel would end
// the just-answered call). Android's cancel branch never tears down a live call, so a stray cancel there is
// harmless; the exclusion still avoids a pointless FCM round-trip.
try { PUSH.sendCallCancel(userId, call.uuid, { exceptToken, reason: 'answered' }); } catch (_) {}
} }
} }
// When a user's chat socket (re)connects, re-send any call they're currently being rung into. The // When a user's chat socket (re)connects, re-send any call they're currently being rung into. The
+18
View File
@@ -55,6 +55,22 @@ const CALLKIT_ENABLED = process.env.CALLKIT_ENABLED === '1';
// FCM configured (FCM_SERVICE_ACCOUNT) + an app build carrying the native-call Android plugin. Set ANDROID_CALLS=1. // FCM configured (FCM_SERVICE_ACCOUNT) + an app build carrying the native-call Android plugin. Set ANDROID_CALLS=1.
const ANDROID_CALLS = process.env.ANDROID_CALLS === '1'; const ANDROID_CALLS = process.env.ANDROID_CALLS === '1';
// Native call MEDIA on Android (Increment 2): LiveKit audio in the plugin + a foreground service, instead of the
// WebView carrying the media. INDEPENDENT of ANDROID_CALLS (which only governs the native ring) so media can be
// flipped on for testing after the ring is solid. OFF (default) → Android keeps WebView call media (works today,
// video included). ON → the Android app drives call audio natively (background audio + Speaker/Bluetooth), but
// video is not yet native (Increment 2b) — so enable this for VOICE testing first. Requires an app build carrying
// the LiveKit-Android plugin. Set ANDROID_NATIVE_MEDIA=1.
const ANDROID_NATIVE_MEDIA = process.env.ANDROID_NATIVE_MEDIA === '1';
// Native call VIDEO on Android (Increment 2b): the plugin captures/publishes the camera and renders remote video
// tiles natively via hole-punch (a transparent WebView over native VideoViews), mirroring iOS. INDEPENDENT of
// ANDROID_NATIVE_MEDIA (audio) so video can be enabled only once a 2b-capable APK is installed. OFF (default) →
// Android native calls are AUDIO-ONLY and the WebView keeps its normal (opaque, dark) meeting grid — hole-punch
// stays off, so no transparent grid reveals the page behind it. ON → native video + hole-punch. Requires an app
// build carrying the 2b native-video plugin. Set ANDROID_NATIVE_VIDEO=1.
const ANDROID_NATIVE_VIDEO = process.env.ANDROID_NATIVE_VIDEO === '1';
module.exports = { module.exports = {
PORT: process.env.PORT || 8090, PORT: process.env.PORT || 8090,
HTTPS_PORT: process.env.HTTPS_PORT || 8443, HTTPS_PORT: process.env.HTTPS_PORT || 8443,
@@ -73,6 +89,8 @@ module.exports = {
GIPHY_API_KEY, GIPHY_API_KEY,
CALLKIT_ENABLED, CALLKIT_ENABLED,
ANDROID_CALLS, ANDROID_CALLS,
ANDROID_NATIVE_MEDIA,
ANDROID_NATIVE_VIDEO,
PUBLIC_DIR, PUBLIC_DIR,
REC_DIR, REC_DIR,
TRANS_DIR, TRANS_DIR,
+21 -1
View File
@@ -10,7 +10,27 @@ const path = require('path');
// to match the SQLite backend. Otherwise `expires_at < Date.now()` would compare a string to a number. // to match the SQLite backend. Otherwise `expires_at < Date.now()` would compare a string to a number.
types.setTypeParser(20, (v) => (v === null ? null : parseInt(v, 10))); types.setTypeParser(20, (v) => (v === null ? null : parseInt(v, 10)));
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 }); // Pool sizing + SAFETY TIMEOUTS. The old `max:10` with no connectionTimeoutMillis meant that once all 10
// connections were in use (a leaked/stuck client, a long query, or just a login burst) EVERY new query waited
// FOREVER for a free client — the "login spinner that never ends" outage. Now:
// • connectionTimeoutMillis — a saturated pool makes callers ERROR fast instead of hanging indefinitely.
// • statement_timeout — a runaway query is killed, so it can't pin a connection permanently.
// • idle_in_transaction_session_timeout — reclaims a client left "idle in transaction" (a hung tx()),
// which is the classic way a pool slowly leaks to zero.
// • max raised to 20 for more headroom.
// All tunable via env so they can be adjusted without a code change.
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: Number(process.env.PG_POOL_MAX || 20),
connectionTimeoutMillis: Number(process.env.PG_CONN_TIMEOUT_MS || 10000),
idleTimeoutMillis: Number(process.env.PG_IDLE_TIMEOUT_MS || 30000),
statement_timeout: Number(process.env.PG_STATEMENT_TIMEOUT_MS || 60000),
idle_in_transaction_session_timeout: Number(process.env.PG_IDLE_TX_TIMEOUT_MS || 60000),
});
// Don't let an unexpected idle-client error crash the process (pg emits 'error' on broken idle connections);
// the pool evicts the bad client and the next query gets a fresh one.
pool.on('error', (e) => { try { console.warn('[pg] idle client error:', e && e.message); } catch (_) {} });
// Repos use '?' placeholders (SQLite style); Postgres wants $1,$2,… — replace positionally. Safe because // Repos use '?' placeholders (SQLite style); Postgres wants $1,$2,… — replace positionally. Safe because
// no literal '?' appears inside any SQL string literal in this codebase. // no literal '?' appears inside any SQL string literal in this codebase.
+62 -12
View File
@@ -4336,7 +4336,9 @@ async function unsubscribePush(){
// (onDmCall / the group invite check nativeCallOn()). // (onDmCall / the group invite check nativeCallOn()).
let _callkitReady=false; let _callkitReady=false;
let _callWebViewMedia=false; // Android Increment 1: native full-screen ring, but MEDIA stays in the WebView (iOS runs the call media natively via the plugin). Governs the answer/outgoing branches below. let _callWebViewMedia=false; // Android Increment 1: native full-screen ring, but MEDIA stays in the WebView (iOS runs the call media natively via the plugin). Governs the answer/outgoing branches below.
let _androidNativeVideo=false; // Android Increment 2b: plugin renders native video tiles via hole-punch. OFF until a 2b APK is installed (server androidNativeVideo). While off, hole-punch stays off so the grid keeps its dark background (a transparent grid with no native video behind = white page). iOS video is always native.
let _nativeAnswered=new Set(); // rooms this device answered natively (so a "call-taken" from the server doesn't end our own call) let _nativeAnswered=new Set(); // rooms this device answered natively (so a "call-taken" from the server doesn't end our own call)
let _myVoipTok=''; // this device's own iOS VoIP (ios-voip) token — sent on answer so the answered-elsewhere cancel skips us
function nativeCallPlugin(){ const P=window.Capacitor&&window.Capacitor.Plugins; return (P&&P.NativeCall)||null; } function nativeCallPlugin(){ const P=window.Capacitor&&window.Capacitor.Plugins; return (P&&P.NativeCall)||null; }
function nativeCallOn(){ return _callkitReady; } function nativeCallOn(){ return _callkitReady; }
async function setupNativeCall(){ async function setupNativeCall(){
@@ -4345,19 +4347,28 @@ async function setupNativeCall(){
const plat=nativePlatform(); const plat=nativePlatform();
let cfg={}; try{ cfg=await fetch('/api/meetings/config').then(r=>r.json()); }catch(_){} let cfg={}; try{ cfg=await fetch('/api/meetings/config').then(r=>r.json()); }catch(_){}
if(plat==='android'){ if(plat==='android'){
// Android Increment 1: native full-screen ring (over the lock screen, wakes from closed via FCM data), but // Android native calling (ring + answer) gated on androidCall (server ANDROID_CALLS=1). The 'android' FCM
// the WebView still carries the call media. Gated on androidCall (server ANDROID_CALLS=1). The 'android' FCM
// token is already registered by setupNativePush — the server call-wake targets it, so no token step here. // token is already registered by setupNativePush — the server call-wake targets it, so no token step here.
if(!cfg || !cfg.androidCall){ console.log('[nativecall] Android native calling disabled by server — using WebView calls'); return; } if(!cfg || !cfg.androidCall){ console.log('[nativecall] Android native calling disabled by server — using WebView calls'); return; }
_callkitReady=true; _callWebViewMedia=true; _callkitReady=true;
// Increment 2: when the server enables native media, the plugin carries the call AUDIO (background audio +
// Speaker/Bluetooth) and the WebView must NOT also join SFU media. Until then (default), media stays in the
// WebView (video calls included). androidNativeMedia === true → _callWebViewMedia=false (native media path).
_callWebViewMedia = !(cfg && cfg.androidNativeMedia);
_androidNativeVideo = !!(cfg && cfg.androidNativeVideo); // Increment 2b: native video tiles + hole-punch
if(!_callWebViewMedia){
console.log('[nativecall] Android native call media ON — background audio + native routing' + (_androidNativeVideo?' + native video (2b)':' (audio-only; video in WebView path off)'));
// Android CAN switch the output (unlike iOS): mirror the plugin's real route into the speaker button.
try{ NC.addListener('audioRoute', (e)=>{ bzOnAndroidRoute(e); }); }catch(_){}
}
} else { } else {
// iOS CallKit: native media (the plugin owns this identity's one LiveKit connection). Flipped on once native // iOS CallKit: native media (the plugin owns this identity's one LiveKit connection). Flipped on once native
// media carries the audio. // media carries the audio.
if(!cfg || !cfg.callkit){ console.log('[callkit] disabled by server — using WebView calls'); return; } if(!cfg || !cfg.callkit){ console.log('[callkit] disabled by server — using WebView calls'); return; }
_callkitReady=true; _callkitReady=true;
// VoIP (PushKit) token → register as an 'ios-voip' device so the server sends CallKit wake pushes. // VoIP (PushKit) token → register as an 'ios-voip' device so the server sends CallKit wake pushes.
NC.addListener('voipToken', (e)=>{ const token=e&&e.token; if(!token) return; postJSON('/api/v1/devices',{ platform:'ios-voip', token }).then(()=>console.log('[callkit] voip token registered')).catch((err)=>console.warn('[callkit] voip register failed', err)); }); NC.addListener('voipToken', (e)=>{ const token=e&&e.token; if(!token) return; _myVoipTok=token; postJSON('/api/v1/devices',{ platform:'ios-voip', token }).then(()=>console.log('[callkit] voip token registered')).catch((err)=>console.warn('[callkit] voip register failed', err)); });
try{ const t=await NC.getToken(); if(t&&t.token) postJSON('/api/v1/devices',{ platform:'ios-voip', token:t.token }).catch(()=>{}); }catch(_){} try{ const t=await NC.getToken(); if(t&&t.token){ _myVoipTok=t.token; postJSON('/api/v1/devices',{ platform:'ios-voip', token:t.token }).catch(()=>{}); } }catch(_){}
} }
// Native media: the plugin carries the call over its OWN LiveKit room. The WebView must NOT also join // Native media: the plugin carries the call over its OWN LiveKit room. The WebView must NOT also join
// (LiveKit = one connection per identity). So on answer we do NOT enterMeeting — we just tell the server // (LiveKit = one connection per identity). So on answer we do NOT enterMeeting — we just tell the server
@@ -4366,6 +4377,11 @@ async function setupNativeCall(){
NC.addListener('answerCall', (d)=>{ try{ NC.addListener('answerCall', (d)=>{ try{
if(!d||!d.room) return; if(!d||!d.room) return;
_nativeAnswered.add(d.room); _nativeAnswered.add(d.room);
// Tell the server THIS device answered. Native/LiveKit calls bypass our mesh, so without this the call is
// never marked answered server-side and the answerer's OTHER devices (e.g. an iPhone ringing on CallKit)
// keep ringing. deviceToken = our own call token so the answered-elsewhere cancel skips us (never ends the
// call we just picked up). iOS → the VoIP token; Android → the FCM token.
try{ const _ct=(nativePlatform()==='ios')?_myVoipTok:_nativeTok; postJSON('/api/calls/answered',{ room:d.room, deviceToken:_ct||'' }).catch(()=>{}); }catch(_){}
dismissCallInvite(d.room); dismissCallInvite(d.room);
// Open the REAL meeting window. Joining the mesh marks the call answered server-side, shows the caller's // Open the REAL meeting window. Joining the mesh marks the call answered server-side, shows the caller's
// tile, and wires the end-both-sides lifecycle. meetReturn = the chat to land back on when the call ends. // tile, and wires the end-both-sides lifecycle. meetReturn = the chat to land back on when the call ends.
@@ -4379,7 +4395,7 @@ async function setupNativeCall(){
}catch(e){ console.warn('[nativecall] answerCall failed:', e); } }); }catch(e){ console.warn('[nativecall] answerCall failed:', e); } });
// Enforce the muted-by-default rule against the plugin's actual mic once the room connects (belt-and-braces // Enforce the muted-by-default rule against the plugin's actual mic once the room connects (belt-and-braces
// for builds whose plugin still connects the mic live). meetMic is the source of truth for the mic button. // for builds whose plugin still connects the mic live). meetMic is the source of truth for the mic button.
NC.addListener('callConnected', ()=>{ if(meetNative){ try{ NC.setMuted({ muted:!meetMic }); }catch(_){} if(meetCam){ try{ NC.setCamera({ on:true }); }catch(_){} } } }); // re-applies mic/cam after a #12 media reconnect too NC.addListener('callConnected', ()=>{ if(meetNative){ try{ NC.setMuted({ muted:!meetMic }); }catch(_){} if(meetCam){ try{ NC.setCamera({ on:true }); }catch(_){} try{ if(nativeCallAudioOn() && NC.getAudioRoute){ NC.getAudioRoute().then((r)=>bzOnAndroidRoute(r)).catch(()=>{}); } }catch(_){} } } }); // re-applies mic/cam (and seeds the Android audio route) after a #12 media reconnect too
NC.addListener('callError', (e)=>{ console.warn('[callkit] call error:', (e&&e.error)||''); }); NC.addListener('callError', (e)=>{ console.warn('[callkit] call error:', (e&&e.error)||''); });
// Muted from the CallKit system UI → reflect it in the meeting window's mic button. // Muted from the CallKit system UI → reflect it in the meeting window's mic button.
NC.addListener('setMuted', (e)=>{ if(!e||meetState!=='call'||!meetNative) return; meetMic=!e.muted; try{ updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); }catch(_){} }); NC.addListener('setMuted', (e)=>{ if(!e||meetState!=='call'||!meetNative) return; meetMic=!e.muted; try{ updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); }catch(_){} });
@@ -5582,7 +5598,12 @@ function bzApplyRoute(route){
} }
// The speaker toggle is usable if the browser supports output switching (setSinkId) OR the native iOS route // The speaker toggle is usable if the browser supports output switching (setSinkId) OR the native iOS route
// plugin is present. // plugin is present.
function canRouteAudio(){ try{ if(nativeAudioRoute()) return true; return typeof HTMLMediaElement!=='undefined' && typeof HTMLMediaElement.prototype.setSinkId==='function'; }catch(_){ return false; } } function canRouteAudio(){ try{ if(nativeAudioRoute()) return true; if(nativeCallAudioOn()) return true; return typeof HTMLMediaElement!=='undefined' && typeof HTMLMediaElement.prototype.setSinkId==='function'; }catch(_){ return false; } }
// Android native call media: the NativeCall plugin owns the audio and CAN switch the output (speaker/earpiece/
// bluetooth), unlike iOS. True only when the server enabled native media and the plugin is present.
function nativeCallAudioOn(){ try{ return nativePlatform()==='android' && _callkitReady && !_callWebViewMedia && !!nativeCallPlugin(); }catch(_){ return false; } }
let _androidRoutes=[]; // outputs the plugin reports we can switch to (from the audioRoute event / getAudioRoute)
function bzOnAndroidRoute(e){ if(!e) return; if(e.output) _nativeOutput=e.output; if(Array.isArray(e.available)) _androidRoutes=e.available; refreshSpkBtn(); }
// new #4: let the user drag a floating bar out of the way — it otherwise covers part of the shared // new #4: let the user drag a floating bar out of the way — it otherwise covers part of the shared
// screen. Drag from anywhere on the bar except a control. Position is remembered per bar. // screen. Drag from anywhere on the bar except a control. Position is remembered per bar.
function makeDraggable(el, key){ function makeDraggable(el, key){
@@ -5631,11 +5652,22 @@ let meetRoute=(()=>{ try{ return localStorage.getItem('bzc_route')||'speaker'; }
let _nativeOutput=null; let _nativeOutput=null;
function iosRoute(){ return _nativeOutput; } // truthy only on iOS native once the plugin has reported function iosRoute(){ return _nativeOutput; } // truthy only on iOS native once the plugin has reported
function spkIcon(){ function spkIcon(){
if(iosRoute()){ return _nativeOutput==='bluetooth' ? 'bluetooth' : (_nativeOutput==='wired' ? 'headphones' : 'speaker'); } // iosRoute() is truthy on iOS AND on Android native media (both drive the icon from the real output _nativeOutput).
if(iosRoute()){
if(_nativeOutput==='bluetooth') return 'bluetooth';
if(_nativeOutput==='wired') return 'headphones';
if(_nativeOutput==='earpiece') return 'speakerOff'; // Android can route to the earpiece (iOS reports 'receiver')
return 'speaker';
}
return meetRoute==='bt' ? 'bluetooth' : (meetRoute==='speaker' ? 'speaker' : 'speakerOff'); return meetRoute==='bt' ? 'bluetooth' : (meetRoute==='speaker' ? 'speaker' : 'speakerOff');
} }
function routeLabel(){ function routeLabel(){
if(iosRoute()){ return _nativeOutput==='bluetooth' ? 'Bluetooth' : (_nativeOutput==='wired' ? 'Headset' : 'Speaker'); } if(iosRoute()){
if(_nativeOutput==='bluetooth') return 'Bluetooth';
if(_nativeOutput==='wired') return 'Headset';
if(_nativeOutput==='earpiece') return 'Earpiece';
return 'Speaker';
}
return meetRoute==='bt' ? 'Bluetooth / headset' : (meetRoute==='speaker' ? 'Speaker' : 'Earpiece'); return meetRoute==='bt' ? 'Bluetooth / headset' : (meetRoute==='speaker' ? 'Speaker' : 'Earpiece');
} }
function refreshSpkBtn(){ function refreshSpkBtn(){
@@ -5665,7 +5697,7 @@ function bzInitNativeRoute(){
function bzOnNativeRoute(output){ function bzOnNativeRoute(output){
if(!output) return; if(!output) return;
_nativeOutput=output; _nativeOutput=output;
meetRoute = (output==='bluetooth'||output==='wired') ? 'bt' : (output==='speaker' ? 'speaker' : meetRoute); meetRoute = (output==='bluetooth'||output==='wired') ? 'bt' : (output==='earpiece' ? 'earpiece' : (output==='speaker' ? 'speaker' : meetRoute));
refreshSpkBtn(); refreshSpkBtn();
} }
// Detect a connected BT/headset output so the cycle can include it (and the icon can show it). // Detect a connected BT/headset output so the cycle can include it (and the icon can show it).
@@ -5777,6 +5809,19 @@ async function toggleSpeakerphone(){
: 'On speaker — connect a Bluetooth/wired headset to use it'); : 'On speaker — connect a Bluetooth/wired headset to use it');
return; return;
} }
// Android native call media: the plugin CAN switch the route — cycle through the outputs it reports (bluetooth/
// wired only appear while a headset is connected).
if(nativeCallAudioOn()){
const NC=nativeCallPlugin(); if(!NC){ return; }
const avail=(_androidRoutes && _androidRoutes.length) ? _androidRoutes : ['speaker','earpiece'];
const order=['speaker','earpiece','bluetooth','wired'].filter(r=>avail.indexOf(r)>=0);
const cur=_nativeOutput||'speaker';
const i=order.indexOf(cur);
const next=order[(i<0?0:(i+1))%(order.length||1)] || 'speaker';
try{ const r=await NC.setAudioRoute({ route: next }); if(r && r.output){ _nativeOutput=r.output; } else { _nativeOutput=next; } }catch(_){ _nativeOutput=next; }
refreshSpkBtn(); toast(routeLabel());
return;
}
let outs=[]; try{ outs=(await navigator.mediaDevices.enumerateDevices()).filter(x=>x.kind==='audiooutput'); }catch(_){} let outs=[]; try{ outs=(await navigator.mediaDevices.enumerateDevices()).filter(x=>x.kind==='audiooutput'); }catch(_){}
const bt=outs.find(d=>isBtLabel(d.label)); _btConnected=!!bt; const bt=outs.find(d=>isBtLabel(d.label)); _btConnected=!!bt;
const routes = ['speaker','earpiece'].concat(bt?['bt']:[]); const routes = ['speaker','earpiece'].concat(bt?['bt']:[]);
@@ -6140,10 +6185,15 @@ function bzNativeSyncTiles(){
}); });
try{ NC.syncVideoTiles({ tiles }); }catch(_){} try{ NC.syncVideoTiles({ tiles }); }catch(_){}
} }
// Native video available? iOS: always (shipped). Android: only when a 2b-capable APK is active (server flag).
// While false we must NOT enable hole-punch — a transparent grid with no native video behind it shows the white
// page (the 2a audio-only bug). Keeps the opaque dark grid + web avatars instead.
function bzNativeVideoOn(){ try{ const p=nativePlatform(); if(p==='ios') return true; if(p==='android') return !!_androidNativeVideo; return false; }catch(_){ return false; } }
function bzNativeStartTiles(){ function bzNativeStartTiles(){
if(!meetNative) return; if(!meetNative) return;
const NC=nativeCallPlugin(); const NC=nativeCallPlugin();
if(NC && NC.setHolePunch){ bzHolePunch=true; try{ NC.setHolePunch({ on:true }); }catch(_){} document.body.classList.add('bz-hp'); } // permanent fix: video behind transparent WebView if(NC && NC.setHolePunch && bzNativeVideoOn()){ bzHolePunch=true; try{ NC.setHolePunch({ on:true }); }catch(_){} document.body.classList.add('bz-hp'); } // video behind transparent WebView (only when native video exists)
if(!bzNativeVideoOn()) return; // no native video (e.g. Android 2a audio-only): nothing to render; keep the normal web grid
if(_ncTileTimer) return; _ncTileTimer=setInterval(bzNativeSyncTiles, 400); bzNativeSyncTiles(); if(_ncTileTimer) return; _ncTileTimer=setInterval(bzNativeSyncTiles, 400); bzNativeSyncTiles();
} }
function bzNativeStopTiles(){ function bzNativeStopTiles(){
@@ -6164,7 +6214,7 @@ function bzNativeStopTiles(){
if(!meetNative || !bzHolePunch) return null; if(!meetNative || !bzHolePunch) return null;
const t=target&&target.closest&&target.closest('#meetGrid .meet-tile'); if(!t) return null; // a menu/button over the tile isn't inside it → no zoom const t=target&&target.closest&&target.closest('#meetGrid .meet-tile'); if(!t) return null; // a menu/button over the tile isn't inside it → no zoom
const id=t.id.replace('meet-tile-',''); if(id==='__local' || !meetSharers.has(id)) return null; // only the shared screen const id=t.id.replace('meet-tile-',''); if(id==='__local' || !meetSharers.has(id)) return null; // only the shared screen
return { el:t, uid: meetPeerUids.get(id)||'' }; return { el:t, uid: id }; // key MUST match syncVideoTiles' tile key = the peerId (LiveKit identity, #12), NOT meetPeerUids.get(id) (the user id) — else the plugin can't find the renderer to zoom
} }
document.addEventListener('touchstart',(e)=>{ document.addEventListener('touchstart',(e)=>{
const s=pick(e.target); if(!s || !s.uid){ el=null; return; } const s=pick(e.target); if(!s || !s.uid){ el=null; return; }
+17 -6
View File
@@ -203,23 +203,34 @@ async function sendCallNotification(userId, data) {
return 'push'; return 'push';
} }
// Tell a user's device(s) to STOP ringing a call (caller hung up / declined / ring timed out). Sent as a // Tell a user's device(s) to STOP ringing a call (caller hung up / declined / ring timed out / answered on
// VoIP push so it reaches a killed app that has no WebSocket — the CallKit plugin ends the reported call. // another device). Sent as a VoIP push (iOS) / high-priority data message (Android) so it reaches a killed app
// No-op for non-VoIP devices (their ring is a normal notification that just goes away). // that has no WebSocket — the native plugin ends/dismisses the reported call. No-op for non-native devices
async function sendCallCancel(userId, callUUID) { // (their ring is a normal notification that just goes away).
// opts.exceptToken — skip this exact device token. Used for "answered elsewhere": the device that actually
// answered must NOT be cancelled (on iOS a cancel ends the just-answered CallKit call), only its SIBLINGS.
// opts.reason — optional tag carried in the payload ('answered' etc.), for logs / a future answered-elsewhere
// call-history entry. Native plugins ignore unknown fields, so it's safe to always include.
async function sendCallCancel(userId, callUUID, opts) {
if (!callUUID) return; if (!callUUID) return;
const exceptToken = opts && opts.exceptToken;
const reason = opts && opts.reason;
let toks = []; let toks = [];
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; } try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
// iOS: cancel the CallKit ring via a VoIP push. // iOS: cancel the CallKit ring via a VoIP push.
if (apnsCfg && process.env.CALLKIT_ENABLED === '1') { if (apnsCfg && process.env.CALLKIT_ENABLED === '1') {
for (const t of toks.filter((t) => t.platform === 'ios-voip')) { for (const t of toks.filter((t) => t.platform === 'ios-voip')) {
try { const r = await sendApnsVoip(t.token, { type: 'cancel', callUUID }); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {} if (exceptToken && t.token === exceptToken) continue; // never cancel the device that answered
const payload = reason ? { type: 'cancel', callUUID, reason } : { type: 'cancel', callUUID };
try { const r = await sendApnsVoip(t.token, payload); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
} }
} }
// Android: cancel the native ring via a high-priority data message (BzCallMessagingService dismisses it). // Android: cancel the native ring via a high-priority data message (BzCallMessagingService dismisses it).
if (fcmSA && process.env.ANDROID_CALLS === '1') { if (fcmSA && process.env.ANDROID_CALLS === '1') {
for (const t of toks.filter((t) => t.platform === 'android')) { for (const t of toks.filter((t) => t.platform === 'android')) {
try { const r = await sendFcmData(t.token, { bzcall: '1', type: 'cancel', callUUID }); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {} if (exceptToken && t.token === exceptToken) continue; // never cancel the device that answered
const data = reason ? { bzcall: '1', type: 'cancel', callUUID, reason } : { bzcall: '1', type: 'cancel', callUUID };
try { const r = await sendFcmData(t.token, data); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
} }
} }
} }
+6 -4
View File
@@ -116,7 +116,7 @@ const API_KEY_SCOPES = ['report:read', 'audit:read'];
const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence'); const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence');
const CALLS = require('./calls'); const CALLS = require('./calls');
require('./reminders'); // start the 10-minute meeting-reminder loop require('./reminders'); // start the 10-minute meeting-reminder loop
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, PUBLIC_BASE_URL, GIPHY_API_KEY, CALLKIT_ENABLED, ANDROID_CALLS } = require('./config'); const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, PUBLIC_BASE_URL, GIPHY_API_KEY, CALLKIT_ENABLED, ANDROID_CALLS, ANDROID_NATIVE_MEDIA, ANDROID_NATIVE_VIDEO } = require('./config');
const https = require('https'); const https = require('https');
// Small GET-JSON helper for the GIPHY proxy (keeps the key server-side). // Small GET-JSON helper for the GIPHY proxy (keeps the key server-side).
function fetchJSON(url) { function fetchJSON(url) {
@@ -993,7 +993,7 @@ route('POST', '/api/calls/invite', async (req, res) => {
// Does this deployment use the LiveKit SFU for meeting media? The client asks on load; if sfu is // Does this deployment use the LiveKit SFU for meeting media? The client asks on load; if sfu is
// false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…). // false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…).
route('GET', '/api/meetings/config', (req, res) => { route('GET', '/api/meetings/config', (req, res) => {
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '', callkit: CALLKIT_ENABLED, androidCall: ANDROID_CALLS, fcm: PUSH.fcmReady() }); json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '', callkit: CALLKIT_ENABLED, androidCall: ANDROID_CALLS, androidNativeMedia: ANDROID_NATIVE_MEDIA, androidNativeVideo: ANDROID_NATIVE_VIDEO, fcm: PUSH.fcmReady() });
}); });
// #5 GIF search — server-side GIPHY proxy so the API key never reaches the browser. Empty q → trending. // #5 GIF search — server-side GIPHY proxy so the API key never reaches the browser. Empty q → trending.
@@ -1120,9 +1120,11 @@ route('POST', '/api/calls/decline', async (req, res) => {
route('POST', '/api/calls/answered', async (req, res) => { route('POST', '/api/calls/answered', async (req, res) => {
const u = await currentUser(req); const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const { room } = await readBody(req); const { room, deviceToken } = await readBody(req);
if (!room) return json(res, 400, { error: 'room required' }); if (!room) return json(res, 400, { error: 'room required' });
try { CALLS.markDmAnswered(String(room), u.id); } catch (_) {} // deviceToken = THIS device's own call-push token (ios-voip / android fcm). Passed so the answered-elsewhere
// cancel sent to the user's other devices skips the one that actually answered.
try { CALLS.markDmAnswered(String(room), u.id, deviceToken ? String(deviceToken) : undefined); } catch (_) {}
json(res, 200, { ok: true }); json(res, 200, { ok: true });
}); });
route('POST', '/api/calls/end', async (req, res) => { route('POST', '/api/calls/end', async (req, res) => {
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env node
// Pre-deploy syntax guard for the served HTML pages.
//
// WHY: our pages carry large INLINE <script> blocks. A single JavaScript syntax error in one block makes the
// WHOLE block fail to parse — every function/handler it defines becomes undefined. That is exactly what caused
// an app-wide login outage (home.html's `callConnected` listener was missing one `}`: the boot code never ran,
// so after login the splash spun forever). The browser catches it instantly; our deploy did not. This script
// turns that class of bug into a FAILED BUILD (exit 1) instead of a production outage.
//
// It parses — but never executes — every inline <script> in server/public/*.html with Node's `vm`, so it only
// checks SYNTAX (browser globals like `document` are irrelevant to parsing). Runs in the Docker build (see
// Dockerfile, right after `COPY server/ ./`) and locally from redeploy.sh for fast feedback.
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const dir = path.join(__dirname, '..', 'public');
let files;
try {
files = fs.readdirSync(dir).filter((f) => f.toLowerCase().endsWith('.html'));
} catch (e) {
console.error('[check-syntax] cannot read ' + dir + ': ' + e.message);
process.exit(1);
}
// Inline <script> … </script> (skip ones with a src= attribute — those are external files, nothing inline).
const re = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi;
let errors = 0;
let blocks = 0;
for (const f of files) {
const html = fs.readFileSync(path.join(dir, f), 'utf8');
re.lastIndex = 0;
let m;
while ((m = re.exec(html))) {
if (/\bsrc\s*=/i.test(m[1] || '')) continue; // external script
blocks++;
const code = m[2];
// File line where this block's body starts, so the reported line is file-relative (not block-relative).
const openTagEnd = m.index + m[0].indexOf('>') + 1;
const startLine = html.slice(0, openTagEnd).split('\n').length - 1; // 0-indexed offset
try {
new vm.Script(code, { filename: f, lineOffset: startLine });
} catch (e) {
errors++;
const loc = (String(e.stack || '').split('\n')[0] || '').trim(); // e.g. "home.html:4398"
console.error('[check-syntax] ' + f + ' — ' + (e.message || 'syntax error') +
(loc && /:\d+$/.test(loc) ? (' @ ' + loc) : (' (inline <script> near line ' + (startLine + 1) + ')')));
}
}
}
if (errors) {
console.error('[check-syntax] FAILED: ' + errors + ' inline <script> block(s) with syntax errors. Aborting deploy.');
process.exit(1);
}
console.log('[check-syntax] OK: ' + blocks + ' inline <script> block(s) parsed across ' + files.length + ' HTML file(s).');
process.exit(0);