JOPA Call: P2P WebRTC-диалер на Android
После запуска VK MAX звонков все мы ощутили «улучшение качества связи»: WhatsApp и Telegram-звонки внезапно стали то заикаться, то просто падать. На парковке созвониться? Забудьте. Через VPN по мобильной сети? Тоже боль. Добавим к этому ещё и порезанную сотовую связь «из-за дронов» — и получаем настоящий ад для тех, кто привык общаться через привычные мессенджеры.
Решение? Сделать свою звонилку.
Так родился проект JOPA Call — Just One Peer App (или, если по-русски: «Просто одно приложение для звонков»).
Идея простая:
WebRTC берёт на себя всю магию p2p-видеосвязи;
сервер на Go нужен лишь как “сигнализатор”, чтобы свести двух пользователей в одну комнату;
дальше общение идёт напрямую, без лишних посредников и блокировок.
Уже есть рабочий Android-клиент, в планах — iOS-версия.
Особенности:
Минимализм в интерфейсе — только звонок, без мишуры.
Максимальная прозрачность кода — всё понятно, без «чёрных ящиков».
Экспериментальный дух — делается не ради хайпа, а потому что «надоело терпеть».
💡 Max нервно курит в сторонке — теперь у нас есть своя звонилка, которая работает там, где «официальные» сервисы бессильно машут руками.
Презентация приложения

Так как я Kotlin знаю слабо, в помощь ChatGPT и Gemini интегрированный в Android Studio
Поэтому создаем проект, набрасываем скелет кода, обвязываем функционалом, дебажим в итоге получилось: [Исходник:] GitHub
И по быстрому сервер на Go [Исходник:]
GitHub
Также в репозитории лежит конфиг демона сервиса и конфиг TurnServer
Архитектура:
UI (CallActivity)
отрисовывает два SurfaceViewRenderer:
remote video (во весь экран),
local preview (маленькое окно в углу).
SignalingClient
простой клиент к WebSocket серверу (Node.js/Golang/Python — неважно),
обменивается JSON-сообщениями: offer, answer, ice.
PeerConnectionManager
инициализация WebRTC peer-соединения,
управление медиа-трекерами,
подписка на ICE-кандидаты и события соединения.
RtcEnv (Singleton)
глобальный EglBase и PeerConnectionFactory,
аудиомодуль JavaAudioDeviceModule,
правильная инициализация WebRTC на старте приложения.

Ключевые моменты реализации
Глобальное WebRTC-окружение
object RtcEnv { private var eglBase: EglBase? = null val eglCtx get() = requireNotNull(eglBase).eglBaseContext lateinit var factory: PeerConnectionFactory private set private var adm: JavaAudioDeviceModule? = null fun init(app: Application) { PeerConnectionFactory.initialize( PeerConnectionFactory.InitializationOptions.builder(app) .setEnableInternalTracer(true) .createInitializationOptions() ) eglBase = EglBase.create() adm = JavaAudioDeviceModule.builder(app) .setUseHardwareAcousticEchoCanceler(false) .setUseHardwareNoiseSuppressor(false) .createAudioDeviceModule() val encoder = DefaultVideoEncoderFactory(eglCtx, true, true) val decoder = DefaultVideoDecoderFactory(eglCtx) factory = PeerConnectionFactory.builder() .setAudioDeviceModule(requireNotNull(adm)) .setVideoEncoderFactory(encoder) .setVideoDecoderFactory(decoder) .createPeerConnectionFactory() } }
PeerConnectionManager
class PeerConnectionManager( private val factory: PeerConnectionFactory, private val eglCtx: EglBase.Context, private val iceServers: List<PeerConnection.IceServer>, private val localSink: VideoSink, private val remoteSink: VideoSink ) { private var peer: PeerConnection? = null fun createPeer( onIce: (IceCandidate) -> Unit, onConnected: () -> Unit, onDisconnected: () -> Unit ) { val config = PeerConnection.RTCConfiguration(iceServers).apply { iceTransportsType = PeerConnection.IceTransportsType.ALL } peer = factory.createPeerConnection(config, object : PeerConnection.Observer { override fun onIceCandidate(c: IceCandidate) = onIce(c) override fun onConnectionChange(newState: PeerConnection.PeerConnectionState) { when (newState) { PeerConnection.PeerConnectionState.CONNECTED -> onConnected() PeerConnection.PeerConnectionState.DISCONNECTED -> onDisconnected() else -> {} } } }) ?: throw IllegalStateException("PeerConnection create failed") // Медиа треки val videoSource = factory.createVideoSource(false) val videoCapturer = createCameraCapturer() videoCapturer?.initialize( SurfaceTextureHelper.create("CaptureThread", eglCtx), null, videoSource.capturerObserver ) videoCapturer?.startCapture(640, 480, 30) val localTrack = factory.createVideoTrack("local", videoSource) localTrack.addSink(localSink) peer?.addTrack(localTrack) } }
Signaling (WebSocket)
class SignalingClient( private val url: String, private val scope: CoroutineScope, private val listener: Listener ) { private val client = OkHttpClient() private var ws: WebSocket? = null fun connect(room: String) { val req = Request.Builder().url("$url/join?room=$room").build() ws = client.newWebSocket(req, object : WebSocketListener() { override fun onMessage(ws: WebSocket, text: String) { val msg = Json.decodeFromString<SignalMessage>(text) when (msg.type) { "offer" -> listener.onOffer(msg.from, msg.sdp) "answer" -> listener.onAnswer(msg.from, msg.sdp) "ice" -> listener.onIce(msg.from, msg.mid, msg.index, msg.candidate) } } }) } }
Интеграция с TURN/STUN
В CallActivity добавляем ICE-сервера:
val ice = listOf( PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer(), PeerConnection.IceServer.builder(BuildConfig.TURN_URL) .setUsername(BuildConfig.TURN_USER) .setPassword(BuildConfig.TURN_PASS) .createIceServer() )
Debug & Release
В proguard-rules.pro обязательно:
-keep class org.webrtc.** { *; } -dontwarn org.webrtc.**
Результат
Рабочее P2P-приложение для звонков,
Минимум зависимостей (Kotlin + WebRTC + OkHttp),
Поддержка STUN/TURN серверов,
Код наглядно демонстрирует, как устроен WebRTC-клиент на Android.

Выводы
Проект JOPA Call — это не конкурент WhatsApp или Telegram, а скорее учебный и демонстрационный пример. Он показывает, что собрать минимальный WebRTC-клиент на Android реально за пару вечеров.
Дальше можно развивать:
добавить push-уведомления и звонки «как в мессенджерах»,
сделать групповые звонки через SFU/MCU,
добавить сквозное шифрование SRTP-ключами,
и встроить поддержку экраншеринга.
P.S. Если кому то интересно, мой блог в TG:
Тук
