JOPA Call: P2P WebRTC-диалер на Android

После запуска VK MAX звонков все мы ощутили «улучшение качества связи»: WhatsApp и Telegram-звонки внезапно стали то заикаться, то просто падать. На парковке созвониться? Забудьте. Через VPN по мобильной сети? Тоже боль. Добавим к этому ещё и порезанную сотовую связь «из-за дронов» — и получаем настоящий ад для тех, кто привык общаться через привычные мессенджеры.

Решение? Сделать свою звонилку.

Так родился проект JOPA Call — Just One Peer App (или, если по-русски: «Просто одно приложение для звонков»).

Идея простая:

  • WebRTC берёт на себя всю магию p2p-видеосвязи;

  • сервер на Go нужен лишь как “сигнализатор”, чтобы свести двух пользователей в одну комнату;

  • дальше общение идёт напрямую, без лишних посредников и блокировок.

Уже есть рабочий Android-клиент, в планах — iOS-версия.

Особенности:

  • Минимализм в интерфейсе — только звонок, без мишуры.

  • Максимальная прозрачность кода — всё понятно, без «чёрных ящиков».

  • Экспериментальный дух — делается не ради хайпа, а потому что «надоело терпеть».

? Max нервно курит в сторонке — теперь у нас есть своя звонилка, которая работает там, где «официальные» сервисы бессильно машут руками.

Презентация приложения

Just One Peer App
Just One Peer App

Так как я Kotlin знаю слабо, в помощь ChatGPT и Gemini интегрированный в Android Studio

Поэтому создаем проект, набрасываем скелет кода, обвязываем функционалом, дебажим в итоге получилось: [Исходник:] GitHub

И по быстрому сервер на Go [Исходник:]
GitHub
Также в репозитории лежит конфиг демона сервиса и конфиг TurnServer

Архитектура:

  1. UI (CallActivity)

  • отрисовывает два SurfaceViewRenderer:

  • remote video (во весь экран),

  • local preview (маленькое окно в углу).

  1. SignalingClient

  • простой клиент к WebSocket серверу (Node.js/Golang/Python — неважно),

  • обменивается JSON-сообщениями: offer, answer, ice.

  1. PeerConnectionManager

  • инициализация WebRTC peer-соединения,

  • управление медиа-трекерами,

  • подписка на ICE-кандидаты и события соединения.

  1. RtcEnv (Singleton)

  • глобальный EglBase и PeerConnectionFactory,

  • аудиомодуль JavaAudioDeviceModule,

  • правильная инициализация WebRTC на старте приложения.

Схема работы
Схема работы

Ключевые моменты реализации

  1. Глобальное 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()
    }
}

  1. 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)
    }
}
  1. 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.

Demo
Demo

Выводы

Проект JOPA Call — это не конкурент WhatsApp или Telegram, а скорее учебный и демонстрационный пример. Он показывает, что собрать минимальный WebRTC-клиент на Android реально за пару вечеров.

Дальше можно развивать:

  • добавить push-уведомления и звонки «как в мессенджерах»,

  • сделать групповые звонки через SFU/MCU,

  • добавить сквозное шифрование SRTP-ключами,

и встроить поддержку экраншеринга.

P.S. Если кому то интересно, мой блог в TG:
Тук

Комментарии (8)


  1. bodyawm
    03.10.2025 12:29

    Занимательно


  1. Shevman
    03.10.2025 12:29

    У меня возникала аналогичная идея, очень поддерживаю, не бросайте проект


    1. bodyawm
      03.10.2025 12:29

      Я будучи любителем ретро-гаджетов, хотел намутить современный защищенный и простой в реализации протокол для организации чатиков. Есть XMPP конечно, но его xml не прям везде можно быстро парсить.


      1. CEOCTO Автор
        03.10.2025 12:29

        https://habr.com/ru/articles/907934/

        Вот я писал пост о секьюрном мессенджере на Rust


    1. CEOCTO Автор
      03.10.2025 12:29

      спасибо!