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