Compare commits

..
2 Commits
Author SHA1 Message Date
SravanandClaude Opus 4.8 aa794102c3 android(call): volume key silences the ring like a real phone
The incoming ring is driven by our own Ringer (we aren't a telecom
ConnectionService yet), so the OS doesn't route volume keys to silence
it. Capture them two ways that together cover every state:

- Foreground/locked: IncomingCallActivity.dispatchKeyEvent consumes
  VOLUME_UP/DOWN/MUTE and silences.
- Unlocked/backgrounded heads-up (no activity): a MediaSession with a
  remote VolumeProvider so the framework delivers volume presses to us.

Silence stops audio + vibration but keeps the call ringing visually
(notification + full-screen stay) so answer/decline still work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-29 22:10:17 +05:30
SravanandClaude Opus 4.8 7ec6c3f227 docs(techstack): add Identity & auth section
Document the auth/identity subsystem: crypto-only (scrypt + TOTP MFA),
opaque sliding sessions over cookie/bearer/query transports, rotating
native refresh tokens, BizGaze IdP + signed SSO, roles & tenancy,
bzc_ API keys, server-side directory search, and the audit log.

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

No files matched your search

+42 -1
View File
@@ -1,6 +1,6 @@
# Tech Stack — BizGaze Connect # Tech Stack — BizGaze Connect
> Living document. **Last updated: 2026-10-27.** Update this file whenever a dependency, platform, service, or > Living document. **Last updated: 2026-10-27** (rev 2). 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.
@@ -155,4 +194,6 @@ Bump the **Last updated** date at the top and add a line to the Changelog below.
## Changelog ## Changelog
- **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.
@@ -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
@@ -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;
} }
} }