diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index 40bd8a7..ae42a75 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 0000000..5f86c89 --- /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 aa6e273..3bdb418 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 4167822..360d30a 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 0000000..c4a6863 --- /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 0000000..adc6271 --- /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 253ac1d..ddde62a 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 9d03448..2f6fbaa 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 f31bc45..6ca994d 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 6ac051e..4ac4b25 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 1008d75..d027505 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 5e2897d..15a8732 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 adfa159..aa1c8b8 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 a77e494..79da0f0 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 9941ccc..2e38a8c 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 0000000..239f8ea --- /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 0000000..48e8030 --- /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 0000000..dad2aee --- /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 0000000..1a080c2 --- /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 0000000..0f337b2 --- /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 0000000..e0d670c --- /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 0000000..1c24819 --- /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 0000000..368fbf6 --- /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 0000000..4de954f --- /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 0000000..d3ce89e --- /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 0000000..4940973 --- /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 0000000..d1d70e2 --- /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 c60918d..f5f232a 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 0000000..032ca68 --- /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']); +});