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:
Тук
Комментарии (8)
Shevman
03.10.2025 12:29У меня возникала аналогичная идея, очень поддерживаю, не бросайте проект
bodyawm
03.10.2025 12:29Я будучи любителем ретро-гаджетов, хотел намутить современный защищенный и простой в реализации протокол для организации чатиков. Есть XMPP конечно, но его xml не прям везде можно быстро парсить.
CEOCTO Автор
03.10.2025 12:29https://habr.com/ru/articles/907934/
Вот я писал пост о секьюрном мессенджере на Rust
bodyawm
Занимательно