İçindekiler
- Yüksek Düzeyli Mimari
- Veri Modeli
- GraphQL API Yüzeyi
- Eş Sohbeti: Mesaj Gönderme
- Eş Sohbeti: Mesaj Alma
- Kanal Sohbeti: Lider Seçimi ve Dağıtım
- Kanal Sistem Mesajları
- Kanal Yaşam Döngüsü
- Eş Taşıma Katmanı (LAN → Wi-Fi Aware → BLE)
- Eş Durumu ve Varlık
- Önbellekleme Katmanı
- Dosya İndirmeleri
- Tasarım Desenleri Özeti
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ür | Sabit | Açıklama |
|---|---|---|
PEER | ChatTargetType.PEER | İki eşleştirilmiş cihaz arasında birebir doğrudan sohbet. |
CHANNEL | ChatTargetType.CHANNEL | Bir 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ı
Mimari kasıtlı olarak katmanlıdır:
- UI / GraphQL giriş noktaları, asla taşımalara veya DB'ye doğrudan dokunmaz.
ChatManagerbir façadedir — her çağırıcı (UI, GraphQL resolver, eş alıcı) ondan geçer.ChatSender,ChatTargetType'a göre dallanan ve eş veya kanal göndericilerine devreden bir dağıtıcıdır.- 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:
| Tablo | Varlık | Amaç |
|---|---|---|
chats | DChat | Mesaj başına bir satır (metin / resim / dosya). |
chat_channels | DChatChannel | Grup kanalı başına bir satır. |
peers | DPeer | Bilinen her cihaz için bir satır (eşleştirilmiş veya yalnızca kanal). |
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ınclientId'si kararlı olmadan önce sahip gibi davranmasına izin veren bir bekçidir;isOwnedByMe()hem"me"hem deTempData.clientId'yi kabul eder.
GraphQL API Yüzeyi {#graphql-api-surface}
PlainApp iki GraphQL şeması sunar:
- Web GraphQL (
addChatChannelSchema+addChatMessageSchema) — yerel Ktor sunucusu tarafından tarayıcı UI'sine veapitest/harness'ine sunulur. ChaCha20 ile şifrelenmiş bir belirteçle doğrulanır. - 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:
Her atlama uygulanan anahtar değişmezler:
ChatManager.createChatItemher 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.PeerGraphQLClient.buildSignedRequest,signature|timestamp|requestJsonbiç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.PeerTransportRouter.send, taşımalarıLan → WifiAware → Blesırasıyla yineler. Her taşıma, yönlendiricinin bir sonrakini denemesine izin vermek içinTransportUnavailablefırlatabilir.- Alma tarafında,
PeerChatParser.decrypt, zaman damgasının±5 dakikaiçinde olup olmadığını kontrol eder ve GraphQL mutasyonu çalıştırılmadan önce Ed25519 imzasını doğrular. ChatMessageReceiver.receive,"$fromPeerId|$signature|$timestamp"ile anahtarlanan birseenSignatureskümesi tutar ve yinelenenlerdeReplayedMessageExceptionfı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:
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)
- Çevrimiçi olan katılmış üyelere filtre uygula (yerel cihaz her zaman çevrimiçi kabul edilir).
- Sahibi çevrimiçi katılmış üyeler arasındaysa → sahip liderdir.
- Aksi takdirde, lider en küçük
clientId'ye sahip çevrimiçi katılmış üyedir (deterministik kriz çözümü, koordinasyon gerekmez). - Çevrimiçi katılmış üye yoksa
nulldöndürür.
Gönderme akışı
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ür | Yön | İmzalı mı? | Amaç |
|---|---|---|---|
channel_invite | Sahip → davetli | Evet | Bir eşi davet et; kanal anahtarı + üyeleri taşır. |
channel_invite_accept | Davetli → sahip | Hayır | Kabul; davetlinin ortak anahtarını taşır. |
channel_invite_decline | Davetli → sahip | Hayır | Red; sahip üyeyi kaldırır. |
channel_update | Sahip → tüm üyeler | Evet | Üyelik/ad değişikliği yayını. |
channel_kick | Sahip → kovulan eş | Evet | Hedefli kovma; kanal silindiğinde de yayınlanır. |
channel_leave | Üye → sahip | Hayı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ı).
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}
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:
LanTransport— ilk seçim. HTTPS üzerinden ChaCha20 şifreleme interceptor'ü ile OkHttp kullanır.peer.ipboş olduğunda (henüz keşfetmediğimiz alt ağ ötesi eş) tamamen atlanır.WifiAwareTransport(yalnızca Android 13+) — Wi-Fi Aware (NAN) veri yollarını kullanır. EşinawareRunningbayrağı 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.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.
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
buildLinkzaman 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ıkrequestNetworkyapabilirsin.»
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.
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:
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.
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}
| Desen | Nerede | Neden |
|---|---|---|
| Façade | ChatManager | Tek giriş noktası; çağıranlar asla DB/taşımaya doğrudan dokunmaz. |
| Strateji + Sorumluluk Zinciri | PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransport | Düşüş sinyali olarak TransportUnavailable ile takılabilir taşımalar. |
| Devre Kesici | PeerCircuitBreaker | 2 başarısızlık / 30 s, bir (eş, taşıma) bacağını açar, böylece Wi-Fi Aware yedeklemeyi engellemez. |
| Durum Makinesi | PeerStatusManager.PeerState, AwarePeerLink.LinkState | Soket yaşam döngüsü ve NDP bağlantı yaşam döngüsü için açık geçişler. |
| Üretici/Tüketici + Havuz | DownloadQueue (3 worker, Channel.BUFFERED) | Dosya indirmeleri için sınırlı eşzamanlılık. |
| Gözlemci / Reaktif | Her yerde StateFlow | Compose doğrudan toplar; manuel yenileme yok. |
| Tekrar Koruması | ChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MS | LAN+BLE çift teslimatten yinelenenleri bırak; pencere dışı zaman damgalarını reddet. |
| Üstel Geri Çekilme | PeerStatusManager.scheduleReconnect | min(60 s, 1 s × 2^min(n-1, 6)) — 64 s'de sınırlar. |
| Kopyala-ve-Yaz | PeerCacher.mutatePeer, ChannelCacher.mutateChannel | Her mutasyonda StateFlow.distinctUntilChanged'i ateşlemeye zorlar. |
| İmzalı Zarf | PeerGraphQLClient.buildSignedRequest | signature|timestamp|body — zaman damgasını gövdeye bağlar, tekrarı önler. |
| Deterministik Rol Bölünmesi | TempData.clientId < peer.id | WebSocket istemci ve sunucu, Wi-Fi Aware abone ve yayıncı arasında karar verir. |
| Tembel Doldurma | Davet/güncelleme üzerine ensureChannelPeer | Dağıtım yönlendirmesi çalışsın diye görülmemiş kanal üyeleri için peers satırları oluşturur. |
| Şifreli Kimlik | LANDiscoverManager.discoverSpecificDevice | Yö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.shveapitest/groups/chat-channels.sh— her GraphQL mutasyonunu uçtan uca çalıştıran yürütülebilir test planı.