From 9bede9afa170e644a23fa371059a3ffbe31fab6c Mon Sep 17 00:00:00 2001 From: Rohit Date: Fri, 30 Oct 2026 11:13:09 +0530 Subject: [PATCH] Call someone from their Remote, as the web does A one-to-one thread gets the web's phone button. It opens a call screen that rings them for 45 seconds, and a call ringing for you opens the same screen with Accept and Decline from the Log, Notifications, Remotes, your profile or any thread, while the app is open. Once connected it shows the web's fp code, made from both ends' keys, with mute, speaker and hang up. The two ends swap a WebRTC offer and answer through the signed API's new call endpoints, then talk through the relay, so a call works whether the other end is the web or the app. packages/calls is a NativePHP plugin around Google's WebRTC (stream-webrtc-android, stasel/WebRTC) that sets the call up exactly as the browser does: the microphone, room for video and a "call" data channel whose closing means the other end hung up. The thread now logs calls in the web's words ("you started a call", "missed call from @ada"), naming the other person where devnull leaves the author off, instead of a bare "* someone". Audio only for now: a partner's camera isn't shown yet, and a call only rings while the app is open. --- app/Devnull/Client.php | 70 +++ app/Devnull/Data/IncomingCall.php | 28 ++ app/Devnull/Data/Message.php | 17 +- app/Icons/Lucide.php | 6 + app/NativeComponents/Call.php | 442 ++++++++++++++++++ .../Concerns/WatchesForCalls.php | 44 ++ app/NativeComponents/Conversation.php | 32 +- app/NativeComponents/Log.php | 2 + app/NativeComponents/Notifications.php | 2 + app/NativeComponents/Profile.php | 2 + app/NativeComponents/Remotes.php | 4 +- app/Providers/NativeServiceProvider.php | 2 + composer.json | 6 + composer.lock | 38 +- config/nativephp.php | 2 +- packages/calls/composer.json | 26 ++ packages/calls/nativephp.json | 72 +++ .../calls/resources/android/CallManager.kt | 408 ++++++++++++++++ .../calls/resources/android/CallsFunctions.kt | 92 ++++ .../calls/resources/ios/CallManager.swift | 330 +++++++++++++ .../calls/resources/ios/CallsFunctions.swift | 88 ++++ packages/calls/src/Calls.php | 66 +++ packages/calls/src/CallsServiceProvider.php | 13 + packages/calls/src/Events/Connected.php | 15 + .../calls/src/Events/DescriptionReady.php | 19 + packages/calls/src/Events/Ended.php | 20 + packages/calls/src/Facades/Calls.php | 22 + resources/icons/mic-off.svg | 1 + resources/icons/phone-off.svg | 1 + resources/icons/phone.svg | 1 + resources/views/native/call.blade.php | 46 ++ routes/mobile.php | 4 + tests/Feature/Screens/CallTest.php | 274 +++++++++++ 33 files changed, 2186 insertions(+), 9 deletions(-) create mode 100644 app/Devnull/Data/IncomingCall.php create mode 100644 app/NativeComponents/Call.php create mode 100644 app/NativeComponents/Concerns/WatchesForCalls.php create mode 100644 packages/calls/composer.json create mode 100644 packages/calls/nativephp.json create mode 100644 packages/calls/resources/android/CallManager.kt create mode 100644 packages/calls/resources/android/CallsFunctions.kt create mode 100644 packages/calls/resources/ios/CallManager.swift create mode 100644 packages/calls/resources/ios/CallsFunctions.swift create mode 100644 packages/calls/src/Calls.php create mode 100644 packages/calls/src/CallsServiceProvider.php create mode 100644 packages/calls/src/Events/Connected.php create mode 100644 packages/calls/src/Events/DescriptionReady.php create mode 100644 packages/calls/src/Events/Ended.php create mode 100644 packages/calls/src/Facades/Calls.php create mode 100644 resources/icons/mic-off.svg create mode 100644 resources/icons/phone-off.svg create mode 100644 resources/icons/phone.svg create mode 100644 resources/views/native/call.blade.php create mode 100644 tests/Feature/Screens/CallTest.php diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index 40bd8a7d..ae42a753 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -7,6 +7,7 @@ use App\Devnull\Data\Connection; use App\Devnull\Data\CurrentUser; use App\Devnull\Data\DraftImage; +use App\Devnull\Data\IncomingCall; use App\Devnull\Data\MessageVoice; use App\Devnull\Data\Notification; use App\Devnull\Data\Page; @@ -141,6 +142,14 @@ public static function threadPath(string $username = '', ?string $orgId = null): return $orgId === null ? 'remotes/@'.rawurlencode($username) : 'remotes/orgs/'.rawurlencode($orgId); } + /** + * Where a call with someone rings, is answered and ends, beside your thread with them. + */ + public static function callPath(string $username): string + { + return self::threadPath($username).'/call'; + } + /** * Where a message's reactions are left and taken back. */ @@ -600,6 +609,67 @@ public function unblock(string $username): void $this->cache->blockChanged($this->json($this->send('DELETE', 'users/'.rawurlencode($username).'/block'))); } + /** + * The call ringing for you, as the web checks for one every few seconds, or null. + */ + public function incomingCall(): ?IncomingCall + { + $call = $this->json($this->send('GET', 'remotes/call'))['call'] ?? null; + + return is_array($call) ? IncomingCall::fromArray($call) : null; + } + + /** + * Fresh relay credentials for one call with them, which devnull only hands out for someone you can call. + * + * @return array{ice_servers: list, username: string, credential: string}>, is_relay_only: bool} + */ + public function callIceServers(string $username): array + { + $json = $this->json($this->send('GET', self::callPath($username).'/ice-servers')); + /** @var list, username: string, credential: string}> $servers */ + $servers = array_values(array_filter((array) ($json['ice_servers'] ?? []), is_array(...))); + + return ['ice_servers' => $servers, 'is_relay_only' => ($json['is_relay_only'] ?? true) !== false]; + } + + /** + * Rings them with this phone's offer, for 45 seconds. + */ + public function ring(string $username, string $offer): void + { + $this->send('POST', self::callPath($username), payload: ['sdp' => $offer]); + } + + /** + * Your call to them while it rings: null once it's gone, with their answer once they pick up. + * + * @return array{answer: string|null}|null + */ + public function outgoingCall(string $username): ?array + { + $call = $this->json($this->send('GET', self::callPath($username)))['call'] ?? null; + + return is_array($call) ? ['answer' => is_string($call['answer'] ?? null) ? $call['answer'] : null] : null; + } + + /** + * Picks up their call with this phone's answer. + */ + public function answerCall(string $username, string $answer): void + { + $this->send('PUT', self::callPath($username), payload: ['sdp' => $answer]); + } + + /** + * Ends the call between you, whoever rang. Not picking up one that rang logs it declined, or failed when this + * phone tried to answer and couldn't. + */ + public function endCall(string $username, bool $failed = false): void + { + $this->send('DELETE', self::callPath($username), payload: ['failed' => $failed]); + } + /** * The people you mute, last muted first. * diff --git a/app/Devnull/Data/IncomingCall.php b/app/Devnull/Data/IncomingCall.php new file mode 100644 index 00000000..5f86c89c --- /dev/null +++ b/app/Devnull/Data/IncomingCall.php @@ -0,0 +1,28 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + partner: User::fromArray(is_array($data['partner'] ?? null) ? $data['partner'] : []), + offer: is_string($data['sdp'] ?? null) ? $data['sdp'] : '', + isMuted: ($data['is_muted'] ?? false) === true, + ); + } +} diff --git a/app/Devnull/Data/Message.php b/app/Devnull/Data/Message.php index aa6e273d..3bdb418c 100644 --- a/app/Devnull/Data/Message.php +++ b/app/Devnull/Data/Message.php @@ -228,10 +228,10 @@ public function hasReacted(?string $viewerId, string $emoji): bool * name in an org for someone else's, "sent a voice note" or "sent an image" for a message with no text, and the * event for an org's log line. */ - public function preview(?string $viewerId, bool $inOrg): string + public function preview(?string $viewerId, bool $inOrg, ?User $partner = null): string { if ($this->isEvent()) { - return $this->eventText($viewerId); + return $this->eventText($viewerId, partner: $partner); } $text = trim((string) preg_replace('/\s+/', ' ', $this->body)); @@ -246,11 +246,12 @@ public function preview(?string $viewerId, bool $inOrg): string /** * An event, as the web logs it between messages: who did it, and to whom. In a thread with one person devnull - * leaves the author off a ping, since it can only be them. + * leaves the author off a ping or a call, since it can only be them or the partner. */ - public function eventText(?string $viewerId, ?string $orgName = null): string + public function eventText(?string $viewerId, ?string $orgName = null, ?User $partner = null): string { - $actor = $this->userId === $viewerId ? 'you' : ($this->user === null ? 'someone' : $this->user->handle()); + $author = $this->user ?? $partner; + $actor = $this->userId === $viewerId ? 'you' : ($author === null ? 'someone' : $author->handle()); $subject = $this->subjectId === $viewerId ? 'you' : ($this->subject === null ? 'someone' : $this->subject->handle()); return match ($this->event) { @@ -262,6 +263,12 @@ public function eventText(?string $viewerId, ?string $orgName = null): string 'promoted' => "{$actor} made {$subject} a maintainer", 'demoted' => "{$actor} made {$subject} a contributor", 'inherited' => "{$actor} inherited maintainer", + 'call_started' => "{$actor} started a call", + 'call_answered' => "{$actor} answered the call", + 'call_cancelled' => "{$actor} cancelled the call", + 'call_declined' => "{$actor} declined the call", + 'call_failed' => "{$actor} couldn't answer the call", + 'call_missed' => $this->userId === $viewerId ? "your call wasn't answered" : "missed call from {$actor}", self::Ping => match (true) { $this->userId === $viewerId => 'you sent a ping', $this->user !== null => "{$actor} sent a ping", diff --git a/app/Icons/Lucide.php b/app/Icons/Lucide.php index 41678223..360d30a4 100644 --- a/app/Icons/Lucide.php +++ b/app/Icons/Lucide.php @@ -106,6 +106,9 @@ enum Lucide case RotateCw; case Square; case Copy; + case Phone; + case PhoneOff; + case MicOff; /** * The icon the web's inbox draws for a kind of notification. @@ -227,6 +230,9 @@ public function custom(bool $filled = false): ?string self::RotateCw => 'rotate-cw', self::Square => 'square-filled', self::Copy => 'copy', + self::Phone => 'phone', + self::PhoneOff => 'phone-off', + self::MicOff => 'mic-off', default => null, }; diff --git a/app/NativeComponents/Call.php b/app/NativeComponents/Call.php new file mode 100644 index 00000000..c4a68639 --- /dev/null +++ b/app/NativeComponents/Call.php @@ -0,0 +1,442 @@ +username = (string) $this->param('username', ''); + $this->name = (string) $this->data('name', $this->username); + $this->avatarUrl = $this->data('avatar_url'); + $this->offer = (string) $this->data('offer', ''); + $this->since = self::now(); + + if ($this->offer !== '') { + $this->status = self::Ringing; + $this->isRingingSilently = (bool) $this->data('is_muted', false); + $this->deadline = $this->since + self::RingSeconds; + self::markSeen($this->offer); + + return; + } + + $relays = $this->attempt(fn (Client $devnull): array => $devnull->callIceServers($this->username)); + + if ($relays === null) { + $this->finish($this->error ?? "Could not call @{$this->username}."); + + return; + } + + $this->deadline = $this->since + self::RingSeconds; + Calls::start($relays['ice_servers'], $relays['is_relay_only']); + } + + public function navTitle(): string + { + return $this->name; + } + + /** + * Picks up. Only now does this phone's own address go to the other end, through the relay. + */ + public function accept(): void + { + if ($this->status !== self::Ringing) { + return; + } + + $relays = $this->attempt(fn (Client $devnull): array => $devnull->callIceServers($this->username)); + + if ($relays === null) { + $this->forget(failed: true); + $this->finish($this->error ?? 'Could not answer the call.'); + + return; + } + + $this->status = self::Connecting; + $this->remoteDescription = $this->offer; + $this->deadline = self::now() + self::ConnectSeconds; + Calls::start($relays['ice_servers'], $relays['is_relay_only'], $this->offer); + } + + public function decline(): void + { + if ($this->status === self::Ringing) { + $this->forget(); + } + + $this->back(); + } + + /** + * Ends the call for both: cancelling it while it rings, or hanging up. + */ + public function hangUp(): void + { + if ($this->status === self::Ended) { + $this->back(); + + return; + } + + if ($this->status === self::Ringing) { + $this->decline(); + + return; + } + + if ($this->status === self::Calling) { + $this->forget(); + } + + Calls::end(); + $this->finish('Call ended.'); + } + + public function toggleMute(): void + { + $this->isMuted = ! $this->isMuted; + Calls::setMuted($this->isMuted); + } + + public function toggleSpeaker(): void + { + $this->isSpeakerOn = ! $this->isSpeakerOn; + Calls::setSpeaker($this->isSpeakerOn); + } + + /** + * Android's back hangs up, as leaving the web's page asks before it ends the call. + */ + public function onBackPressed(): void + { + $this->hangUp(); + } + + /** + * This phone's offer or answer, with every way to reach it, for devnull to pass on. + */ + #[On(DescriptionReady::class)] + public function described(string $sdp = ''): void + { + $this->localDescription = $sdp; + + if ($this->status === self::Calling) { + $rang = $this->attempt(function (Client $devnull) use ($sdp): bool { + $devnull->ring($this->username, $sdp); + + return true; + }); + + if ($rang === null) { + Calls::end(); + $this->finish($this->error ?? "Could not call @{$this->username}."); + } + + return; + } + + if ($this->status === self::Connecting) { + $answered = $this->attempt(function (Client $devnull) use ($sdp): bool { + $devnull->answerCall($this->username, $sdp); + + return true; + }); + + if ($answered === null) { + Calls::end(); + $this->forget(failed: true); + $this->finish($this->error ?? 'Could not answer the call.'); + } + } + } + + #[On(Connected::class)] + public function connected(): void + { + if ($this->status !== self::Connecting) { + return; + } + + $this->status = self::Connected; + $this->since = self::now(); + $this->code = self::verificationCode($this->localDescription, $this->remoteDescription); + } + + /** + * The call ended without this phone hanging up: the other end did, or it couldn't connect or dropped. + */ + #[On(Ended::class)] + public function ended(string $reason = ''): void + { + if ($this->status === self::Ended) { + return; + } + + if ($this->status === self::Calling) { + $this->forget(); + } + + // Picking up went wrong before the answer got to them, so they hear it failed rather than declined. + if ($this->status === self::Connecting && $this->localDescription === '') { + $this->forget(failed: true); + } + + $this->finish(match (true) { + $reason === 'hung_up' => 'Call ended.', + $reason === 'microphone' => 'Allow the microphone to call.', + $this->status === self::Connected => 'Call ended.', + default => "Couldn't connect the call.", + }); + } + + /** + * Checks on the call as the web does: for their answer while it rings for them, for it still ringing while it + * rings for you, and for giving up when nothing comes. + */ + #[Poll(1500)] + public function check(): void + { + $now = self::now(); + + match ($this->status) { + self::Calling => $this->checkForAnswer($now), + self::Ringing => $this->checkStillRinging($now), + self::Connecting => $now > $this->deadline ? $this->giveUp("Couldn't connect the call.") : null, + self::Ended => $now > $this->deadline ? $this->back() : null, + default => null, + }; + } + + /** + * Eight hex digits from both ends' key fingerprints, read like a short hash, exactly as the web makes them: + * `3f9a c217`. + */ + public static function verificationCode(string $localDescription, string $remoteDescription): string + { + $fingerprints = collect([$localDescription, $remoteDescription]) + ->map(fn (string $sdp): string => preg_match('/a=fingerprint:(.+)/', $sdp, $match) === 1 ? trim($match[1]) : '') + ->sort(strcmp(...)) + ->implode('|'); + $hex = substr(hash('sha256', $fingerprints), 0, 8); + + return substr($hex, 0, 4).' '.substr($hex, 4); + } + + /** + * Whether this offer already rang here, so a call you declined or left doesn't ring again while devnull still + * has it. + */ + public static function hasSeen(string $offer): bool + { + return Cache::get('calls.seen') === hash('sha256', $offer); + } + + public function render(): View + { + return view('native.call', [ + 'avatar' => ImageCache::src($this->avatarUrl), + 'line' => $this->line(), + ]); + } + + private function checkForAnswer(float $now): void + { + $call = $this->attempt(fn (Client $devnull): ?array => $devnull->outgoingCall($this->username)); + + // Try again on the next check. + if ($this->error !== null) { + return; + } + + if (($call['answer'] ?? null) !== null) { + $this->status = self::Connecting; + $this->remoteDescription = $call['answer']; + $this->deadline = $now + self::ConnectSeconds; + Calls::answer($call['answer']); + $this->forget(); + + return; + } + + if ($call === null || $now > $this->deadline) { + $this->forget(); + $this->giveUp('No answer.'); + } + } + + private function checkStillRinging(float $now): void + { + $call = $this->attempt(fn (Client $devnull) => $devnull->incomingCall()); + + if ($this->error !== null) { + return; + } + + if ($call?->offer !== $this->offer || $now > $this->deadline) { + $this->finish("Missed call from @{$this->username}."); + + return; + } + + if (! $this->isRingingSilently) { + Device::vibrate(); + } + } + + private function giveUp(string $reason): void + { + Calls::end(); + $this->finish($reason); + } + + private function finish(string $reason): void + { + $this->status = self::Ended; + $this->reason = $reason; + $this->deadline = self::now() + self::EndedSeconds; + } + + /** + * Forgets the call on devnull, which logs one that was never picked up in your thread: cancelled, declined, or + * failed when this phone tried to answer and couldn't. + */ + private function forget(bool $failed = false): void + { + $this->attempt(function (Client $devnull) use ($failed): bool { + $devnull->endCall($this->username, $failed); + + return true; + }); + } + + /** + * What the screen says under their name, in the web's words. + */ + private function line(): string + { + $elapsed = self::elapsed(self::now() - $this->since); + + return match ($this->status) { + self::Ringing => 'is calling', + self::Calling => "Ringing… {$elapsed}", + self::Connecting => 'Connecting…', + self::Connected => $elapsed, + default => $this->reason, + }; + } + + private static function elapsed(float $seconds): string + { + $seconds = max(0, (int) $seconds); + + return sprintf('%d:%02d', intdiv($seconds, 60), $seconds % 60); + } + + private static function markSeen(string $offer): void + { + Cache::put('calls.seen', hash('sha256', $offer), self::RingSeconds + 15); + } + + private static function now(): float + { + return (float) CarbonImmutable::now()->format('U.u'); + } +} diff --git a/app/NativeComponents/Concerns/WatchesForCalls.php b/app/NativeComponents/Concerns/WatchesForCalls.php new file mode 100644 index 00000000..adc6271e --- /dev/null +++ b/app/NativeComponents/Concerns/WatchesForCalls.php @@ -0,0 +1,44 @@ +user() === null) { + return; + } + + try { + $call = app(Client::class)->incomingCall(); + } catch (ApiException|KeychainUnavailable) { + // The screen's own requests say what's wrong; this tries again on the next check. + return; + } + + if ($call === null || $call->offer === '' || Call::hasSeen($call->offer)) { + return; + } + + $this->navigate('/call/'.rawurlencode($call->partner->username), [ + 'name' => $call->partner->name, + 'avatar_url' => $call->partner->avatarUrl, + 'offer' => $call->offer, + 'is_muted' => $call->isMuted, + ])->transition(Transition::SlideFromBottom); + } +} diff --git a/app/NativeComponents/Conversation.php b/app/NativeComponents/Conversation.php index 253ac1da..ddde62a5 100644 --- a/app/NativeComponents/Conversation.php +++ b/app/NativeComponents/Conversation.php @@ -31,6 +31,7 @@ use App\NativeComponents\Concerns\SuggestsMentions; use App\NativeComponents\Concerns\SyncsOutbox; use App\NativeComponents\Concerns\TalksToDevnull; +use App\NativeComponents\Concerns\WatchesForCalls; use App\NativeComponents\Concerns\WritesInBackground; use Carbon\CarbonImmutable; use Illuminate\Support\Str; @@ -41,6 +42,7 @@ use Native\Mobile\Edge\Layouts\Builders\NavBarOptions; use Native\Mobile\Edge\NativeComponent; use Native\Mobile\Edge\SharedValue; +use Native\Mobile\Edge\Transition; use Native\Mobile\Events\Alert\ButtonPressed; use Native\Mobile\Facades\Browser; use Native\Mobile\Facades\Device; @@ -108,6 +110,7 @@ class Conversation extends NativeComponent use SuggestsMentions; use SyncsOutbox; use TalksToDevnull; + use WatchesForCalls; use WritesInBackground; /** @@ -437,6 +440,16 @@ public function navigationOptions(): ?NavBarOptions ? NavAction::make('unmute')->label('Unmute')->icon(ios: Lucide::Bell->ios(), android: Lucide::Bell->android())->press('unmute') : NavAction::make('mute')->label('Mute')->icon(ios: Lucide::BellOff->ios(), android: Lucide::BellOff->android())->press('chooseMute'); + // Calls are one-to-one, where a ping could go, as the web's phone button. + if (! $this->isOrg() && $this->thread->access === Thread::Open) { + $options->action( + NavAction::make('call') + ->icon(ios: Lucide::Phone->ios(), android: Lucide::Phone->android()) + ->a11yLabel('Call') + ->press('call'), + ); + } + return $options->action( NavAction::make('conversation-actions') ->icon(ios: Lucide::Ellipsis->ios(), android: Lucide::Ellipsis->android()) @@ -663,6 +676,23 @@ public function ping(): void }); } + /** + * Calls them, as the web's phone button does, on a screen of its own over the thread (see Call). + */ + public function call(): void + { + $partner = $this->thread?->partner; + + if ($partner === null || $this->isOrg() || $this->thread->access !== Thread::Open) { + return; + } + + $this->navigate('/call/'.rawurlencode($partner->username), [ + 'name' => $partner->name, + 'avatar_url' => $partner->avatarUrl, + ])->transition(Transition::SlideFromBottom); + } + /** * Tells the others you're typing, at most every three seconds, while the composer has something in it. */ @@ -1929,7 +1959,7 @@ private function items(?User $viewer): array } if ($message->isEvent()) { - $items[] = ['type' => 'event', 'message' => $message, 'text' => $message->eventText($viewer?->id)]; + $items[] = ['type' => 'event', 'message' => $message, 'text' => $message->eventText($viewer?->id, partner: $this->thread?->partner)]; continue; } diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php index 9d03448e..2f6fbaa0 100644 --- a/app/NativeComponents/Log.php +++ b/app/NativeComponents/Log.php @@ -27,6 +27,7 @@ use App\NativeComponents\Concerns\ShowsCommits; use App\NativeComponents\Concerns\SuggestsWhoToFollow; use App\NativeComponents\Concerns\TalksToDevnull; +use App\NativeComponents\Concerns\WatchesForCalls; use Carbon\CarbonImmutable; use Closure; use Illuminate\View\View; @@ -112,6 +113,7 @@ class Log extends NativeComponent use ShowsCommits; use SuggestsWhoToFollow; use TalksToDevnull; + use WatchesForCalls; /** * Every frame goes in full. EDGE's subtree reuse sometimes refers to rows diff --git a/app/NativeComponents/Notifications.php b/app/NativeComponents/Notifications.php index f31bc45b..6ca994df 100644 --- a/app/NativeComponents/Notifications.php +++ b/app/NativeComponents/Notifications.php @@ -14,6 +14,7 @@ use App\NativeComponents\Concerns\RefreshesInBackground; use App\NativeComponents\Concerns\SyncsOutbox; use App\NativeComponents\Concerns\TalksToDevnull; +use App\NativeComponents\Concerns\WatchesForCalls; use Carbon\CarbonImmutable; use Illuminate\View\View; use Native\Mobile\Edge\NativeComponent; @@ -45,6 +46,7 @@ class Notifications extends NativeComponent use RefreshesInBackground; use SyncsOutbox; use TalksToDevnull; + use WatchesForCalls; /** * Every frame goes in full, as on the Log (see Log::$forceFullFrames). diff --git a/app/NativeComponents/Profile.php b/app/NativeComponents/Profile.php index 6ac051e0..4ac4b256 100644 --- a/app/NativeComponents/Profile.php +++ b/app/NativeComponents/Profile.php @@ -25,6 +25,7 @@ use App\NativeComponents\Concerns\RefreshesInBackground; use App\NativeComponents\Concerns\ShowsCommits; use App\NativeComponents\Concerns\TalksToDevnull; +use App\NativeComponents\Concerns\WatchesForCalls; use Closure; use Illuminate\View\View; use Native\Mobile\Attributes\On; @@ -75,6 +76,7 @@ class Profile extends NativeComponent use RefreshesInBackground; use ShowsCommits; use TalksToDevnull; + use WatchesForCalls; /** * Every frame goes in full, as on the Log (see Log::$forceFullFrames). A tab diff --git a/app/NativeComponents/Remotes.php b/app/NativeComponents/Remotes.php index 1008d750..d027505b 100644 --- a/app/NativeComponents/Remotes.php +++ b/app/NativeComponents/Remotes.php @@ -18,6 +18,7 @@ use App\NativeComponents\Concerns\RefreshesInBackground; use App\NativeComponents\Concerns\SyncsOutbox; use App\NativeComponents\Concerns\TalksToDevnull; +use App\NativeComponents\Concerns\WatchesForCalls; use App\NativeComponents\Concerns\WritesInBackground; use App\NativeComponents\Layouts\TabsLayout; use Carbon\CarbonImmutable; @@ -65,6 +66,7 @@ class Remotes extends NativeComponent use RefreshesInBackground; use SyncsOutbox; use TalksToDevnull; + use WatchesForCalls; use WritesInBackground; /** @@ -843,7 +845,7 @@ private function page(string $tab, array $conversations, bool $loaded, bool $has 'conversation' => $conversation, 'title' => $conversation->title($viewer?->id), 'others' => $conversation->others($viewer?->id), - 'preview' => $conversation->lastMessage?->preview($viewer?->id, $conversation->isOrg()) ?? '', + 'preview' => $conversation->lastMessage?->preview($viewer?->id, $conversation->isOrg(), $conversation->partner) ?? '', 'mine' => $conversation->lastMessage !== null && $conversation->lastMessage->userId === $viewer?->id, 'viewer' => $viewer, 'myColour' => $this->myColour(), diff --git a/app/Providers/NativeServiceProvider.php b/app/Providers/NativeServiceProvider.php index 5e2897d8..15a87320 100644 --- a/app/Providers/NativeServiceProvider.php +++ b/app/Providers/NativeServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use Codingwithrk\NativephpSocialAuth\NativephpSocialAuthServiceProvider; +use Devnull\Calls\CallsServiceProvider; use Illuminate\Support\ServiceProvider; use Native\Mobile\Providers\BrowserServiceProvider; use Native\Mobile\Providers\CameraServiceProvider; @@ -58,6 +59,7 @@ public function plugins(): array MobileSplashscreenServiceProvider::class, ToastKitServiceProvider::class, ClipboardServiceProvider::class, + CallsServiceProvider::class, ...(config('devnull.apple_sign_in') ? [NativephpSocialAuthServiceProvider::class] : []), ]; } diff --git a/composer.json b/composer.json index adfa159b..aa1c8b8c 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,11 @@ ], "license": "MIT", "repositories": [ + { + "name": "calls", + "type": "path", + "url": "packages/calls" + }, { "type": "vcs", "url": "https://github.com/NativePHP/mobile-air" @@ -32,6 +37,7 @@ "require": { "php": "^8.3", "codingwithrk/nativephp-social-auth": "^1.0", + "devnull/nativephp-calls": "^1.0", "laravel/boost": "^2.0", "laravel/framework": "^13.0", "laravel/tinker": "^3.0", diff --git a/composer.lock b/composer.lock index a77e4949..79da0f01 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "86b6ccf3a666125ed5bd845be0b1b96f", + "content-hash": "25fe386e348dba059513740c224ec063", "packages": [ { "name": "bacon/bacon-qr-code", @@ -391,6 +391,42 @@ }, "time": "2025-09-16T12:23:56+00:00" }, + { + "name": "devnull/nativephp-calls", + "version": "1.0.0", + "dist": { + "type": "path", + "url": "packages/calls", + "reference": "61931a66d55ea99a272810254b9136be10705f34" + }, + "require": { + "nativephp/mobile": "^4.6.1", + "php": "^8.3" + }, + "type": "nativephp-plugin", + "extra": { + "laravel": { + "providers": [ + "Devnull\\Calls\\CallsServiceProvider" + ] + }, + "nativephp": { + "manifest": "nativephp.json" + } + }, + "autoload": { + "psr-4": { + "Devnull\\Calls\\": "src/" + } + }, + "license": [ + "MIT" + ], + "description": "One-to-one WebRTC calls for the devnull app, speaking the same protocol as dev-null.sh's browser calls", + "transport-options": { + "relative": true + } + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", diff --git a/config/nativephp.php b/config/nativephp.php index 9941ccc2..2e38a8c5 100644 --- a/config/nativephp.php +++ b/config/nativephp.php @@ -112,7 +112,7 @@ 'permissions' => [ 'NSCameraUsageDescription' => 'devnull uses the camera when you take a photo to attach to a commit.', 'NSPhotoLibraryUsageDescription' => 'devnull opens your photos when you pick some to attach to a commit.', - 'NSMicrophoneUsageDescription' => 'devnull uses the microphone when you record a voice note in Remotes.', + 'NSMicrophoneUsageDescription' => 'devnull uses the microphone when you record a voice note or make a call in Remotes.', ], /* diff --git a/packages/calls/composer.json b/packages/calls/composer.json new file mode 100644 index 00000000..239f8eaf --- /dev/null +++ b/packages/calls/composer.json @@ -0,0 +1,26 @@ +{ + "name": "devnull/nativephp-calls", + "description": "One-to-one WebRTC calls for the devnull app, speaking the same protocol as dev-null.sh's browser calls", + "type": "nativephp-plugin", + "version": "1.0.0", + "license": "MIT", + "require": { + "php": "^8.3", + "nativephp/mobile": "^4.6.1" + }, + "autoload": { + "psr-4": { + "Devnull\\Calls\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Devnull\\Calls\\CallsServiceProvider" + ] + }, + "nativephp": { + "manifest": "nativephp.json" + } + } +} diff --git a/packages/calls/nativephp.json b/packages/calls/nativephp.json new file mode 100644 index 00000000..48e80305 --- /dev/null +++ b/packages/calls/nativephp.json @@ -0,0 +1,72 @@ +{ + "namespace": "Calls", + "bridge_functions": [ + { + "name": "Calls.Start", + "android": "com.devnull.plugins.calls.CallsFunctions.Start", + "ios": "CallsFunctions.Start", + "description": "Open the microphone and start a WebRTC connection: an offer to send, or an answer to the offer given" + }, + { + "name": "Calls.Answer", + "android": "com.devnull.plugins.calls.CallsFunctions.Answer", + "ios": "CallsFunctions.Answer", + "description": "Apply the partner's answer to the offer this phone made" + }, + { + "name": "Calls.SetMuted", + "android": "com.devnull.plugins.calls.CallsFunctions.SetMuted", + "ios": "CallsFunctions.SetMuted", + "description": "Mute or unmute the microphone" + }, + { + "name": "Calls.SetSpeaker", + "android": "com.devnull.plugins.calls.CallsFunctions.SetSpeaker", + "ios": "CallsFunctions.SetSpeaker", + "description": "Play the call through the loudspeaker or the earpiece" + }, + { + "name": "Calls.End", + "android": "com.devnull.plugins.calls.CallsFunctions.End", + "ios": "CallsFunctions.End", + "description": "Hang up: closes the connection, which ends the call on the partner's side too" + } + ], + "events": [ + "Devnull\\Calls\\Events\\DescriptionReady", + "Devnull\\Calls\\Events\\Connected", + "Devnull\\Calls\\Events\\Ended" + ], + "android": { + "min_version": 29, + "permissions": [ + "android.permission.INTERNET", + "android.permission.RECORD_AUDIO", + "android.permission.MODIFY_AUDIO_SETTINGS" + ], + "dependencies": { + "implementation": [ + "io.getstream:stream-webrtc-android:1.3.10" + ] + } + }, + "ios": { + "min_version": "18.0", + "info_plist": {}, + "background_modes": [ + "audio" + ], + "dependencies": { + "swift_packages": [ + { + "url": "https://github.com/stasel/WebRTC", + "version": "156.0.0", + "products": [ + "WebRTC" + ] + } + ], + "pods": [] + } + } +} diff --git a/packages/calls/resources/android/CallManager.kt b/packages/calls/resources/android/CallManager.kt new file mode 100644 index 00000000..dad2aee9 --- /dev/null +++ b/packages/calls/resources/android/CallManager.kt @@ -0,0 +1,408 @@ +package com.devnull.plugins.calls + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import com.nativephp.mobile.utils.NativeActionCoordinator +import org.json.JSONObject +import org.webrtc.DataChannel +import org.webrtc.IceCandidate +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.MediaStreamTrack +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.RtpReceiver +import org.webrtc.RtpTransceiver +import org.webrtc.SdpObserver +import org.webrtc.SessionDescription +import org.webrtc.SoftwareVideoDecoderFactory +import org.webrtc.SoftwareVideoEncoderFactory +import org.webrtc.AudioSource +import org.webrtc.AudioTrack +import org.webrtc.audio.JavaAudioDeviceModule +import java.lang.ref.WeakReference +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * The phone's end of a call, set up exactly as dev-null.sh's browsers set theirs up (resources/js/lib/call.ts there), + * so either end can be a browser: the microphone, room for video, and a data channel called "call" whose closing + * means the other end hung up. The offer or answer only goes out once every way to reach the phone is in it, so the + * server passes on just two messages. + */ +object CallManager { + data class IceServer(val urls: List, val username: String, val credential: String) + + private const val TAG = "DevnullCalls" + private const val EVENTS = "Devnull\\Calls\\Events\\" + + /** As long as the browser waits for its candidates before sending what it has. */ + private const val GATHER_SECONDS = 3L + private const val PERMISSION_REQUEST = 7341 + + private val thread = Executors.newSingleThreadExecutor() + private val main = Handler(Looper.getMainLooper()) + + private var activityRef: WeakReference? = null + private var context: Context? = null + private var factory: PeerConnectionFactory? = null + private var peer: PeerConnection? = null + private var channel: DataChannel? = null + private var source: AudioSource? = null + private var microphone: AudioTrack? = null + private var previousAudioMode: Int? = null + + /** + * Which call is current. Starting or ending one moves it on, so what a finished call's connection still reports + * is ignored. + */ + @Volatile + private var call = 0 + + fun start(activity: FragmentActivity, iceServers: List, isRelayOnly: Boolean, offer: String?) { + activityRef = WeakReference(activity) + context = activity.applicationContext + + thread.execute { + val current = ++call + close() + + try { + if (!hasMicrophone(activity)) { + ended(current, "microphone") + + return@execute + } + + if (current == call) { + connect(activity.applicationContext, current, iceServers, isRelayOnly, offer) + } + } catch (e: Exception) { + Log.e(TAG, "Couldn't start the call", e) + ended(current, "error") + } + } + } + + fun answer(sdp: String) { + thread.execute { + val current = call + val peer = peer ?: return@execute + + try { + setDescription(peer, SessionDescription(SessionDescription.Type.ANSWER, sdp), isLocal = false) + } catch (e: Exception) { + Log.e(TAG, "Couldn't use the answer", e) + ended(current, "failed") + } + } + } + + fun setMuted(isMuted: Boolean) { + thread.execute { microphone?.setEnabled(!isMuted) } + } + + fun setSpeaker(isOn: Boolean) { + thread.execute { audioManager()?.let { useSpeaker(it, isOn) } } + } + + fun end() { + thread.execute { + call++ + close() + } + } + + private fun connect(context: Context, current: Int, iceServers: List, isRelayOnly: Boolean, offer: String?) { + val factory = factory ?: createFactory(context).also { factory = it } + useCallAudio() + + val config = PeerConnection.RTCConfiguration(iceServers.map { + PeerConnection.IceServer.builder(it.urls).setUsername(it.username).setPassword(it.credential).createIceServer() + }).apply { + sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN + // Only through the relay: a direct route would hand each person the other's IP address. + iceTransportsType = if (isRelayOnly) PeerConnection.IceTransportsType.RELAY else PeerConnection.IceTransportsType.ALL + continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE + } + + val gathered = CountDownLatch(1) + val peer = factory.createPeerConnection(config, Observer(current, gathered)) + ?: throw IllegalStateException("No peer connection") + this.peer = peer + + val source = factory.createAudioSource(MediaConstraints()).also { this.source = it } + microphone = factory.createAudioTrack("microphone", source).also { peer.addTrack(it, listOf("call")) } + + val description = if (offer == null) { + // Room for video, as the browser leaves it, so a partner on the web can still turn their camera on. + peer.addTransceiver( + MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO, + RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.SEND_RECV), + ) + watch(current, peer.createDataChannel("call", DataChannel.Init())) + createDescription(peer, isOffer = true) + } else { + setDescription(peer, SessionDescription(SessionDescription.Type.OFFER, offer), isLocal = false) + peer.transceivers + .filter { it.mediaType == MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO } + .forEach { it.setDirection(RtpTransceiver.RtpTransceiverDirection.SEND_RECV) } + createDescription(peer, isOffer = false) + } + + setDescription(peer, description, isLocal = true) + gathered.await(GATHER_SECONDS, TimeUnit.SECONDS) + + if (current == call) { + dispatch("DescriptionReady", JSONObject().put("sdp", peer.localDescription.description)) + } + } + + private fun createFactory(context: Context): PeerConnectionFactory { + PeerConnectionFactory.initialize(PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()) + + val audioDevice = JavaAudioDeviceModule.builder(context) + .setUseHardwareAcousticEchoCanceler(true) + .setUseHardwareNoiseSuppressor(true) + .createAudioDeviceModule() + + return PeerConnectionFactory.builder() + .setAudioDeviceModule(audioDevice) + .setVideoEncoderFactory(SoftwareVideoEncoderFactory()) + .setVideoDecoderFactory(SoftwareVideoDecoderFactory()) + .createPeerConnectionFactory() + } + + /** + * Asks for the microphone when it isn't allowed yet. The answer only shows in the permission itself, so it waits + * while the system's dialog has the screen, and gives up once the dialog goes away or never comes up (when the + * user said never ask again). + */ + private fun hasMicrophone(activity: FragmentActivity): Boolean { + val isGranted = { ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED } + + if (isGranted()) { + return true + } + + main.post { ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.RECORD_AUDIO), PERMISSION_REQUEST) } + + var sawDialog = false + + repeat(240) { tick -> + Thread.sleep(250) + + if (isGranted()) { + return true + } + + if (!activity.hasWindowFocus()) { + sawDialog = true + } else if (sawDialog || tick >= 8) { + return false + } + } + + return false + } + + private fun watch(current: Int, opened: DataChannel) { + channel = opened + opened.registerObserver(object : DataChannel.Observer { + override fun onBufferedAmountChange(previousAmount: Long) {} + + override fun onStateChange() { + if (opened.state() == DataChannel.State.CLOSED) { + ended(current, "hung_up") + } + } + + // The partner's camera going on or off: this end doesn't show video yet. + override fun onMessage(buffer: DataChannel.Buffer) {} + }) + } + + private fun ended(current: Int, reason: String) { + thread.execute { + if (current != call) { + return@execute + } + + call++ + close() + dispatch("Ended", JSONObject().put("reason", reason)) + } + } + + private fun close() { + channel?.let { + it.unregisterObserver() + it.close() + it.dispose() + } + channel = null + peer?.dispose() + peer = null + microphone = null + source?.dispose() + source = null + restoreAudio() + } + + private fun createDescription(peer: PeerConnection, isOffer: Boolean): SessionDescription { + val latch = CountDownLatch(1) + var description: SessionDescription? = null + var error: String? = null + val observer = object : SdpObserver { + override fun onCreateSuccess(created: SessionDescription) { + description = created + latch.countDown() + } + + override fun onCreateFailure(message: String?) { + error = message + latch.countDown() + } + + override fun onSetSuccess() {} + + override fun onSetFailure(message: String?) {} + } + + if (isOffer) { + peer.createOffer(observer, MediaConstraints()) + } else { + peer.createAnswer(observer, MediaConstraints()) + } + + latch.await(10, TimeUnit.SECONDS) + + return description ?: throw IllegalStateException(error ?: "No session description") + } + + private fun setDescription(peer: PeerConnection, description: SessionDescription, isLocal: Boolean) { + val latch = CountDownLatch(1) + var error: String? = "Timed out" + val observer = object : SdpObserver { + override fun onCreateSuccess(created: SessionDescription) {} + + override fun onCreateFailure(message: String?) {} + + override fun onSetSuccess() { + error = null + latch.countDown() + } + + override fun onSetFailure(message: String?) { + error = message ?: "Couldn't set the session description" + latch.countDown() + } + } + + if (isLocal) { + peer.setLocalDescription(observer, description) + } else { + peer.setRemoteDescription(observer, description) + } + + latch.await(10, TimeUnit.SECONDS) + error?.let { throw IllegalStateException(it) } + } + + private fun audioManager(): AudioManager? = context?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + + private fun useCallAudio() { + val audio = audioManager() ?: return + + if (previousAudioMode == null) { + previousAudioMode = audio.mode + } + + audio.mode = AudioManager.MODE_IN_COMMUNICATION + useSpeaker(audio, false) + } + + private fun restoreAudio() { + val audio = audioManager() ?: return + val mode = previousAudioMode ?: return + + useSpeaker(audio, false) + audio.mode = mode + previousAudioMode = null + } + + private fun useSpeaker(audio: AudioManager, isOn: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val speaker = audio.availableCommunicationDevices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER } + + if (isOn && speaker != null) { + audio.setCommunicationDevice(speaker) + } else { + audio.clearCommunicationDevice() + } + } else { + @Suppress("DEPRECATION") + audio.isSpeakerphoneOn = isOn + } + } + + private fun dispatch(event: String, payload: JSONObject) { + val activity = activityRef?.get() ?: return + + // Events only reach PHP from the main thread. + main.post { NativeActionCoordinator.dispatchEvent(activity, EVENTS + event, payload.toString()) } + } + + private class Observer(private val current: Int, private val gathered: CountDownLatch) : PeerConnection.Observer { + override fun onIceGatheringChange(state: PeerConnection.IceGatheringState) { + if (state == PeerConnection.IceGatheringState.COMPLETE) { + gathered.countDown() + } + } + + override fun onConnectionChange(state: PeerConnection.PeerConnectionState) { + when (state) { + PeerConnection.PeerConnectionState.CONNECTED -> if (current == call) dispatch("Connected", JSONObject()) + PeerConnection.PeerConnectionState.FAILED -> ended(current, "failed") + else -> {} + } + } + + override fun onDataChannel(channel: DataChannel) { + thread.execute { + if (current == call) { + watch(current, channel) + } + } + } + + override fun onSignalingChange(state: PeerConnection.SignalingState) {} + + override fun onIceConnectionChange(state: PeerConnection.IceConnectionState) {} + + override fun onIceConnectionReceivingChange(receiving: Boolean) {} + + override fun onIceCandidate(candidate: IceCandidate) {} + + override fun onIceCandidatesRemoved(candidates: Array) {} + + override fun onAddStream(stream: MediaStream) {} + + override fun onRemoveStream(stream: MediaStream) {} + + override fun onRenegotiationNeeded() {} + + override fun onAddTrack(receiver: RtpReceiver, streams: Array) {} + } +} diff --git a/packages/calls/resources/android/CallsFunctions.kt b/packages/calls/resources/android/CallsFunctions.kt new file mode 100644 index 00000000..1a080c2e --- /dev/null +++ b/packages/calls/resources/android/CallsFunctions.kt @@ -0,0 +1,92 @@ +package com.devnull.plugins.calls + +import androidx.fragment.app.FragmentActivity +import com.nativephp.mobile.bridge.BridgeFunction +import org.json.JSONArray + +/** + * One-to-one WebRTC calls. + * Namespace: "Calls.*" + * + * Every function returns at once: the work happens on the call's own thread, and what comes of it arrives as events + * (DescriptionReady, Connected, Ended). + */ +object CallsFunctions { + + /** + * Opens the microphone and starts a connection. + * Parameters: + * - ice_servers: array of {urls, username, credential} + * - is_relay_only: boolean - Only connect through the relay, so neither end sees the other's IP address + * - offer: (optional) string - The partner's offer to answer; without it, this phone makes the offer + */ + class Start(private val activity: FragmentActivity) : BridgeFunction { + override fun execute(parameters: Map): Map { + val servers = (parameters["ice_servers"] as? JSONArray) ?: JSONArray() + val iceServers = (0 until servers.length()).mapNotNull { servers.optJSONObject(it) }.map { server -> + val urls = server.opt("urls") + CallManager.IceServer( + urls = if (urls is JSONArray) (0 until urls.length()).map { urls.getString(it) } else listOfNotNull(urls?.toString()), + username = server.optString("username"), + credential = server.optString("credential"), + ) + } + val isRelayOnly = parameters["is_relay_only"] as? Boolean ?: true + // A null offer arrives as JSONObject.NULL, which isn't a String. + val offer = (parameters["offer"] as? String)?.takeIf { it.isNotEmpty() } + + CallManager.start(activity, iceServers, isRelayOnly, offer) + + return mapOf("success" to true) + } + } + + /** + * Applies the partner's answer to this phone's offer. + * Parameters: + * - sdp: string + */ + class Answer(private val activity: FragmentActivity) : BridgeFunction { + override fun execute(parameters: Map): Map { + val sdp = parameters["sdp"] as? String ?: return mapOf("success" to false) + CallManager.answer(sdp) + + return mapOf("success" to true) + } + } + + /** + * Parameters: + * - muted: boolean + */ + class SetMuted(private val activity: FragmentActivity) : BridgeFunction { + override fun execute(parameters: Map): Map { + CallManager.setMuted(parameters["muted"] as? Boolean ?: false) + + return mapOf("success" to true) + } + } + + /** + * Parameters: + * - on: boolean - Loudspeaker when true, earpiece when false + */ + class SetSpeaker(private val activity: FragmentActivity) : BridgeFunction { + override fun execute(parameters: Map): Map { + CallManager.setSpeaker(parameters["on"] as? Boolean ?: false) + + return mapOf("success" to true) + } + } + + /** + * Hangs up, which closes the call on the partner's side too. + */ + class End(private val activity: FragmentActivity) : BridgeFunction { + override fun execute(parameters: Map): Map { + CallManager.end() + + return mapOf("success" to true) + } + } +} diff --git a/packages/calls/resources/ios/CallManager.swift b/packages/calls/resources/ios/CallManager.swift new file mode 100644 index 00000000..0f337b20 --- /dev/null +++ b/packages/calls/resources/ios/CallManager.swift @@ -0,0 +1,330 @@ +import AVFoundation +import Foundation +import WebRTC + +/// The phone's end of a call, set up exactly as dev-null.sh's browsers set theirs up (resources/js/lib/call.ts there), +/// so either end can be a browser: the microphone, room for video, and a data channel called "call" whose closing +/// means the other end hung up. The offer or answer only goes out once every way to reach the phone is in it, so the +/// server passes on just two messages. +final class CallManager: NSObject { + struct IceServer { + let urls: [String] + let username: String + let credential: String + } + + static let shared = CallManager() + + private static let events = "Devnull\\Calls\\Events\\" + + /// As long as the browser waits for its candidates before sending what it has. + private static let gatherSeconds = 3.0 + + private let queue = DispatchQueue(label: "devnull.calls") + private lazy var factory: RTCPeerConnectionFactory = { + RTCInitializeSSL() + + return RTCPeerConnectionFactory( + encoderFactory: RTCDefaultVideoEncoderFactory(), + decoderFactory: RTCDefaultVideoDecoderFactory() + ) + }() + + private var peer: RTCPeerConnection? + private var channel: RTCDataChannel? + private var microphone: RTCAudioTrack? + private var gathered: DispatchSemaphore? + + /// Which call is current. Starting or ending one moves it on, so what a finished call's connection still reports + /// is ignored. + private var call = 0 + + func start(iceServers: [IceServer], isRelayOnly: Bool, offer: String?) { + queue.async { + self.call += 1 + let current = self.call + self.close() + + guard self.hasMicrophone() else { + self.ended(current, reason: "microphone") + + return + } + + do { + try self.connect(current, iceServers: iceServers, isRelayOnly: isRelayOnly, offer: offer) + } catch { + print("DevnullCalls: couldn't start the call: \(error)") + self.ended(current, reason: "error") + } + } + } + + func answer(sdp: String) { + queue.async { + let current = self.call + + guard let peer = self.peer else { + return + } + + do { + try self.setDescription(peer, RTCSessionDescription(type: .answer, sdp: sdp), isLocal: false) + } catch { + print("DevnullCalls: couldn't use the answer: \(error)") + self.ended(current, reason: "failed") + } + } + } + + func setMuted(_ isMuted: Bool) { + queue.async { self.microphone?.isEnabled = !isMuted } + } + + func setSpeaker(_ isOn: Bool) { + queue.async { + try? AVAudioSession.sharedInstance().overrideOutputAudioPort(isOn ? .speaker : .none) + } + } + + func end() { + queue.async { + self.call += 1 + self.close() + } + } + + private func connect(_ current: Int, iceServers: [IceServer], isRelayOnly: Bool, offer: String?) throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth]) + try session.setActive(true) + + let config = RTCConfiguration() + config.iceServers = iceServers.map { RTCIceServer(urlStrings: $0.urls, username: $0.username, credential: $0.credential) } + config.sdpSemantics = .unifiedPlan + // Only through the relay: a direct route would hand each person the other's IP address. + config.iceTransportPolicy = isRelayOnly ? .relay : .all + config.continualGatheringPolicy = .gatherOnce + + let gathered = DispatchSemaphore(value: 0) + self.gathered = gathered + + guard let peer = factory.peerConnection( + with: config, + constraints: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil), + delegate: self + ) else { + throw CallError.failed("No peer connection") + } + self.peer = peer + + let source = factory.audioSource(with: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)) + let microphone = factory.audioTrack(with: source, trackId: "microphone") + peer.add(microphone, streamIds: ["call"]) + self.microphone = microphone + + let description: RTCSessionDescription + + if let offer { + try setDescription(peer, RTCSessionDescription(type: .offer, sdp: offer), isLocal: false) + peer.transceivers.filter { $0.mediaType == .video }.forEach { $0.setDirection(.sendRecv, error: nil) } + description = try createDescription(peer, isOffer: false) + } else { + // Room for video, as the browser leaves it, so a partner on the web can still turn their camera on. + let video = RTCRtpTransceiverInit() + video.direction = .sendRecv + peer.addTransceiver(of: .video, init: video) + + if let channel = peer.dataChannel(forLabel: "call", configuration: RTCDataChannelConfiguration()) { + watch(channel) + } + + description = try createDescription(peer, isOffer: true) + } + + try setDescription(peer, description, isLocal: true) + _ = gathered.wait(timeout: .now() + Self.gatherSeconds) + + if current == call, let sdp = peer.localDescription?.sdp { + dispatch("DescriptionReady", ["sdp": sdp]) + } + } + + /// Asks for the microphone when it isn't allowed yet, and waits for the answer. + private func hasMicrophone() -> Bool { + switch AVAudioApplication.shared.recordPermission { + case .granted: + return true + case .denied: + return false + default: + let answered = DispatchSemaphore(value: 0) + var isGranted = false + AVAudioApplication.requestRecordPermission { granted in + isGranted = granted + answered.signal() + } + answered.wait() + + return isGranted + } + } + + private func watch(_ opened: RTCDataChannel) { + channel = opened + opened.delegate = self + } + + private func ended(_ current: Int, reason: String) { + queue.async { + guard current == self.call else { + return + } + + self.call += 1 + self.close() + self.dispatch("Ended", ["reason": reason]) + } + } + + private func close() { + channel?.delegate = nil + channel?.close() + channel = nil + peer?.delegate = nil + peer?.close() + peer = nil + microphone = nil + gathered?.signal() + gathered = nil + try? AVAudioSession.sharedInstance().overrideOutputAudioPort(.none) + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + + private func createDescription(_ peer: RTCPeerConnection, isOffer: Bool) throws -> RTCSessionDescription { + let done = DispatchSemaphore(value: 0) + var description: RTCSessionDescription? + var failure: Error? + let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil) + let completion: (RTCSessionDescription?, Error?) -> Void = { created, error in + description = created + failure = error + done.signal() + } + + if isOffer { + peer.offer(for: constraints, completionHandler: completion) + } else { + peer.answer(for: constraints, completionHandler: completion) + } + + _ = done.wait(timeout: .now() + 10) + + guard let description else { + throw failure ?? CallError.failed("No session description") + } + + return description + } + + private func setDescription(_ peer: RTCPeerConnection, _ description: RTCSessionDescription, isLocal: Bool) throws { + let done = DispatchSemaphore(value: 0) + var failure: Error? = CallError.failed("Timed out") + let completion: (Error?) -> Void = { error in + failure = error + done.signal() + } + + if isLocal { + peer.setLocalDescription(description, completionHandler: completion) + } else { + peer.setRemoteDescription(description, completionHandler: completion) + } + + _ = done.wait(timeout: .now() + 10) + + if let failure { + throw failure + } + } + + private func dispatch(_ event: String, _ payload: [String: Any]) { + // Events only reach PHP from the main thread. + DispatchQueue.main.async { + LaravelBridge.shared.send?(Self.events + event, payload) + } + } + + private enum CallError: Error { + case failed(String) + } +} + +// MARK: - RTCPeerConnectionDelegate + +extension CallManager: RTCPeerConnectionDelegate { + func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) { + // Straight from here: the call's queue is the one waiting for it. + if newState == .complete { + gathered?.signal() + } + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCPeerConnectionState) { + queue.async { + guard peerConnection === self.peer else { + return + } + + switch newState { + case .connected: + self.dispatch("Connected", [:]) + case .failed: + self.ended(self.call, reason: "failed") + default: + break + } + } + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) { + queue.async { + if peerConnection === self.peer { + self.watch(dataChannel) + } + } + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {} + + func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {} + + func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {} + + func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {} + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {} + + func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) {} + + func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {} +} + +// MARK: - RTCDataChannelDelegate + +extension CallManager: RTCDataChannelDelegate { + func dataChannelDidChangeState(_ dataChannel: RTCDataChannel) { + guard dataChannel.readyState == .closed else { + return + } + + queue.async { + if dataChannel === self.channel { + self.ended(self.call, reason: "hung_up") + } + } + } + + /// The partner's camera going on or off: this end doesn't show video yet. + func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) {} +} diff --git a/packages/calls/resources/ios/CallsFunctions.swift b/packages/calls/resources/ios/CallsFunctions.swift new file mode 100644 index 00000000..e0d670c5 --- /dev/null +++ b/packages/calls/resources/ios/CallsFunctions.swift @@ -0,0 +1,88 @@ +import Foundation + +// MARK: - Calls Function Namespace + +/// One-to-one WebRTC calls. +/// Namespace: "Calls.*" +/// +/// Every function returns at once: the work happens on the call's own queue, and what comes of it arrives as events +/// (DescriptionReady, Connected, Ended). +enum CallsFunctions { + + // MARK: - Calls.Start + + /// Opens the microphone and starts a connection. + /// Parameters: + /// - ice_servers: array of {urls, username, credential} + /// - is_relay_only: boolean - Only connect through the relay, so neither end sees the other's IP address + /// - offer: (optional) string - The partner's offer to answer; without it, this phone makes the offer + class Start: BridgeFunction { + func execute(parameters: [String: Any]) throws -> [String: Any] { + let servers = (parameters["ice_servers"] as? [[String: Any]] ?? []).map { server in + CallManager.IceServer( + urls: server["urls"] as? [String] ?? [server["urls"] as? String].compactMap { $0 }, + username: server["username"] as? String ?? "", + credential: server["credential"] as? String ?? "" + ) + } + let isRelayOnly = parameters["is_relay_only"] as? Bool ?? true + let offer = (parameters["offer"] as? String).flatMap { $0.isEmpty ? nil : $0 } + + CallManager.shared.start(iceServers: servers, isRelayOnly: isRelayOnly, offer: offer) + + return ["success": true] + } + } + + // MARK: - Calls.Answer + + /// Applies the partner's answer to this phone's offer. + /// Parameters: + /// - sdp: string + class Answer: BridgeFunction { + func execute(parameters: [String: Any]) throws -> [String: Any] { + guard let sdp = parameters["sdp"] as? String else { + return ["success": false] + } + + CallManager.shared.answer(sdp: sdp) + + return ["success": true] + } + } + + // MARK: - Calls.SetMuted + + /// Parameters: + /// - muted: boolean + class SetMuted: BridgeFunction { + func execute(parameters: [String: Any]) throws -> [String: Any] { + CallManager.shared.setMuted(parameters["muted"] as? Bool ?? false) + + return ["success": true] + } + } + + // MARK: - Calls.SetSpeaker + + /// Parameters: + /// - on: boolean - Loudspeaker when true, earpiece when false + class SetSpeaker: BridgeFunction { + func execute(parameters: [String: Any]) throws -> [String: Any] { + CallManager.shared.setSpeaker(parameters["on"] as? Bool ?? false) + + return ["success": true] + } + } + + // MARK: - Calls.End + + /// Hangs up, which closes the call on the partner's side too. + class End: BridgeFunction { + func execute(parameters: [String: Any]) throws -> [String: Any] { + CallManager.shared.end() + + return ["success": true] + } + } +} diff --git a/packages/calls/src/Calls.php b/packages/calls/src/Calls.php new file mode 100644 index 00000000..1c248195 --- /dev/null +++ b/packages/calls/src/Calls.php @@ -0,0 +1,66 @@ +, username: string, credential: string}> $iceServers + */ + public function start(array $iceServers, bool $isRelayOnly, ?string $offer = null): bool + { + return $this->call('Calls.Start', [ + 'ice_servers' => $iceServers, + 'is_relay_only' => $isRelayOnly, + 'offer' => $offer, + ]); + } + + /** + * Applies the partner's answer to the offer this phone made. + */ + public function answer(string $sdp): bool + { + return $this->call('Calls.Answer', ['sdp' => $sdp]); + } + + public function setMuted(bool $isMuted): bool + { + return $this->call('Calls.SetMuted', ['muted' => $isMuted]); + } + + public function setSpeaker(bool $isOn): bool + { + return $this->call('Calls.SetSpeaker', ['on' => $isOn]); + } + + /** + * Hangs up. Closing the connection ends the call on the partner's side as well, with no server in between. + */ + public function end(): bool + { + return $this->call('Calls.End'); + } + + /** + * @param array $parameters + */ + private function call(string $method, array $parameters = []): bool + { + if (! function_exists('nativephp_call')) { + return false; + } + + $result = nativephp_call($method, json_encode($parameters, JSON_THROW_ON_ERROR)); + + return (bool) (json_decode((string) $result, true)['success'] ?? false); + } +} diff --git a/packages/calls/src/CallsServiceProvider.php b/packages/calls/src/CallsServiceProvider.php new file mode 100644 index 00000000..368fbf67 --- /dev/null +++ b/packages/calls/src/CallsServiceProvider.php @@ -0,0 +1,13 @@ +app->singleton(Calls::class); + } +} diff --git a/packages/calls/src/Events/Connected.php b/packages/calls/src/Events/Connected.php new file mode 100644 index 00000000..4de954f1 --- /dev/null +++ b/packages/calls/src/Events/Connected.php @@ -0,0 +1,15 @@ + diff --git a/resources/icons/phone-off.svg b/resources/icons/phone-off.svg new file mode 100644 index 00000000..d3ce89e4 --- /dev/null +++ b/resources/icons/phone-off.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/phone.svg b/resources/icons/phone.svg new file mode 100644 index 00000000..49409733 --- /dev/null +++ b/resources/icons/phone.svg @@ -0,0 +1 @@ + diff --git a/resources/views/native/call.blade.php b/resources/views/native/call.blade.php new file mode 100644 index 00000000..d1d70e27 --- /dev/null +++ b/resources/views/native/call.blade.php @@ -0,0 +1,46 @@ +@use('App\Icons\Lucide') +@use('App\NativeComponents\Call') + + + {{-- Who it's with and how it's going, in the middle of the screen. --}} + + @if ($avatar) + + @endif + + {{ $name }} + + {{-- The clock ticks every second while it rings for them and once connected. --}} + @if (in_array($status, [Call::Calling, Call::Connected], true)) + {{ $line }} + @else + {{ $line }} + @endif + + @if ($status === Call::Connected && $code !== '') + + fp {{ $code }} + If you both see the same code, nobody is listening in between. + + @endif + + + + @if ($status === Call::Ringing) + Decline + Accept + @elseif ($status === Call::Connected) + + + Hang up + @elseif ($status === Call::Ended) + Close + @else + Cancel + @endif + + diff --git a/routes/mobile.php b/routes/mobile.php index c60918d7..f5f232af 100644 --- a/routes/mobile.php +++ b/routes/mobile.php @@ -2,6 +2,7 @@ use App\NativeComponents\AddOrgMembers; use App\NativeComponents\AuthCallback; +use App\NativeComponents\Call; use App\NativeComponents\CommitDetail; use App\NativeComponents\Connections; use App\NativeComponents\Conversation; @@ -26,6 +27,9 @@ Route::native('/onboarding', Onboarding::class); Route::native('/auth/callback', AuthCallback::class); +// A call takes the whole screen, over whichever tab rang or placed it. +Route::native('/call/{username}', Call::class); + Route::nativeGroup(TabsLayout::class, function (): void { Route::native('/log', Log::class); Route::native('/notifications', Notifications::class); diff --git a/tests/Feature/Screens/CallTest.php b/tests/Feature/Screens/CallTest.php new file mode 100644 index 00000000..032ca680 --- /dev/null +++ b/tests/Feature/Screens/CallTest.php @@ -0,0 +1,274 @@ + 'http://devnull.test']); + $this->bridge = signIn(); + $this->travelTo('2026-10-19 15:30:00'); +}); + +/** + * The relay credentials devnull hands out for a call with Ada. + * + * @return array + */ +function callRelays(): array +{ + return ['GET /api/v1/remotes/@ada/call/ice-servers' => [[ + 'ice_servers' => [['urls' => ['turn:turn.example.test:3478'], 'username' => 'u', 'credential' => 'c']], + 'is_relay_only' => true, + ]]]; +} + +function callingAda(): TestableComponent +{ + return Native::visit('/call/ada', ['name' => 'Ada Lovelace']); +} + +function ringingFromAda(array $data = []): TestableComponent +{ + return Native::visit('/call/ada', ['name' => 'Ada Lovelace', 'offer' => CallOffer, ...$data]); +} + +it('rings them with this phone\'s offer once it has one, through the relay devnull hands out', function () { + serveApi([...callRelays(), 'POST /api/v1/remotes/@ada/call' => [null, 204]]); + + $screen = callingAda()->assertSee('Ada Lovelace')->assertSee('Ringing… 0:00'); + + $this->bridge->assertCalled('Calls.Start', fn (array $params): bool => $params['ice_servers'][0]['urls'] === ['turn:turn.example.test:3478'] + && $params['is_relay_only'] === true + && $params['offer'] === null); + expect(sentTo('POST', 'remotes/@ada/call'))->toBe([]); + + $screen->emitNative(DescriptionReady::class, ['sdp' => CallOffer]); + + expect(sentTo('POST', 'remotes/@ada/call'))->toBe([json_encode(['sdp' => CallOffer])]); +}); + +it('says why devnull won\'t let you call, in its words', function () { + serveApi(['GET /api/v1/remotes/@ada/call/ice-servers' => [['message' => "Calls aren't available right now."], 403]]); + + callingAda()->assertSee("Calls aren't available right now.")->assertSee('Close'); + + $this->bridge->assertNotCalled('Calls.Start'); +}); + +it('hangs up when devnull won\'t ring them', function () { + serveApi([...callRelays(), 'POST /api/v1/remotes/@ada/call' => [['message' => 'Say hi to @ada before calling them.'], 403]]); + + callingAda() + ->emitNative(DescriptionReady::class, ['sdp' => CallOffer]) + ->assertSee('Say hi to @ada before calling them.'); + + $this->bridge->assertCalled('Calls.End'); +}); + +it('connects once they answer, and shows the same code the web does', function () { + serveApi([...callRelays(), 'POST /api/v1/remotes/@ada/call' => [null, 204], 'GET /api/v1/remotes/@ada/call' => [['call' => ['answer' => CallAnswer]]], 'DELETE /api/v1/remotes/@ada/call' => [null, 204]]); + + $screen = callingAda() + ->emitNative(DescriptionReady::class, ['sdp' => CallOffer]) + ->firePoll('check') + ->assertSee('Connecting…'); + + $this->bridge->assertCalled('Calls.Answer', fn (array $params): bool => $params['sdp'] === CallAnswer); + // Picked up, so devnull forgets it without logging anything. + expect(sentTo('DELETE', 'remotes/@ada/call'))->toHaveCount(1); + + $this->travel(42)->seconds(); + + $screen->emitNative(Connected::class) + ->assertSee('0:00') + ->assertSee('fp '.CallCode); +}); + +it('gives up when nobody answers within 45 seconds', function () { + serveApi([...callRelays(), 'POST /api/v1/remotes/@ada/call' => [null, 204], 'GET /api/v1/remotes/@ada/call' => [['call' => ['answer' => null]]], 'DELETE /api/v1/remotes/@ada/call' => [null, 204]]); + + $screen = callingAda()->emitNative(DescriptionReady::class, ['sdp' => CallOffer])->firePoll('check'); + expect(sentTo('DELETE', 'remotes/@ada/call'))->toBe([]); + + $this->travel(46)->seconds(); + $screen->firePoll('check')->assertSee('No answer.'); + + expect(sentTo('DELETE', 'remotes/@ada/call'))->toHaveCount(1); + $this->bridge->assertCalled('Calls.End'); +}); + +it('cancels a call that is still ringing, so their thread logs it', function () { + serveApi([...callRelays(), 'DELETE /api/v1/remotes/@ada/call' => [null, 204]]); + + callingAda()->tap('cancel')->assertSee('Call ended.'); + + expect(sentTo('DELETE', 'remotes/@ada/call'))->toBe([json_encode(['failed' => false])]); + $this->bridge->assertCalled('Calls.End'); +}); + +it('rings with Accept and Decline when they call, and answers with this phone\'s answer', function () { + serveApi([...callRelays(), 'PUT /api/v1/remotes/@ada/call' => [null, 204]]); + + $screen = ringingFromAda()->assertSee('is calling')->assertSee('Decline')->assertSee('Accept'); + $this->bridge->assertNotCalled('Calls.Start'); + + $screen->tap('accept')->assertSee('Connecting…'); + $this->bridge->assertCalled('Calls.Start', fn (array $params): bool => $params['offer'] === CallOffer); + + $screen->emitNative(DescriptionReady::class, ['sdp' => CallAnswer]) + ->emitNative(Connected::class) + ->assertSee('fp '.CallCode); + + expect(sentTo('PUT', 'remotes/@ada/call'))->toBe([json_encode(['sdp' => CallAnswer])]); +}); + +it('declines, which devnull logs in your thread, and closes', function () { + serveApi(['DELETE /api/v1/remotes/@ada/call' => [null, 204]]); + + ringingFromAda()->tap('decline')->assertWentBack(); + + expect(sentTo('DELETE', 'remotes/@ada/call'))->toBe([json_encode(['failed' => false])]); +}); + +it('buzzes while it rings, unless their thread is muted', function (bool $isMuted) { + serveApi(['GET /api/v1/remotes/call' => [['call' => ['partner' => userJson(['handle' => 'ada']), 'sdp' => CallOffer, 'is_muted' => $isMuted]]]]); + + ringingFromAda(['is_muted' => $isMuted])->firePoll('check')->assertSee('is calling'); + + expect($this->bridge->callsTo('Device.Vibrate'))->toHaveCount($isMuted ? 0 : 1); +})->with([false, true]); + +it('says you missed it once they stop calling', function () { + serveApi(['GET /api/v1/remotes/call' => [['call' => null]]]); + + ringingFromAda()->firePoll('check')->assertSee('Missed call from @ada.'); +}); + +it('tells them answering failed when the microphone isn\'t allowed', function () { + serveApi([...callRelays(), 'DELETE /api/v1/remotes/@ada/call' => [null, 204]]); + + ringingFromAda()->tap('accept') + ->emitNative(Ended::class, ['reason' => 'microphone']) + ->assertSee('Allow the microphone to call.'); + + expect(sentTo('DELETE', 'remotes/@ada/call'))->toBe([json_encode(['failed' => true])]); +}); + +it('says how the call ended when it ends at the other end', function (string $reason, bool $wasConnected, string $said) { + serveApi([...callRelays(), 'PUT /api/v1/remotes/@ada/call' => [null, 204]]); + + $screen = ringingFromAda()->tap('accept')->emitNative(DescriptionReady::class, ['sdp' => CallAnswer]); + + if ($wasConnected) { + $screen->emitNative(Connected::class); + } + + $screen->emitNative(Ended::class, ['reason' => $reason])->assertSee($said); +})->with([ + 'they hung up' => ['hung_up', true, 'Call ended.'], + 'it dropped' => ['failed', true, 'Call ended.'], + 'it never connected' => ['failed', false, "Couldn't connect the call."], +]); + +it('gives up connecting after 20 seconds', function () { + serveApi([...callRelays(), 'PUT /api/v1/remotes/@ada/call' => [null, 204]]); + + $screen = ringingFromAda()->tap('accept')->emitNative(DescriptionReady::class, ['sdp' => CallAnswer]); + $this->travel(21)->seconds(); + + $screen->firePoll('check')->assertSee("Couldn't connect the call."); + $this->bridge->assertCalled('Calls.End'); +}); + +it('mutes the microphone and turns the speaker on during a call', function () { + serveApi([...callRelays(), 'PUT /api/v1/remotes/@ada/call' => [null, 204]]); + + ringingFromAda()->tap('accept') + ->emitNative(DescriptionReady::class, ['sdp' => CallAnswer]) + ->emitNative(Connected::class) + ->tap('mute') + ->tap('speaker'); + + $this->bridge->assertCalled('Calls.SetMuted', fn (array $params): bool => $params['muted'] === true); + $this->bridge->assertCalled('Calls.SetSpeaker', fn (array $params): bool => $params['on'] === true); +}); + +it('hangs up on Android\'s back during a call', function () { + serveApi([...callRelays(), 'PUT /api/v1/remotes/@ada/call' => [null, 204]]); + + ringingFromAda()->tap('accept') + ->emitNative(DescriptionReady::class, ['sdp' => CallAnswer]) + ->emitNative(Connected::class) + ->pressBack() + ->assertSee('Call ended.'); + + $this->bridge->assertCalled('Calls.End'); +}); + +it('closes a few seconds after the call ends', function () { + serveApi(['GET /api/v1/remotes/@ada/call/ice-servers' => [['message' => 'Nope.'], 403]]); + + $screen = callingAda()->firePoll('check'); + expect($screen->navigationIntent())->toBeNull(); + + $this->travel(Call::EndedSeconds + 1)->seconds(); + $screen->firePoll('check')->assertWentBack(); +}); + +it('opens a call ringing for you from the thread you are on, once', function () { + serveApi([ + 'GET /api/v1/remotes/@ada/messages' => [threadJson([remoteMessageJson(['id' => 1, 'body' => 'hey'])])], + 'GET /api/v1/remotes/call' => [['call' => ['partner' => userJson(['handle' => 'ada', 'name' => 'Ada Lovelace']), 'sdp' => CallOffer, 'is_muted' => false]]], + ]); + + $thread = finishRefreshes(Native::visit('/remotes/conversations/ada'))->firePoll('checkForCall')->assertNavigatedTo('/call/ada'); + + expect($thread->navigationIntent()->data)->toMatchArray(['name' => 'Ada Lovelace', 'offer' => CallOffer, 'is_muted' => false]); + + // Once it has rung, it doesn't open again while devnull still has it. + ringingFromAda(); + expect(finishRefreshes(Native::visit('/remotes/conversations/ada'))->firePoll('checkForCall')->navigationIntent())->toBeNull(); +}); + +it('logs calls in the thread in the web\'s words, naming them where devnull leaves the author off', function () { + $me = userJson()['id']; + serveApi(['GET /api/v1/remotes/@ada/messages' => [threadJson([ + remoteMessageJson(['id' => 1, 'user_id' => $me, 'body' => '', 'event' => 'call_started']), + remoteMessageJson(['id' => 2, 'body' => '', 'event' => 'call_answered']), + remoteMessageJson(['id' => 3, 'user_id' => $me, 'body' => '', 'event' => 'call_cancelled']), + remoteMessageJson(['id' => 4, 'body' => '', 'event' => 'call_declined']), + remoteMessageJson(['id' => 5, 'body' => '', 'event' => 'call_failed']), + remoteMessageJson(['id' => 6, 'user_id' => $me, 'body' => '', 'event' => 'call_missed']), + remoteMessageJson(['id' => 7, 'body' => '', 'event' => 'call_missed']), + ])]]); + + finishRefreshes(Native::visit('/remotes/conversations/ada')) + ->assertSee('* you started a call') + ->assertSee('* @ada answered the call') + ->assertSee('* you cancelled the call') + ->assertSee('* @ada declined the call') + ->assertSee("* @ada couldn't answer the call") + ->assertSee("* your call wasn't answered") + ->assertSee('* missed call from @ada') + ->assertDontSee('someone'); +}); + +it('calls them from the phone button in your thread with them', function () { + serveApi(['GET /api/v1/remotes/@ada/messages' => [threadJson([remoteMessageJson(['id' => 1, 'body' => 'hey'])])]]); + + $thread = finishRefreshes(Native::visit('/remotes/conversations/ada'))->call('call')->assertNavigatedTo('/call/ada'); + + expect($thread->navigationIntent()->data)->toMatchArray(['name' => 'Ada Lovelace']); +});