Compare commits

...
15 Commits
Author SHA1 Message Date
SravanandClaude Opus 4.8 3b5dd81016 android(call): shared screen fits (contain) in portrait, not center-cropped
Received shared screen fit the window in landscape but was center-cropped in
portrait (only the middle showed). The LiveKit TextureViewRenderer's own
SCALE_ASPECT_FIT didn't letterbox a wide screen in a tall tile — it filled and
cropped. Fix: compute the contain (letterbox) rect ourselves and size the
renderer to it, centred in the tile, then FILL (renderer == video aspect → no
crop), so the WHOLE screen shows in both orientations. Landscape unchanged
(tile aspect ~= video aspect → rect ~= full tile).

The live frame aspect comes from a small VideoSink attached to the screen
track alongside the renderer; frameAspect is a ConcurrentHashMap (written on
the render thread, read on the UI thread). Sinks + aspect are cleaned up on
tile removal / track change / hole-punch teardown. Camera tiles unchanged
(full-tile cover). Pinch-zoom still applies on top of the fitted renderer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-11-04 22:39:24 +05:30
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 1187 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
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);
# the /data volume holds uploads / recordings / transcripts / downloads (see docker-compose.yml).
ENV PORT=8090
+74 -5
View File
@@ -1,6 +1,6 @@
# 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
> [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) —
**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
- **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:**
- 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`).
- 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/`)
@@ -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,
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
`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
share sheet), `file-opener` (native Quick Look / open-with).
- **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
(`mobile/scripts/android-patch.js`) since `mobile/android/` is generated fresh per build.
- **Android build:** JDK **21**, AGP 8.7 / Gradle 8.11, **minSdk 24** (raised for LiveKit), target SDK 36,
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
@@ -155,4 +205,23 @@ Bump the **Last updated** date at the top and add a line to the Changelog below.
## 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.
@@ -1,32 +1,48 @@
// 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
// the plugins that already build. Java (not Kotlin) to match the proven official-plugin structure — no extra
// kotlin-gradle-plugin wiring in the library module, one less thing to fail when authored blind.
// node_modules/@capacitor/status-bar/android/build.gradle) so it drops into the CI-generated app like the
// plugins that already build.
//
// 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 {
junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
androidxCoreVersion = project.hasProperty('androidxCoreVersion') ? rootProject.ext.androidxCoreVersion : '1.12.0'
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.
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 {
ext.bzc_kotlin = rootProject.hasProperty('kotlin_version') ? rootProject.ext.kotlin_version : '1.9.24'
repositories {
google()
mavenCentral()
}
dependencies {
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: 'kotlin-android'
android {
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 {
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
versionCode 1
versionName "1.0"
@@ -44,11 +60,16 @@ android {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
repositories {
google()
mavenCentral()
// LiveKit pulls transitive artifacts (its WebRTC fork) that live on JitPack.
maven { url 'https://jitpack.io' }
}
dependencies {
@@ -62,4 +83,15 @@ dependencies {
// RemoteMessage / FirebaseMessaging (transitive from the push plugin, but declared so this module compiles
// against it directly; same version → no resolution conflict).
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.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>
<!-- 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
@@ -32,5 +42,12 @@
android:showWhenLocked="true"
android:turnScreenOn="true"
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>
</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,208 @@
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 java.util.concurrent.ConcurrentHashMap
import livekit.org.webrtc.RendererCommon
import livekit.org.webrtc.VideoFrame
import livekit.org.webrtc.VideoSink
// 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>()
// Shared-screen fit: the renderer's own aspect-fit crops a wide screen in a tall (portrait) tile, so we size
// the renderer to the letterboxed rect ourselves. For that we need the live frame aspect (w/h), captured by a
// tiny VideoSink attached to the screen track alongside the renderer.
private val frameAspect = ConcurrentHashMap<String, Float>() // written on the render thread, read on the UI thread
private val aspectSinks = HashMap<String, VideoSink>()
// 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
val tileW = t.w * density; val tileH = t.h * density
val tileX = t.x * density; val tileY = t.y * density
if (t.wantScreen) {
// A shared screen must show in FULL (contain) in BOTH orientations. The renderer's own
// SCALE_ASPECT_FIT was cropping a wide screen in a tall (portrait) tile, so we compute the
// letterbox rect ourselves from the live frame aspect and size the renderer to it (centred in
// the tile), then FILL it (renderer == video aspect → no crop). Until the first frame's aspect
// is known we fall back to the whole tile.
val a = frameAspect[t.key] ?: 0f
var rw = tileW; var rh = tileH; var ox = 0.0; var oy = 0.0
if (a > 0f) {
if (a > tileW / tileH) { rh = tileW / a; oy = (tileH - rh) / 2.0 } // wider than tile → bars top/bottom
else { rw = tileH * a; ox = (tileW - rw) / 2.0 } // taller than tile → bars left/right
}
r.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL)
val lp = FrameLayout.LayoutParams(rw.toInt(), rh.toInt())
lp.leftMargin = (tileX + ox).toInt(); lp.topMargin = (tileY + oy).toInt()
r.layoutParams = lp
} else {
r.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL)
val lp = FrameLayout.LayoutParams(tileW.toInt(), tileH.toInt())
lp.leftMargin = tileX.toInt(); lp.topMargin = tileY.toInt()
r.layoutParams = lp
}
val cur = bound[t.key]
if (cur !== track) {
if (cur != null) { try { cur.removeRenderer(r) } catch (_: Throwable) {}; detachAspectSink(t.key, cur) }
try { track.addRenderer(r) } catch (_: Throwable) {}
bound[t.key] = track
}
if (t.wantScreen) attachAspectSink(t.key, track) else detachAspectSink(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) {}; detachAspectSink(e.key, tr) }
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()
}
}
}
// A screen track's live frame aspect is captured by this sink so syncTiles can size the renderer to the exact
// letterbox rect. One per screen tile key, attached to the same track as the renderer.
private fun attachAspectSink(key: String, track: VideoTrack) {
if (aspectSinks[key] != null) return
val sink = VideoSink { frame: VideoFrame ->
val w = frame.rotatedWidth; val h = frame.rotatedHeight
if (w > 0 && h > 0) frameAspect[key] = w.toFloat() / h.toFloat()
}
aspectSinks[key] = sink
try { track.addRenderer(sink) } catch (_: Throwable) {}
}
private fun detachAspectSink(key: String, track: VideoTrack) {
val s = aspectSinks.remove(key) ?: return
try { track.removeRenderer(s) } catch (_: Throwable) {}
frameAspect.remove(key)
}
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) {}
}
for ((key, s) in aspectSinks) {
val tr = bound[key]
if (tr != null) try { tr.removeRenderer(s) } catch (_: Throwable) {}
}
aspectSinks.clear()
frameAspect.clear()
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.util.Log;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.View;
import android.view.WindowManager;
import android.widget.LinearLayout;
@@ -214,6 +215,20 @@ public class IncomingCallActivity extends Activity {
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
protected void onDestroy() {
// 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;
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.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.ActivityCallback;
import com.getcapacitor.annotation.CapacitorPlugin;
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 —
// the SAME jsName the iOS plugin uses, so the web bridge (setupNativeCall in home.html) drives both platforms
// through one code path.
@@ -32,6 +47,7 @@ public class NativeCallPlugin extends Plugin {
@Override
public void load() {
instance = this;
wireMedia();
if (pendingAnswer != null) {
JSObject a = pendingAnswer;
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
protected void handleOnDestroy() {
super.handleOnDestroy();
@@ -90,41 +157,177 @@ public class NativeCallPlugin extends Plugin {
call.resolve();
}
// Outgoing call from the web app. Increment 1: media stays in the WebView, so there is nothing to ring or
// connect natively here — the callee is rung by the server's FCM call-wake. Just record it so a later
// endCall/cancel can match. (Increment 2 will start a foreground service + native LiveKit here.)
// Outgoing call from the web app. Increment 2 (native media): start the ongoing-call foreground service and
// connect the LiveKit room with the url/token the web minted (/api/meetings/token). Connect MUTED — the web
// re-applies the real mic state on the callConnected event. The callee is rung by the server's FCM call-wake.
@PluginMethod
public void reportOutgoingCall(PluginCall call) {
String uuid = call.getString("callUUID", "");
if (!uuid.isEmpty()) CallStore.markActive(uuid);
startNativeMedia(call.getString("url", ""), call.getString("token", ""));
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
public void endCall(PluginCall call) {
String uuid = call.getString("callUUID", "");
if (!uuid.isEmpty()) CallStore.markEnded(uuid);
CallStore.cancel(getContext());
IncomingCallActivity.dismissIfShowing();
try { CallVideo.setHolePunch(getActivity(), getBridge() != null ? getBridge().getWebView() : null, false); } catch (Exception ignored) {}
CallMedia.disconnect();
CallForegroundService.stop(getContext());
call.resolve();
}
// Increment 1: mute is handled by the WebView (it owns the media). No-op so the shared bridge doesn't reject.
@PluginMethod public void setMuted(PluginCall call) { call.resolve(); }
// Mute/unmute the native mic (Increment 2 — the plugin owns the media now).
@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
// for now so the shared web bridge never rejects on Android; real bodies land with the native-media build. ---
@PluginMethod public void reconnectRoom(PluginCall call) { call.resolve(); }
@PluginMethod public void setCamera(PluginCall call) { call.resolve(); }
@PluginMethod public void switchCamera(PluginCall call) { call.resolve(); }
@PluginMethod public void startScreenShare(PluginCall call) { call.resolve(); }
@PluginMethod public void stopScreenShare(PluginCall call) { call.resolve(); }
// Connect (answer path) or re-home (#12 multi-device) the native LiveKit room with web-minted creds. On
// Android this is the primary connect on answer: the FCM call-wake carries no livekit creds, so the web calls
// this (bzNativeReconnectMedia) with a peerId token once the meeting WebSocket has joined.
@PluginMethod
public void reconnectRoom(PluginCall call) {
startNativeMedia(call.getString("url", ""), call.getString("token", ""));
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 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 stopTranscription(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.Ringtone;
import android.media.RingtoneManager;
import android.media.VolumeProvider;
import android.media.session.MediaSession;
import android.media.session.PlaybackState;
import android.net.Uri;
import android.os.Build;
import android.os.Vibrator;
@@ -18,6 +21,10 @@ import android.util.Log;
class Ringer {
private static Ringtone ringtone;
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) {
if (ringtone != null) return; // already ringing
@@ -51,6 +58,44 @@ class Ringer {
vibrator = v;
}
} 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() {
@@ -58,5 +103,11 @@ class Ringer {
ringtone = null;
try { if (vibrator != null) vibrator.cancel(); } catch (Exception ignored) {}
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 {
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
// 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
+9
View File
@@ -39,5 +39,14 @@ if [ -z "$PW" ]; then
read -rsp "Server password for $USER@$HOST: " PW; echo
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) …"
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
// 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 call = dmCalls.get(key); if (!call) return;
if (userId && userId !== call.startedBy && !call.answered) {
@@ -183,6 +183,12 @@ function markDmAnswered(room, userId) {
// caller's UI from "ringing" to "connected".
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 (_) {}
// ...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
+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.
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 = {
PORT: process.env.PORT || 8090,
HTTPS_PORT: process.env.HTTPS_PORT || 8443,
@@ -73,6 +89,8 @@ module.exports = {
GIPHY_API_KEY,
CALLKIT_ENABLED,
ANDROID_CALLS,
ANDROID_NATIVE_MEDIA,
ANDROID_NATIVE_VIDEO,
PUBLIC_DIR,
REC_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.
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
// 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()).
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 _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 _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 nativeCallOn(){ return _callkitReady; }
async function setupNativeCall(){
@@ -4345,19 +4347,28 @@ async function setupNativeCall(){
const plat=nativePlatform();
let cfg={}; try{ cfg=await fetch('/api/meetings/config').then(r=>r.json()); }catch(_){}
if(plat==='android'){
// Android Increment 1: native full-screen ring (over the lock screen, wakes from closed via FCM data), but
// the WebView still carries the call media. Gated on androidCall (server ANDROID_CALLS=1). The 'android' FCM
// Android native calling (ring + answer) 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.
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 {
// iOS CallKit: native media (the plugin owns this identity's one LiveKit connection). Flipped on once native
// media carries the audio.
if(!cfg || !cfg.callkit){ console.log('[callkit] disabled by server — using WebView calls'); return; }
_callkitReady=true;
// 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)); });
try{ const t=await NC.getToken(); if(t&&t.token) postJSON('/api/v1/devices',{ platform:'ios-voip', token:t.token }).catch(()=>{}); }catch(_){}
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){ _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
// (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{
if(!d||!d.room) return;
_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);
// 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.
@@ -4379,7 +4395,7 @@ async function setupNativeCall(){
}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
// 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)||''); });
// 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(_){} });
@@ -5582,7 +5598,12 @@ function bzApplyRoute(route){
}
// The speaker toggle is usable if the browser supports output switching (setSinkId) OR the native iOS route
// 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
// screen. Drag from anywhere on the bar except a control. Position is remembered per bar.
function makeDraggable(el, key){
@@ -5631,11 +5652,22 @@ let meetRoute=(()=>{ try{ return localStorage.getItem('bzc_route')||'speaker'; }
let _nativeOutput=null;
function iosRoute(){ return _nativeOutput; } // truthy only on iOS native once the plugin has reported
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');
}
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');
}
function refreshSpkBtn(){
@@ -5665,7 +5697,7 @@ function bzInitNativeRoute(){
function bzOnNativeRoute(output){
if(!output) return;
_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();
}
// 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');
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(_){}
const bt=outs.find(d=>isBtLabel(d.label)); _btConnected=!!bt;
const routes = ['speaker','earpiece'].concat(bt?['bt']:[]);
@@ -6140,10 +6185,15 @@ function bzNativeSyncTiles(){
});
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(){
if(!meetNative) return;
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();
}
function bzNativeStopTiles(){
@@ -6164,7 +6214,7 @@ function bzNativeStopTiles(){
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 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)=>{
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';
}
// Tell a user's device(s) to STOP ringing a call (caller hung up / declined / ring timed out). Sent as a
// VoIP push so it reaches a killed app that has no WebSocket — the CallKit plugin ends the reported call.
// No-op for non-VoIP devices (their ring is a normal notification that just goes away).
async function sendCallCancel(userId, callUUID) {
// Tell a user's device(s) to STOP ringing a call (caller hung up / declined / ring timed out / answered on
// another device). Sent as a VoIP push (iOS) / high-priority data message (Android) so it reaches a killed app
// that has no WebSocket — the native plugin ends/dismisses the reported call. No-op for non-native devices
// (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;
const exceptToken = opts && opts.exceptToken;
const reason = opts && opts.reason;
let toks = [];
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
// iOS: cancel the CallKit ring via a VoIP push.
if (apnsCfg && process.env.CALLKIT_ENABLED === '1') {
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).
if (fcmSA && process.env.ANDROID_CALLS === '1') {
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 CALLS = require('./calls');
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');
// Small GET-JSON helper for the GIPHY proxy (keeps the key server-side).
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
// false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…).
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.
@@ -1120,9 +1120,11 @@ route('POST', '/api/calls/decline', async (req, res) => {
route('POST', '/api/calls/answered', async (req, res) => {
const u = await currentUser(req);
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' });
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 });
});
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);