Commit Graph

46 Commits

Author SHA1 Message Date
Sravan e6d3d2d66a Native video: render others' screen-share on native calls
Last native-video gap: on a native (iOS) call the WebView has no LiveKit
connection, so a screen shared by a web/desktop participant never rendered.
The mesh already flips the sharer's tile into the big "stage" (meeting-
peer-screen -> meetSharers -> sharing-mode), so extend the tile sync: each
tile now carries a `screen` flag (meetSharers.has(id)). The plugin renders
that participant's screen-share track (source .screenShareVideo) on the
tile with layoutMode .fit (contain, no crop) instead of the camera.

Outgoing screen-share from iOS is still unsupported (no getDisplayMedia /
ReplayKit broadcast extension) — toggleScreen now shows a clear toast on
native instead of silently failing.

Needs a Codemagic build (plugin change). Web deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 16:39:39 +05:30
Sravan a5e2ed8a9d Native video: readable tile labels (red mute, name chip) + flip camera
Label legibility: the name + mute overlays were both white/invisible.
- Mute badge is now a RED (#dc2626) circle with a white mic-slash, matching
  the web tile's .meet-mute, moved to the top-left.
- Name is now white on a dark translucent pill (PaddingLabel) so it stays
  legible over any video, bottom-left.

Front/back camera switch: new NativeCall.switchCamera() flips the local
CameraCapturer front<->back (switchCameraPosition, verified in 2.15.3). New
"Flip camera" button on the meeting bar (switchCamera icon), shown only
while a native call's camera is on (updateFlipBtn). Mirroring auto-corrects
(VideoView mirrorMode .auto only mirrors the front camera).

Needs a Codemagic build (plugin change). Web deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 15:58:12 +05:30
Sravan 34bdd8ea16 Native video: name + mute labels on the native video tiles
The native video covers the web tile's name/mute badges, so redraw those
natively. syncVideoTiles now also carries each tile's name + muted state;
each tile VideoView gets a bottom-left name label (with shadow for
legibility) and a bottom-right mic-slash badge shown when that participant
is muted. Kept above the video renderer via bringSubviewToFront. Web sends
name/muted from meetNames/meetMuted (and ME.name/!meetMic for __local).

Front/back camera switch + screen-share rendering still deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 14:10:54 +05:30
Sravan 4415225407 Native video Increment 2b: remote tiles + self-view on tile; speaker fix
Video (2b): render every participant's camera natively, positioned to
match the web meeting tiles. The web has no LiveKit connection on a native
call, so the plugin draws native VideoViews over the WebView. New
NativeCall.syncVideoTiles({tiles:[{uid,local,x,y,w,h}]}) — the web polls
each tile's getBoundingClientRect + user id (400ms + on camera toggle) and
the plugin places a VideoView (subview of the WKWebView, so CSS-px rects ==
points) for whoever has a live, unmuted camera track; camera-off keeps the
web avatar. Replaces the 2a fixed-corner self-view: your own camera now
renders on the __local tile. Remote video correlated by LiveKit identity ==
meetPeerUids user id. APIs verified vs client-sdk-swift 2.15.3 source:
Room.remoteParticipants[Participant.Identity(from:)], Participant.videoTracks,
TrackPublication.source/.track/.isMuted, Track.Source.camera.

Audio: fix "sound starts on the earpiece until I tap something" — the
LiveKit audio engine starting after CallKit activates the session flips the
route to the receiver. Added an AVAudioSession routeChange observer that
re-asserts the loudspeaker (via preferSpeaker) whenever we land on the
built-in receiver mid-call (headset/BT still win).

Web change is safe on the current (2a) build: syncVideoTiles is absent so
the poll no-ops. Needs a Codemagic build to take effect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 11:59:52 +05:30
Sravan 149e32b8d8 Native video Increment 2a: publish local camera + self-view
Wire the camera button on native calls to the plugin instead of a
"not available" toast. New NativeCall.setCamera({on}) calls LiveKit
localParticipant.setCamera(enabled:) so the iOS user's camera is
published to the room — every web/desktop peer renders it via their
existing SFU subscription. Locally the plugin shows a small rounded
self-view (VideoView) pinned top-right over the WebView.

APIs verified against client-sdk-swift 2.15.3 source: setCamera ->
LocalTrackPublication?, TrackPublication.track, VideoView(.track/.layoutMode),
CameraCaptureOptions(position:.front). Front camera only for now.

Rendering the OTHER participants as native tiles synced to the web
meeting grid is Increment 2b (the fragile part) — next build. Until the
new IPA ships, the web branch falls back to an "update the app" toast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 23:10:19 +05:30
Sravan 2c1e1c7ca5 Fix Photos save for images + add native Quick Look file preview
Photos/Files bug: the lightbox download handed nativeSaveFile a bare
"/files/<id>" with no name, extension, or mime. Empty mime made
bzSaveToPhotos short-circuit as "notmedia" — so saveToAlbum (and its
permission prompt) never ran, and the image landed in the generic Files
folder as an extension-less blob. Now nativeSaveFile trusts the server's
Content-Type when the mime is unknown and appends a real extension
(bzExtForMime/bzEnsureExt), so images reach the Images folder AND Photos.

File preview: replace the @capacitor/share "share sheet" open with a new
native FileOpener plugin (QLPreviewController). bzOpenFile now prefers a
real Quick Look preview and only falls back to the share sheet if the
plugin isn't in the build. Wired file-opener into mobile/package.json and
the codemagic SPM diagnostics loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 21:38:48 +05:30
Sravan b68ba94d2a SPM: wire local plugins correctly — add Package.swift to each plugin's files array
Root cause of the SPM runtime regressions (call/chat push when closed, photo save):
our local file: plugins didn't function because their package.json `files` array
omitted "Package.swift", so npm can drop it from node_modules on install → cap sync
can't wire the plugin into the CapApp-SPM package → the plugin isn't loaded at
runtime. Official plugins all list Package.swift in files; ours now do too.

Also add build-log diagnostics (node_modules symlink/copy + Package.swift presence,
and the generated CapApp-SPM/Package.swift) so the wiring is provable, not guessed.

Reapplies the SPM migration (Step B) that was reverted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 15:56:13 +05:30
Sravan 7f54182186 Reapply "Capacitor 8 — Step B: migrate iOS from CocoaPods to Swift Package Manager"
This reverts commit 166bea4314.
2026-08-03 15:52:46 +05:30
Sravan 166bea4314 Revert "Capacitor 8 — Step B: migrate iOS from CocoaPods to Swift Package Manager"
This reverts commit 92f1f8f1a3.
2026-08-03 15:43:23 +05:30
Sravan 92f1f8f1a3 Capacitor 8 — Step B: migrate iOS from CocoaPods to Swift Package Manager
Move off CocoaPods (sunsetting; trunk goes read-only Dec 2026) to SPM, the Cap 8
default. This also ends the LiveKit git-pin hack — LiveKit becomes a real SPM dep.

- Each local plugin gets a Package.swift (swift-tools 5.9, iOS 15, capacitor-swift-pm
  from 8.0.0, source path ios/Sources/<Plugin>). native-call additionally declares
  LiveKit: .package(client-sdk-swift, exact 2.15.3) + product "LiveKit".
- native-call Swift: import LiveKitClient -> import LiveKit (SPM product name).
- codemagic.yaml: `cap add ios --packagemanager SPM`; removed the "Install CocoaPods"
  (pod install) step; XCODE_WORKSPACE -> XCODE_PROJECT (App.xcodeproj); build-ipa
  --workspace -> --project. Xcode resolves the Swift packages during archive.
- ios-patch.sh: removed the Podfile LiveKit git-pin injection + Podfile.lock deletion
  (no Podfile under SPM). Info.plist/entitlements/AppDelegate/notif.wav patches stay.
- add-share-extension.rb is SPM-safe (operates on App.xcodeproj, no workspace/Pods refs).

Authored blind (no local Xcode) — expect build iterations on the first SPM build
(cap-add SPM layout, CapApp-SPM plugin wiring, LiveKit SPM resolution, signing).
Fully revertible: git revert -> back to Cap 8 + CocoaPods (working).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 11:48:45 +05:30
Sravan 29106370fd Capacitor 8 upgrade — Step A (keep CocoaPods)
Bump to Capacitor 8 while staying on CocoaPods (decouples the major-version bump
from the SPM switch, per the verified plan). Changes:
- mobile/package.json: all @capacitor/* + @capacitor-community/safe-area + cli → ^8.0.0
  (safe-area 8.0.1 verified Cap 8 compatible; @capacitor/assets stays 3.x, version-agnostic).
- 4 local plugins: @capacitor/core peer/dev dep → ^8.0.0; podspec ios deployment_target 14→15.
- codemagic.yaml: node 20→22 (Cap 8 requires Node 22+); `cap add ios --packagemanager Cocoapods`
  (Cap 8 defaults to SPM — force CocoaPods); fail loudly if no Podfile is generated.
- add-share-extension.rb: fallback deployment target 14→15.
Requirements per the Cap 7→8 guide: Node 22+, Xcode 26+, iOS 15 min. capacitor.config has no
adjustMarginsForEdgeToEdge to remove. LiveKit git-pins + native calling unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 18:48:57 +05:30
Sravan fafc480904 Fix crash after multiple calls: always reportNewIncomingCall for every VoIP push
iOS 13+ terminates the app if a PushKit VoIP push doesn't call reportNewIncomingCall
before completion(). My earlier "no re-ring blip" change made the cancel handler SKIP
the report for already-known/ended calls — which is exactly what iOS kills the app
for, surfacing as a crash after several calls (a cancel push for a prior call's UUID).
Revert to ALWAYS report then immediately end: for a still-active/ringing UUID the
report errors harmlessly (no second ring) and reportCall ends it; only a late duplicate
cancel shows a brief, unavoidable blip. A crash is far worse than a blip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 13:09:25 +05:30
Sravan f4dbbde558 Native calls: route audio to the speaker by default (was earpiece)
The .voiceChat AVAudioSession mode defaults to the earpiece, so call audio came
out of the earpiece and only settled after mute/unmute toggling. Switch to
.videoChat + .defaultToSpeaker + allow Bluetooth so audio goes to the loudspeaker
by default while wired/BT headsets still win. Add preferSpeaker() (override to
speaker when on the built-in receiver) in didActivate AND after mic toggles (the
route can flip back to earpiece on unmute). Report the chosen route in telemetry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 00:50:40 +05:30
Sravan a92cdd69f8 Native calls: get LiveKit 2.15.3 via git-tag pin (stay on CocoaPods) + audio fix
The LiveKitClient CocoaPod on trunk caps at 2.0.18 (2.1+ is SPM-only), so the
2.15 CallKit audio API was unreachable. BUT the repo still ships a valid podspec
at tag 2.15.3, and its deps (LiveKitWebRTC 144.7559.11, LiveKitUniFFI 0.0.6,
SwiftProtobuf) ARE on trunk. So instead of a risky SPM migration:
- ios-patch.sh injects `pod 'LiveKitClient', :git => <repo>, :tag => '2.15.3'`
  into the generated Podfile (after cap sync, before pod install). The NativeCall
  podspec's '~> 2.0' is satisfied by 2.15.3. Idempotent; hard-fails if it can't
  find the App target so we never silently fall back to 2.0.18.
- Plugin re-adds the CallKit<->LiveKit audio-session coordination, now compilable:
  auto-config OFF + engine OFF at load; configure session + enable engine in
  didActivate; disable in didDeactivate; request mic permission on connect.

Core Room APIs (connect/disconnect/setMicrophone) verified compatible between
2.0.18 and 2.15.3 against the real source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 19:46:48 +05:30
Sravan c3de5ac8e3 Revert "Native calls: upgrade to LiveKit 2.15 + add CallKit audio-session coordination"
This reverts commit 455d16abcc.
2026-07-31 19:35:57 +05:30
Sravan 455d16abcc Native calls: upgrade to LiveKit 2.15 + add CallKit audio-session coordination
Root cause of the intermittent dead-mic / no-audio / late-speaker: LiveKit's
automatic AVAudioSession config races CallKit's activation. The fix needs the
2.15+ audio API, which the build wasn't getting ('~> 2.0' resolved an older 2.x
from a stale spec cache). So:
- NativeCall.podspec: pin LiveKitClient '~> 2.15'.
- codemagic.yaml: 'pod install --repo-update' so the spec repo knows 2.15.x.
- Plugin: disable LiveKit auto audio-session config + keep the engine OFF; in
  CXProvider didActivate set the session category and enable the engine; in
  didDeactivate disable it. Request mic permission on connect so enabling the
  engine in didActivate doesn't block on undetermined permission.

All AudioManager APIs verified against the raw 2.15.3 source (setEngineAvailability,
AudioEngineAvailability.default/.none, audioSession.isAutomaticConfigurationEnabled).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 19:21:39 +05:30
Sravan 155e89c6c0 Fix iOS build: remove LiveKit AudioManager usage (build resolves older 2.x)
Second failure showed 'AudioManager' has no member 'audioSession' either — even
though the 2.15.3 source has both audioSession and setEngineAvailability. So the
build's CocoaPods is resolving an OLDER 2.x (stale spec repo/cache) that predates
the engine-observer audio API. Rather than keep guessing, drop ALL LiveKit
AudioManager audio-session code and keep the last known-good audio behavior
(LiveKit defaults). The valuable fixes that use only CallKit/AVFoundation stay:
re-ring blip guard, CallKit<->UI mute sync, and the WS reportIncomingCall ring
path. Proper CallKit audio-session coordination is deferred until the pod is
pinned/upgraded to LiveKit 2.15+.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 18:36:20 +05:30
Sravan c472ee2618 Fix iOS build: use LiveKit 2.15.3 audio API (drop nonexistent setEngineAvailability)
The build failed: 'AudioManager' has no member 'setEngineAvailability' — that API
is only on unreleased/main docs, not in the resolved LiveKitClient 2.15.3. Use the
API that actually ships (per audio.md): disable LiveKit's automatic AVAudioSession
configuration (AudioManager.shared.audioSession.isAutomaticConfigurationEnabled =
false) and configure the session ourselves in CXProvider didActivate. Removed all
setEngineAvailability(.none/.default) calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 18:24:54 +05:30
Sravan 36edc6de12 Native calls: CallKit audio-session coordination, no re-ring blip, mute sync, WS ring path
Plugin (rides next build):
- Audio: disable LiveKit auto audio-session config + keep the engine OFF, then
  configure the session and start the engine ONLY in CXProvider didActivate
  (stop in didDeactivate). Fixes intermittent dead mic / no audio and the "speaker
  turns on late" routing. Request mic permission on connect so enabling the engine
  in didActivate can't block on undetermined permission (SDK #815).
- Re-ring blip: a cancel push for a call we already ended/known no longer reports
  a NEW incoming call (that was the phantom "rings back for a second"); it ends
  the known call cleanly, and only reports+ends for a truly unknown (cold) call.
- Mute display: answer/outgoing reflect muted-by-default on the CallKit screen;
  setMuted now drives mute THROUGH CallKit so the system screen and the in-app
  meeting UI stay in sync.
- reportIncomingCall: new method to ring CallKit from a WebSocket call event — a
  2nd path alongside the VoIP push for when the app is open (push can be delayed);
  deduped by UUID.

Web (deploys now; the WS ring path activates once the build has the new method):
- onDmCall/onGroupCall call nativeReportIncoming for native incoming calls.
- audioActivated telemetry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 18:09:32 +05:30
Sravan 199c32a319 Native calls: answer MUTED by default (house rule)
Plugin connects the LiveKit room without enabling the mic (nothing captured/
published until the user taps Mic, which is also when iOS asks permission).
Web: meetMic starts false so the mic button shows muted; on callConnected the
web pushes the muted state to the plugin so builds whose plugin still connects
the mic live are muted too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 10:46:13 +05:30
Sravan 11f72b2592 Native calls: fix answered-call drop, multi-device ring, add in-app call screen
Root cause of "answered but the call disconnects": native calls carry media
over LiveKit and bypass the mesh, so the server only learned "answered" from a
WebView POST. On a cold/locked answer the app is still launching and that event
was lost, so the 40s unanswered timer fired and cancelled the live call. Fix:
- Plugin: notifyListeners("answerCall", retainUntilConsumed:true) so a killed/
  locked pickup isn't lost before the WebView JS attaches.
- Server markDmAnswered: emit call-taken to the callee's OTHER devices (stop the
  ring; no teardown) and call-answered to the caller (flip UI to connected).
- Server declineDmCall: ignore a decline once the call is answered, so dismissing
  a stale ring on a second device can't kill the live call.

Also adds a UI-only in-app call screen for native calls (caller + callee):
avatar, name, live timer, mute (-> plugin), end (-> CallKit). Native media has
no meeting window of its own; this covers "no meeting window / can't unmute".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 18:23:21 +05:30
Sravan 82c6c9ce0e fix(calls): no duplicate native tile; drop Ringing placeholder; name+DP from roster
Caller saw the native callee TWICE — the outgoing 'Ringing…' placeholder (never cleared,
since a native callee doesn't send the mesh 'answered') plus a separate id-labelled tile.
Now when a native LiveKit participant joins: remove the __waiting placeholder, stop
ringback, and label the tile from CONTACTS (name + DP) instead of the raw user id.

Also (plugin, needs build): connectRoom disconnects any previous LiveKit connection
before joining, so repeated calls never leave duplicate/stale participants in the room.

WebView fix deploys now (no rebuild); the connectRoom fix rides the next build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 17:51:46 +05:30
Sravan 3ac5b6e7bd fix(ios): import LiveKitClient (the CocoaPod module name), not LiveKit
Build error 'unable to resolve module dependency: LiveKit' — the LiveKitClient pod
sets no module_name, so CocoaPods names the module after the pod (LiveKitClient). The
SDK compiled/linked fine; only the import statement was wrong. Types (Room, etc.)
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 16:47:39 +05:30
Sravan 3e8aaad68c feat(ios): native LiveKit connection in the call plugin (inc 1, WIP)
The plugin now carries the call media NATIVELY: on answer it connects the LiveKit Room
from the VoIP payload's url+token and publishes the mic; outgoing calls connect via
reportOutgoingCall(url,token). CallKit stays ACTIVE for the whole call (foregrounds the
app, keeps it alive) — the mic works because LiveKit's audio runs natively and
coordinates with CallKit (unlike WebKit's WebRTC). setMuted -> setMicrophone; end ->
disconnect. Removed the handoff hack.

NOT testable yet: still need (a) WebView to stop joining the room for native calls (one
connection per identity) and drive outgoing via reportOutgoingCall, and (b) server
lifecycle endpoints for native calls (answered/ended), since native media bypasses our
mesh/WS signaling. LiveKit Swift API authored without a local compile — expect a build
iteration or two. Don't build/flip CALLKIT_ENABLED yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 16:15:43 +05:30
Sravan c4ffe2a4e9 build(ios): add LiveKitClient pod to native-call (native call media)
Foundation for native LiveKit calling: the LiveKit iOS SDK carries the call media
natively and coordinates its audio engine with CallKit's AVAudioSession (AudioManager
.setEngineAvailability on didActivate/didDeactivate) — the thing WebKit's WebRTC can't
do. With this, the CallKit call stays active (stable ring + lock-screen answer +
background) AND the mic works. Native connection code + server LiveKit token in the VoIP
payload + WebView coordination come next. Don't build yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 16:03:01 +05:30
Sravan e09d17f1a7 fix(ios): stop VoIP-push crash; hand mic to WebView after answer; missed-call banner
CRASH: iOS terminates an app that receives a VoIP push without calling
reportNewIncomingCall. My re-ring 'fix' made the cancel path call completion() without
reporting -> crash. Always report then immediately end on cancel (a tiny ring blip is
unavoidable; a crash is worse).

MIC + earpiece: an ACTIVE CallKit call reserves the mic (WebView WebRTC gets a dead mic
+ earpiece routing). So on answer keep CallKit active only long enough to foreground the
app, then end it and fire 'callHandoff'; the WebView forces the loudspeaker and
re-acquires the mic (sfuSetMic off/on, retried while it finishes joining).

MISSED CALL: endDmCallByRoom now sends a plain missed-call banner to the callee when the
call ends unanswered (timeout / caller hung up before pickup); skipped on decline.

Server (missed banner) deploys now; plugin + web handoff need a Codemagic build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 22:48:24 +05:30
Sravan 9dc253143e fix(ios): keep CallKit call active on answer so the app foregrounds
Answering did not open the app: ending the CallKit call immediately (to free the mic)
made iOS cancel the app launch before it foregrounded. Keep the call ACTIVE on answer
— fulfilling an active-call answer is what foregrounds/unlocks the app — and fire the
answerCall event so the WebView joins. didActivate stays a no-op (don't fight WebKit's
mic). The CallKit call is ended later when the WebView call ends.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 20:44:09 +05:30
Sravan 6096b62368 feat(calls): CallKit ring-only + WebView media (background audio via voip mode)
Pivot away from the native-LiveKit rewrite. Discovery: the 'voip' UIBackgroundMode
already keeps the WebView's call audio alive when backgrounded (confirmed on device),
so background audio is solved WITHOUT native media. The only issue was CallKit
reserving the mic. So use CallKit purely for the incoming RING:

- Plugin CXAnswerCallAction: fulfill, then immediately end the CallKit call
  (reportCall endedAt) to RELEASE the mic, and fire answerCall to the WebView after a
  ~1s beat so iOS tears down the CallKit audio session first. didActivate no longer
  reconfigures the session (was fighting WebKit).
- home.html: outgoing calls no longer register with CallKit (WebView-only → mic works);
  incoming still rings via CallKit → hands off to the WebView on answer.

Net: native full-screen ring + working mic + background audio + all existing call
features. Needs a Codemagic build; then flip CALLKIT_ENABLED=1 to test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 18:25:59 +05:30
Sravan e7c3231c50 feat(calls): CallKit kill-switch + fix ~1s re-ring on cancel
MIC BROKEN with CallKit: a CallKit call reserves the microphone, so the WebView's
WebRTC can't capture it — calls are unusable until native LiveKit media lands. Add a
server kill-switch (CALLKIT_ENABLED, default OFF) so CallKit can be flipped without an
app rebuild: /api/meetings/config now returns callkit; setupNativeCall bails when off
(-> WebView calls, mic works); push.js only sends VoIP/CallKit pushes when enabled.
Deploying with the flag unset immediately restores working WebView calls.

RE-RING: a late cancel push for an already-declined call hit the plugin's 'unknown
uuid' path and re-reported a fresh incoming call (~1s re-ring). Track endedCalls and
make a late cancel for an already-ended call a no-op.

Server part deploys now (no rebuild); plugin re-ring fix ships with the native build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 17:54:21 +05:30
Sravan 3e21aa30d7 fix(calls): cancel the CallKit ring when the caller ends before answer
Bug: a killed/backgrounded callee is woken only for the CallKit ring and has no
WebSocket yet, so the existing dm-call active:false (WS-only) never reaches it and
it keeps ringing after the caller hangs up.

Fix: send a 'cancel' VoIP push on call teardown.
- push.js: sendCallCancel() sends a {type:'cancel',callUUID} VoIP push to the user's
  ios-voip tokens; invites now carry type:'invite'.
- calls.js: endDmCallByRoom + endGroupCallByRoom fire sendCallCancel to the rung users.
- NativeCallPlugin: on a cancel push, end the reported call (reportCall endedAt); if the
  invite was never seen, report-then-end to satisfy iOS's 'report a call per VoIP push'.

Server part deploys now; the plugin part needs the next Codemagic build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 11:09:07 +05:30
Sravan cd7ab74eed fix(ios): NativeCallPlugin Swift compile errors
- didReceiveIncomingPush: normalise payload.dictionaryPayload ([AnyHashable:Any])
  to [String:Any] before storing/using (the reported build error at :121).
- CXProviderConfiguration(localizedName:) instead of the no-arg init (available on
  all deployment targets, avoids an availability edge).
- Make two 'calls[uuid] ?? [:]' bindings explicitly [String:Any] to avoid empty-
  literal inference ambiguity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 23:03:01 +05:30
Sravan f63aba0ed1 feat(ios): native CallKit + PushKit VoIP calling plugin + web bridge
The native call feature (iOS). Backward-compatible: without the plugin (current
builds) nativeCallOn() is false and every CallKit branch is skipped, so web/older
builds behave exactly as before.

Native (mobile/plugins/native-call, a local Capacitor plugin like audio-route):
- PushKit: registers for VoIP pushes, reports the VoIP token to JS (-> /api/v1/devices
  'ios-voip'). On an incoming VoIP push, reports a CallKit incoming call (full-screen
  ring, works when the app is force-killed).
- CallKit: answer/decline/end -> events to JS; configures the call AVAudioSession on
  didActivate so the WebView's WebRTC audio rides a call-priority session (background).
- Outgoing calls register with CallKit too (reportOutgoingCall) so they get the same
  active-call background-audio context.
- NativeCall.podspec (frameworks CallKit/PushKit/AVFoundation); added to mobile deps;
  ios-patch.sh now sets UIBackgroundModes = [audio, voip] (voip required for PushKit).

Web bridge (home.html): setupNativeCall() registers the VoIP token, joins on CallKit
answer, leaves/declines on CallKit end; on CallKit devices the in-app call-invite popup
+ WebAudio ring are suppressed (the system rings instead); outgoing calls are reported
to CallKit; call-end events dismiss the CallKit call. calls.js threads a stable call
uuid through the dm-call/group-call WS events + start responses so both sides can match
the CallKit call.

Needs a Codemagic build to compile the plugin; first on-device iteration expected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 22:34:27 +05:30
Sravan d664dde798 feat(share): send from inside the share sheet — the Teams-style in-extension picker
Replaces the wrong approach (stage + try to bounce into the app, which iOS blocks)
with the one Teams/WhatsApp actually use: the picker and the send happen INSIDE the
share extension, so there's no app-open at all. Tap Share → Biz Connect → pick a
chat → it uploads and sends, right there in the sheet.

How the extension can send without the app: it's a separate process that can't see
the web app's HttpOnly cookie, so:
- server: GET /api/share/token mints a bearer token for the logged-in user.
- web: on every launch the app fetches that token and hands it to the extension via
  the App Group (ShareInbox.setAuth writes token+base to the shared UserDefaults).
- extension: reads the token and calls the SAME API the native client uses —
  GET /api/messages/conversations to list chats, POST /api/messages/upload for each
  file, POST /api/messages to send. Native UITableView picker with search.

Robustness: it still stages the files + writes a manifest first, so if there's no
token yet (user never signed in) or the send fails, the file isn't lost — the app
collects it on next open, exactly as before. On success the manifest is cleared so
the app doesn't re-offer it.

Server + web are live now; the token endpoint is harmless until a build ships the
extension. NEEDS A NEW iOS BUILD for the picker itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:33:23 +05:30
Sravan 0a2c7376b9 feat(share): "Biz Connect" in the iOS share sheet — share a photo/file into a chat
Adds the reverse direction: share FROM Photos/Files/Safari INTO a Biz Connect
conversation. An app can only appear in the iOS share sheet as an app-extension
target, so this is real native work, not a web change.

Pieces:
- mobile/ios-share/ShareViewController.swift: a UI-less Share Extension. It stages
  the shared items into the App Group container and opens bizconnect://share. It
  deliberately does NOT reimplement the chat picker — that lives in the app, which
  already has the chat list, search and upload progress. Appends to the manifest
  (never overwrites), so sharing twice before opening the app loses nothing.
- mobile/scripts/add-share-extension.rb: injects the extension target into the
  Capacitor-generated Xcode project on every CI build (Codemagic checks out fresh),
  using the xcodeproj gem that ships with CocoaPods. Embeds it, sets the bundle id
  <app>.share, and MERGES the App Group into the app's entitlements rather than
  clobbering them (push's aps-environment must survive). Idempotent.
- mobile/plugins/share-inbox: getPending()/clear() to read that manifest — the App
  Group container isn't one of Filesystem's known directories, so it needs a bridge.
- home.html: on bizconnect://share (and every resume, and cold-launch), read the
  inbox and show a "Send to…" picker over the chat list; chosen files run the SAME
  upload + /api/messages send as an in-app attachment. Reuses convertFileSrc to read
  the staged bytes with no base64 marshalling.
- ios-patch.sh registers the bizconnect URL scheme; codemagic.yaml fetches a profile
  for the .share bundle id too.

One-time manual gate (CI cannot toggle App capabilities): the App Group
group.com.bizgaze.connect must be created and enabled on both App IDs in the Apple
portal — documented in mobile/IOS_SETUP.md. Without it the two processes can't see
each other's files and sharing silently no-ops; everything else still works.

Validated cross-file: pod-name/jsName/method wiring for all three plugins, App
Group id identical in all 4 files, URL scheme consistent across extension/plist/web,
entitlement-merge preserves push. Needs a new iOS build (new targets + plugins).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:58:08 +05:30
Sravan 00ea140280 feat(photos): downloaded media also lands in a "Connect" album in Photos
The Files folder is the app's own copy — it powers offline playback and Manage
storage — but Files is not where anyone looks for photos and videos. The Photos app
is, and only PhotoKit can write there; @capacitor/filesystem cannot, because an
app sandbox and the photo library are separate stores. So this adds a small native
plugin, mirroring the existing audio-route one.

- mobile/plugins/media-library: saveToAlbum({path, album, kind}) finds or creates
  the album and adds the asset. Uses addResource(with:fileURL:), which is uniform
  for photo and video and non-optional, unlike creationRequestForAssetFrom*, which
  can silently no-op.
- Requests .readWrite, NOT .addOnly: addOnly can add an asset but cannot look up or
  create an ALBUM, which is the whole point here. Both photo-library usage strings
  are already set by ios-patch.sh.
- If the album can't be resolved (e.g. "limited" access), the asset is still saved
  to the camera roll — landing somewhere beats failing outright. A racing create
  from two simultaneous downloads re-looks-up instead of erroring.
- Podspec named MediaLibrary.podspec with s.name = 'MediaLibrary' to match
  PascalCase of the package name — the same trap that broke the AudioRoute build.
  Checked by a script: pod name, jsName and declared-vs-implemented methods.

Entirely best-effort from the web side: a denied permission or an older app build
never fails a download that is already safe in the app folder. Added a Settings
toggle since this does keep a second copy of the file.

Needs a new iOS build — new native plugin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:39:27 +05:30
Sravan 44526e1ee9 fix(ios audio): stop the speaker<->BT oscillation; single override, no re-force
v1.1.1's route observer re-forced speaker on every change, and WebKit re-added Bluetooth
each time -> the audio flapped speaker<->BT many times/sec (telemetry: dozens of route
flips from 3 taps), which read as 'sound doesn't switch'. WKWebView won't let the app
hold the built-in speaker over an active BT device. So: one override per tap, observer
only REPORTS the output (no fighting). Also stop dimming the iOS button (it's a live
output indicator, not on/off; the dim read as 'disabled' on BT). Probe now carries the
native marker so we can confirm the binary. plugin v1.1.2-stable, web batch150.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:54:42 +05:30
Sravan cfbf950173 fix(ios audio): force speaker over connected Bluetooth by dropping BT category options
overrideOutputAudioPort(.speaker) alone can't beat a connected BT headset (BT is higher
priority), so 'Speaker' snapped back to BT. Now setSpeaker(true) sets category options
[.defaultToSpeaker] (no allowBluetooth) so BT isn't an eligible output and the speaker
wins; setSpeaker(false) restores [.allowBluetooth,.allowBluetoothA2DP] and uses the
default port (routes to the headset). Observer re-holds speaker if a BT connect steals it.
Adds a TEMP web probe (nroute/sptap) to verify from telemetry. plugin v1.1.1, web batch149.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:01:06 +05:30
Sravan c3b0ef560d feat(ios audio): report real output route so the toggle icon is correct
BT audio already routes on iOS, but the web UI can't see it (iOS hides audio outputs
from enumerateDevices), so the icon was stuck on speaker. Plugin v1.1.0 now exposes the
active output: getRoute() + a 'routeChange' event ('speaker'|'bluetooth'|'wired'|
'receiver'|'airplay'). Web subscribes and drives the icon/label from the real route
(bluetooth/headphones/speaker), and the iOS toggle becomes a 2-state Speaker <-> Device
cycle (JS can't enumerate outputs there). Also strips the earpiece-investigation debug
logging from the plugin. Native needs one Codemagic build; web is live (batch148).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:43:07 +05:30
Sravan 0282181ca6 diag(ios audio): settled-route probe (+0.4s/+1.2s) + live mode in route log
1.0.3 telemetry showed mode already == .voiceChat when earpiece still landed on
Speaker, and setSpeaker read the route synchronously (stale) right after the override.
So the mode re-pin alone may be insufficient and the sync read is unreliable.

1.0.5: keeps the .voiceChat re-pin, but stamps live mode into every route-change log
line and re-reads the SETTLED port+mode at +0.4s and +1.2s after each toggle (reported
in the next toggle's trail). This definitively answers whether WebKit flips to .videoChat
and where the route truly settles. native marker 1.0.5-settle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:44:38 +05:30
Sravan b54ba10c69 fix(ios audio): pin .voiceChat before override(.none) so earpiece is reachable
Root cause (WebKit source MediaSessionManagerCocoa.mm + Apple DTS, confirmed by
telemetry): WKWebView's WebRTC re-pins the session to mode .videoChat while capture
is active, and .videoChat auto-implies .defaultToSpeaker. So override(.none) reverts
to the mode default = LOUDSPEAKER, and .none alone can never reach the earpiece once
WebKit flips the mode. Our first override won only because .voiceChat was still active.

Fix: for earpiece, setMode(.voiceChat) (its default route IS the receiver) before
override(.none) in both setSpeaker and the debounced route-change re-assert. Add an
accessory guard so a connected BT/wired headset isn't yanked to the built-in receiver.
Reconcile the launch patch: drop .defaultToSpeaker from inject-audio.js so it stops
contradicting the plugin. native marker 1.0.4-mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:31:14 +05:30
Sravan ce49e8e30d fix(ios audio): re-assert earpiece after WebKit re-forces speaker (debounced)
Telemetry proof: overrideOutputAudioPort(.none) routes to Receiver once, then every
later toggle latches to built-in Speaker despite opts=36 (no .defaultToSpeaker) —
WebKit's WebRTC engine re-forces the loudspeaker after our override. The prior build
ignored .override-reason route changes and never corrected it.

Now: react to ALL route changes, debounced 0.25s, and re-assert the chosen port only
on a genuine mismatch (self-terminating, capped at 6/toggle to avoid thrash). setSpeaker
returns a reason->port route-change trail so the log shows whether WebKit is one-shot
or persistent. native marker 1.0.3-reassert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:20:29 +05:30
Sravan 36d51ba40d diag(ios audio): setSpeaker returns actual output port + category/mode + native marker
Telemetry only showed 'override didn't throw', and the web __BUILD tag can't tell
native binaries apart, so we couldn't see WHERE iOS actually routed the audio or
which plugin build ran. setSpeaker now resolves with the real currentRoute output
port (Receiver/Speaker/Bluetooth), the live AVAudioSession category/mode/options,
and a native-build marker (1.0.2-diag) so the route log is unambiguous.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:22:01 +05:30
Sravan 1920345ecd fix(ios audio): route earpiece reliably; stop fighting WebKit's audio session
Speaker worked but the earpiece was silent, and call audio flickered on speaker
then dropped. Causes: (1) the category set .defaultToSpeaker, so overrideOutputAudioPort(.none)
fell back to the loudspeaker instead of the receiver; (2) setSpeaker re-ran
setCategory+setActive on every toggle mid-call, tearing down the audio unit WebKit's
WebRTC engine was using and silencing the earpiece route.

Fix: drop .defaultToSpeaker (drive the port explicitly), make setSpeaker flip ONLY
overrideOutputAudioPort, and observe routeChangeNotification to re-assert the chosen
route when WebKit reconfigures the session at call start / device change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:10:09 +05:30
Sravan 8f33df5c3d fix(ios): rename podspec to AudioRoute.podspec so cap sync/pod install resolves it
Capacitor derives the pod name from the npm package name (audio-route -> AudioRoute)
and writes 'pod AudioRoute, :path => ../../plugins/audio-route' into the generated
Podfile. CocoaPods then requires a file literally named AudioRoute.podspec whose
s.name is 'AudioRoute'. The old AudioRoutePlugin.podspec (s.name AudioRoutePlugin)
caused the Codemagic build to fail with 'No podspec found for AudioRoute'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 21:15:56 +05:30
Sravan 46b5ae16a4 speaker: force-add audio-route plugin dist stubs (global dist/ ignore would drop them from a fresh clone) 2026-07-20 17:07:01 +05:30
Sravan c6f236ecf9 speaker: make AudioRoute a real Capacitor plugin PACKAGE so it registers (device log: has:0)
Device telemetry proved the app-embedded AudioRoute class never registered (not in
Capacitor.Plugins) — appending a CAPBridgedPlugin to AppDelegate.swift gets stripped/undiscovered
in release builds. The plugins that DO register (Share, Camera, Filesystem) are all npm packages
wired by cap sync. So AudioRoute is now a local plugin package (mobile/plugins/audio-route,
file: dep in mobile/package.json) with a podspec + CAPBridgedPlugin Swift — cap sync adds its pod
and Capacitor registers it like the others. load() sets the launch speaker default; setSpeaker({on})
overrides the output port. inject-audio.js no longer injects the plugin class (would duplicate);
it keeps only the AppDelegate launch default as a fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:06:16 +05:30