Bloga dön
Architecture12 min read

Eş ve Kanal Sohbet Mimarisi

Bu makale, PlainApp'in çevrimdışı öncelikli sohbetinin uçtan uca nasıl çalıştığını açıklamaktadır: bir mesajın UI'da bir dokunuştan eş taşıması üzerinden başka bir cihaza kadar nasıl yol aldığını, grup kanallarının mesajları birçok üyeye nasıl dağıttığını ve ağlar kaybolduğunda sistemin nasıl dayanıklı kaldığını. Eşleştirme (iki cihazı başlatan güven ve anahtar değişimi) ayrı Eşleştirme Akışı makalesinde ele alınmaktadır.

İçindekiler

Yüksek Düzeyli Mimari {#high-level-architecture}

PlainApp sohbeti sunucusuzdur. Her cihaz, gömülü bir Ktor HTTP sunucusu çalıştırır ve cihazlar yerel ağ, Wi-Fi Aware (NAN) veya Bluetooth Low Energy üzerinden doğrudan birbirleriyle konuşur. Aktarma sunucusu, bulut gelen kutusu, telefon numarası tabanlı kimlik yoktur. Cihazlar, kendi kendine üretilmiş bir clientId ile tanımlanır ve eşleştirme sırasında gerçekleştirilen bir Ed25519 + ECDH el sıkışmasıyla doğrulanır.

İki tür sohbet vardır:

TürSabitAçıklama
PEERChatTargetType.PEERİki eşleştirilmiş cihaz arasında birebir doğrudan sohbet.
CHANNELChatTargetType.CHANNELBir cihaza ait çok taraflı grup sohbeti; üyeler mesajları birbirlerine dağıtır.

Özel "local" hedefi, cihazın kendi karalama defteridir (kendine notlar) — buna göndermek kablo üzerinden bir işlem yapmaz.

Bileşen haritası

Diagram 1
1

Mimari kasıtlı olarak katmanlıdır:

  1. UI / GraphQL giriş noktaları, asla taşımalara veya DB'ye doğrudan dokunmaz.
  2. ChatManager bir façadedir — her çağırıcı (UI, GraphQL resolver, eş alıcı) ondan geçer.
  3. ChatSender, ChatTargetType'a göre dallanan ve eş veya kanal göndericilerine devreden bir dağıtıcıdır.
  4. Taşıma katmanı, devre kırma ile takılabilir bir strateji zinciridir, böylece güvenilmez bir Wi-Fi Aware bağlantısı, BLE üzerinden gidebilecek bir mesajı asla engellemez.

Veri Modeli {#data-model}

ChatTarget

Yönlendirmenin en küçük birimi bir ChatTarget'tir — type'ın PEER veya CHANNEL olduğu bir (toId, type) çiftidir. UI'nin kararlı bir yönlendirme anahtarı olarak kullandığı (örneğin TempData.activeToId, böylece alıcı bir bildirim yayıp yaymamaya karar verebilir) bir encodedToId (peer:<id> veya channel:<id>) gösterir, bir isLocal() kontrolü (toId == "local") ve saklanan bir metinden hedefi yeniden yapılandıran bir parseId companion'u.

Veritabanı tabloları

Tüm kalıcılık Room kullanır. Sohbet için üç tablo önemlidir:

TabloVarlıkAmaç
chatsDChatMesaj başına bir satır (metin / resim / dosya).
chat_channelsDChatChannelGrup kanalı başına bir satır.
peersDPeerBilinen her cihaz için bir satır (eşleştirilmiş veya yalnızca kanal).

Diagram 2
2

Birkaç dikkat edilmesi gereken şey:

  • Kimlik clientId'dir, asla MAC değil. Android, BLE MAC'ini her bağlantıda rastgeleleştirir, bu nedenle veritabanı kararlı 13 karakterlik kendi kendine üretilmiş bir kimlik kullanır. Keşfe izin vermek için BLE üzerinden yalnızca 8 baytlık bir SHA-256 öneki (shortId) yayınlanır.
  • status="channel" eşleri, bu cihazın hiçbir zaman doğrudan eşleştirmediği bir kanalın üyeleridir. key'leri boştur — çift yönlü paylaşılan anahtar yerine kanal anahtarı kullanarak doğrulanırlar.
  • owner="me", yeni yüklenmiş bir cihazın clientId'si kararlı olmadan önce sahip gibi davranmasına izin veren bir bekçidir; isOwnedByMe() hem "me" hem de TempData.clientId'yi kabul eder.

GraphQL API Yüzeyi {#graphql-api-surface}

PlainApp iki GraphQL şeması sunar:

  1. Web GraphQL (addChatChannelSchema + addChatMessageSchema) — yerel Ktor sunucusu tarafından tarayıcı UI'sine ve apitest/ harness'ine sunulur. ChaCha20 ile şifrelenmiş bir belirteçle doğrulanır.
  2. Eş GraphQL (PeerGraphQLService.applyPeerSchema) — şifreli eş taşıması üzerinden diğer cihazlar için /peer_graphql'de sunulur. Ed25519 imzası + ChaCha20 gövde şifrelemesiyle doğrulanır.

İki şema aynı iş mantığı singleton'larını (ChannelManager, ChatMessageReceiver, …) paylaşır, ancak güven modeli farklı olduğundan farklı yüzeyler sunar: web GraphQL yerel UI'ye güvenirken, eş GraphQL yalnızca kriptografik olarak doğrulanmış eşlere güvenir.

Web GraphQL yüzeyi (sohbet)

Sorgular: chatChannels (tüm kanalları listele), chatItems(id) (bir hedef için mesajlar — id "local", peer:<id> veya channel:<id>'dir) ve latestChatItems (tüm sohbetlerde önizleme).

Sohbet mutasyonları: sendChatItem(toId, content), deleteChatItem(id), deleteChatItems(query) ve retryChatItem(id).

Kanal mutasyonları: createChatChannel(name), updateChatChannel(id, name), deleteChatChannel(id), leaveChatChannel(id), addChatChannelMember(id, peerId), removeChatChannelMember(id, peerId), acceptChatChannelInvite(id) ve declineChatChannelInvite(id).

Eş GraphQL yüzeyi (taşıma)

/peer_graphql'de sunulur ve Ed25519 imzası + ChaCha20 gövde şifrelemesiyle dorulanır. Yalnızca üç mutasyon taşıma sınırını geçer: createChatItem(content) (gelen bir eş mesajı), channelSystemMessage(type, payload) (davet/ayrılma gibi kanal yaşam döngüsü olayları) ve startAware (eşin Wi-Fi Aware hizmetini başlatması için bir dürtü, böylece daha hızlı bir taşıma devralabilir).

c-id HTTP başlığı gönderenin clientId'sini taşır; c-cid başlığı, istek kanal kapsamında olduğunda bir kanal kimliği taşır (böylece alıcı, şifre çözme için çift yönlü eş anahtarı yerine kanal anahtarını seçer).

Eş Sohbeti: Mesaj Gönderme {#peer-chat-sending-a-message}

Kullanıcı bir eş sohbetinde Gönder'e dokunduğunda, çağrı zinciri şöyledir:

Diagram 3
3

Her atlama uygulanan anahtar değişmezler:

  1. ChatManager.createChatItem her zaman önce bir satır ekler, sonra gönderir. Bu, UI'nin hemen «beklemede» bir balon görmesi ve teslimat henüz gerçekleşmemiş olsa bile mesajın uygulama çökmelerinden kurtulması anlamına gelir.
  2. PeerGraphQLClient.buildSignedRequest, signature|timestamp|requestJson biçiminde bir zarf oluşturur. İmza, "$timestamp$requestJson" üzerinden Ed25519'dur, zaman damgasını gövdeye bağlar, böylece taze bir zaman damgasıyla yeniden oynatılamaz.
  3. PeerTransportRouter.send, taşımaları Lan → WifiAware → Ble sırasıyla yineler. Her taşıma, yönlendiricinin bir sonrakini denemesine izin vermek için TransportUnavailable fırlatabilir.
  4. Alma tarafında, PeerChatParser.decrypt, zaman damgasının ±5 dakika içinde olup olmadığını kontrol eder ve GraphQL mutasyonu çalıştırılmadan önce Ed25519 imzasını doğrular.
  5. ChatMessageReceiver.receive, "$fromPeerId|$signature|$timestamp" ile anahtarlanan bir seenSignatures kümesi tutar ve yinelenenlerde ReplayedMessageException fırlatır — bu, taşımanın aynı yükü iki kez (LAN + BLE) teslim edebileceği için esastır.

PeerChatSender.send boş olmayan bir hata dizesi döndürürse, ChatSendertriggerPeerRediscovery(peerId) çağırır, bu da eşin mevcut IP/bağlantı noktasını yeniden duyurabilmesi için yönlendirilmiş, şifreli bir DISCOVER yayını ateşler.

Eş Sohbeti: Mesaj Alma {#peer-chat-receiving-a-message}

Gelen istekler, PeerGraphQLService tarafından işlenen yerel Ktor sunucusunun /peer_graphql rotasına ulaşır:

Diagram 4
4

Bildirimler

emitNotificationIfNeeded son adımdır. TempData.activeToId == targetId olduğunda (yani kullanıcı o an o sohbeti görüyorsa) veya canShowNotifications() yanlış olduğunda bildirimi bastırır. Kanal bildirimleri gönderenin adıyla öneklenir.

Kanal Sohbeti: Lider Seçimi ve Dağıtım {#channel-chat-leader-election--fan-out}

Kanallar çok taraflıdır ancak sunucusuzdur. Her üyenin aynı mesajı N kez dağıtmasını önlemek için, gönderen taraf, katılan tüm üyelere yayma işi olan tek bir lider seçer.

Lider seçimi algoritması (DChatChannel.electLeader)

  1. Çevrimiçi olan katılmış üyelere filtre uygula (yerel cihaz her zaman çevrimiçi kabul edilir).
  2. Sahibi çevrimiçi katılmış üyeler arasındaysa → sahip liderdir.
  3. Aksi takdirde, lider en küçük clientId'ye sahip çevrimiçi katılmış üyedir (deterministik kriz çözümü, koordinasyon gerekmez).
  4. Çevrimiçi katılmış üye yoksa null döndürür.

Gönderme akışı

Diagram 5
5

Neden bir lider?

Herkesin diğer herkese yayın yaptığı 5 üyeli bir kanal düşünün: tek bir mesaj 20 ağ gidiş-dönüşü ve her üyede 4 yinelenen kopya üretir. Tek bir lider seçerek, yalnızca o cihaz dağıtımı yapar — gönderen ya da dağıtımı kendisi yapar (lider kendisiyse) ya da lidere tek bir kopya aktarır, daha sonra lider dağıtır.

Lider çevrimdışysa, gönderen Result.NoLeader'a düşer, eş yeniden keşfini tetikler (böylece liderin IP'si bulunabilir) ve kullanıcının yeniden denemesine izin vermek için durumu temizler.

Kanal anahtarı yönlendirme

Kanal mesajları, çift yönlü eş anahtarı yerine kanalın ChaCha20 anahtarıyla şifrelenir. Bu, diğer üyelerle yalnızca kanal üzerinden (hiçbir zaman birebir eşleştirilmemiş) tanışan bir üyenin mesajları almasına izin veren şeydir — peers satırı status="channel" ve key=""'dir. Gönderen c-cid HTTP başlığını kanal kimliğine ayarlar; alıcı çift yönlü anahtar yerine ChannelCacher.getKeyBytes(channelId)'yi arar.

Alıcı başına yeniden deneme

Her sendToMember bir DMessageDeliveryResult döndürür. Toplanan DMessageStatusData, sohbet öğesinin status_data JSON'u olarak kalıcı hale getirilir. UI «Alice, Bob'a teslim edildi; Carol için Başarısız» gösterir ve kullanıcının Carol için özel olarak Yeniden Dene'ye dokunmasına izin verir — ChatManager.sendToChannelMembers, yeniden deneme alt kümesi için sendToRecipients'i yeniden çalıştırır ve yeni sonuçları mevcut olanlarla birleştirir, yalnızca yeniden denenen eşleri değiştirir.

Kanal Sistem Mesajları {#channel-system-messages}

Kanal kontrol düzlemi mesajları (davet, kabul, red, güncelleme, kovma, ayrılma) eş GraphQL channelSystemMessage mutasyonu üzerinden alınır. Bunlar, bir type dizesiyle tiplenen JSON yükleridir:

TürYönİmzalı mı?Amaç
channel_inviteSahip → davetliEvetBir eşi davet et; kanal anahtarı + üyeleri taşır.
channel_invite_acceptDavetli → sahipHayırKabul; davetlinin ortak anahtarını taşır.
channel_invite_declineDavetli → sahipHayırRed; sahip üyeyi kaldırır.
channel_updateSahip → tüm üyelerEvetÜyelik/ad değişikliği yayını.
channel_kickSahip → kovulan eşEvetHedefli kovma; kanal silindiğinde de yayınlanır.
channel_leaveÜye → sahipHayırÜye tarafından başlatılan ayrılma bildirimi.

İmzalı yük biçimi

Üç imzalı tür (invite, update, kick), kurallı bir boruyla ayrılmış dize kullanır: "$channelId|$version|$action|$target", burada action, invite, update, kick'ten biridir ve target, davetli/kovulan eş kimliğidir (yayın kick'i için boş).

Sahip bu dizeyi Ed25519 anahtarıyla imzalar. Alıcılar, imzayı kontrol etmeden önce channel.owner != fromId olan herhangi bir mesajı reddeder ve version'ı yerel sürümden ≤ olan ChannelUpdate yüklerini reddeder (sıra dışı teslimata karşı eski sürüm koruması).

Diagram 6
6

Tembel eş doldurma

ChannelInvite ve ChannelUpdate, her üye için hafif eş bilgisi (id, ad, publicKey, deviceType, ip, port) olan bir memberPeers: List<MemberPeerInfo> listesi taşır. Alıcının ensureChannelPeer'i, daha önce hiç görmediği her üye için status="channel" ile bir DPeer satırı oluşturur. Bu, dağıtım yönlendirmesinin mesaj göndermek için her üyenin eş kaydına ihtiyaç duyması nedeniyle kritiktir.

Kanal Yaşam Döngüsü {#channel-lifecycle}

Diagram 7
7

Eş Taşıma Katmanı (LAN → Wi-Fi Aware → BLE) {#peer-transport-layer-lan--wi-fi-aware--ble}

PeerTransportRouter, devre kırma ile bir strateji zinciridir. Taşıma sıralı listesi:

  1. LanTransport — ilk seçim. HTTPS üzerinden ChaCha20 şifreleme interceptor'ü ile OkHttp kullanır. peer.ip boş olduğunda (henüz keşfetmediğimiz alt ağ ötesi eş) tamamen atlanır.
  2. WifiAwareTransport (yalnızca Android 13+) — Wi-Fi Aware (NAN) veri yollarını kullanır. Eşin awareRunning bayrağı yanlış olduğunda (BLE ön ısıtıcı taraması tarafından yenilenir) hızlı atla. Eşin IPv6'sı, ana bilgisayar adını plain-aware-peer'i bağ-yerel adrese eşleyen özel bir DNS ile çözülür.
  3. BleTransport — herhangi bir eşleştirilmiş eş için garanti edilen yedek. GATT üzerinden parçalı RPC akışları yapar. Daha yavaştır ancak herhangi bir IP bağlantısı olmadan çalışır.

Diagram 8
8

Neden bu sıra?

  • LAN en hızlıdır (tek HTTPS gidiş-dönüş, ~10 ms zaman aşımı).
  • Wi-Fi Aware orta (veri yolu kurulumu ~5 s, ardından ~10 ms gidiş-dönüşler) ve alt ağ ötesi çalışır (örneğin bir cihaz misafir Wi-Fi'de, diğeri IoT Wi-Fi'de). Eşin Aware hizmeti çalışmıyorsa, 10 s buildLink zaman aşımını önlemek için hızlı atlamaya ayarlanmıştır.
  • BLE en yavaştır ancak hiçbir IP bağlantısı olmadan çalışır — Wi-Fi olmasa bile, mesaj yine de geçer. Eşleştirilmiş eşler için garanti edilen yedek olarak kullanılır.

Devre kesici, güvenilmez bir taşımanın (özellikle ağ değişimi sırasında Wi-Fi Aware) 2 başarısızlıktan sonra 30 s atlanmasını sağlar, böylece yedekleme, yinelenen 10 s zaman aşımlarını beklemek yerine hızlı gerçekleşir.

Wi-Fi Aware el sıkışması

AwareSession, bir veri yolu açmadan önce iki mesajlık bir el sıkışma yapar:

  • MSG_HELLO (abone → yayıncı): «Seni görüyorum, işte benim eş tutamağım.»
  • MSG_READY (yayncı → abone): «Ağ belirticimi kaydettim, artık requestNetwork yapabilirsin.»

Bu, her iki tarafın connectivityManager.requestNetwork(...) çağrılarını Android çerçevesinin ~500 ms penceresi içinde senkronize eder. Abone, daha küçük clientId'ye sahip taraftır (deterministik rol bölünmesi — her iki taraf koordinasyon olmadan anlaşır) ve yeniden deneme döngüsüne sahiptir.

Eş Durumu ve Varlık {#peer-status--presence}

Varlık, uzun ömürlü WebSocket bağlantıları üzerinden izlenir. Her çiftin yalnızca bir tarafı soketi açar — TempData.clientId < peer.id deterministik kuralıyla karar verilir. Diğer taraf, /peer_status'ta gelen bağlantıyı kabul eder.

Diagram 9
9

PeerCacher.onlineMap, varlık için doğru kaynaktır. onlinePeerIds: StateFlow<Set<String>> olarak gösterilir, kanal lider seçimi (electLeader(onlinePeerIds, myId)) tarafından tüketilir.

Önbellekleme Katmanı {#caching-layer}

İki önbellek, veritabanı tablolarını bellekte yansıtır ve Compose'ın doğrudan topladığı StateFlow'ları gösterir:

Diagram 10
10

Neden kopyala-ve-yaz?

Kotlin'in MutableStateFlow.distinctUntilChanged, yapısal eşitlik kullanır. DPeer'i yerinde değiştirseydik, türetilmiş pairedPeers listesi, önce ve sonra aynı DPeer referansını içerirdi ve distinctUntilChanged fark görmez ve yayımı bastırırdı. Önce varlığı kopyalayarak, kopyayı değiştirerek ve harita girişini yeni bir PeerRuntime/ChannelRuntime ile değiştirerek, türetilmiş liste yeni referanslerin yeni bir listesini alır ve akış ateşlenir.

Dosya İndirmeleri {#file-downloads}

Gelen dosya/resim mesajları, sınırlı bir worker havuzu tarafından otomatik olarak indirilir. Her indirme, kullanılabilir herhangi bir taşımadan (PeerTransportRouter.downloadFile) akış yapar ve geçici bir dosyaya yazar, ardından uygulamanın medya deposuna içe aktarır ve sohbet öğesinin uri alanını yamalar.

Diagram 11
11

Taşıma agnostik akış

DownloadedResponse(status, ByteReadChannel, onClose): AutoCloseable soyutlaması, LAN ve Wi-Fi Aware'in canlı HTTP gövdesini akışını sağlarken, BLE aynı ByteReadChannel üzerinden parçalı RPC'yi (16 KiB parçalar, GET /fs?id=…&offset=…&length=… aracılığıyla) akışını sağlar. onClose callback'i, tüketici yanıtı erken kapattığında (örneğin duraklatmada) BLE'nin arka plan indirme coroutine'ini iptal etmesine izin verir.

Tasarım Desenleri Özeti {#design-patterns-recap}

DesenNeredeNeden
FaçadeChatManagerTek giriş noktası; çağıranlar asla DB/taşımaya doğrudan dokunmaz.
Strateji + Sorumluluk ZinciriPeerTransportRouter + LanTransport/WifiAwareTransport/BleTransportDüşüş sinyali olarak TransportUnavailable ile takılabilir taşımalar.
Devre KesiciPeerCircuitBreaker2 başarısızlık / 30 s, bir (eş, taşıma) bacağını açar, böylece Wi-Fi Aware yedeklemeyi engellemez.
Durum MakinesiPeerStatusManager.PeerState, AwarePeerLink.LinkStateSoket yaşam döngüsü ve NDP bağlantı yaşam döngüsü için açık geçişler.
Üretici/Tüketici + HavuzDownloadQueue (3 worker, Channel.BUFFERED)Dosya indirmeleri için sınırlı eşzamanlılık.
Gözlemci / ReaktifHer yerde StateFlowCompose doğrudan toplar; manuel yenileme yok.
Tekrar KorumasıChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MSLAN+BLE çift teslimatten yinelenenleri bırak; pencere dışı zaman damgalarını reddet.
Üstel Geri ÇekilmePeerStatusManager.scheduleReconnectmin(60 s, 1 s × 2^min(n-1, 6)) — 64 s'de sınırlar.
Kopyala-ve-YazPeerCacher.mutatePeer, ChannelCacher.mutateChannelHer mutasyonda StateFlow.distinctUntilChanged'i ateşlemeye zorlar.
İmzalı ZarfPeerGraphQLClient.buildSignedRequestsignature|timestamp|body — zaman damgasını gövdeye bağlar, tekrarı önler.
Deterministik Rol BölünmesiTempData.clientId < peer.idWebSocket istemci ve sunucu, Wi-Fi Aware abone ve yayıncı arasında karar verir.
Tembel DoldurmaDavet/güncelleme üzerine ensureChannelPeerDağıtım yönlendirmesi çalışsın diye görülmemiş kanal üyeleri için peers satırları oluşturur.
Şifreli KimlikLANDiscoverManager.discoverSpecificDeviceYönlendirilmiş DISCOVER, hedef kimliği eş anahtarıyla şifreler — yalnızca hedef tanır.

Daha Fazla Okuma {#further-reading}

  • Eşleştirme Akışı — iki cihazın nasıl güven kurduğu ve bu makaledeki her taşıma tarafından kullanılan paylaşılan ChaCha20 anahtarını nasıl değiştirdiği.
  • apitest/groups/chat-messages.sh ve apitest/groups/chat-channels.sh — her GraphQL mutasyonunu uçtan uca çalıştıran yürütülebilir test planı.