# C1nk iOS — zadanie pre vývoj

Verzia 1.0 · 3. august 2026 · MiO

---

## 1. Čo staviame

Mobilná aplikácia, ktorá nahrádza domáci telefón. Keď niekto zazvoní pri vchode, na telefóne zazvoní hovor ako bežný telefonát — aj keď je aplikácia zabitá. Používateľ zdvihne, vidí a počuje návštevníka, hovorí s ním a jedným ťuknutím otvorí dvere.

**Kľúčové rozhodnutie architektúry:** aplikácia **neobsahuje SIP klienta**. Žiadny Linphone, žiadna SIP registrácia, žiadny PJSIP. Aplikácia hovorí len WebRTC cez WebSocket s naším serverom. Server je online nepretržite, takže odpadá celá trieda problémov so spánkom aplikácie na iOS.

---

## 2. Testovacie prostredie (funguje teraz)

| Čo | Kde |
|---|---|
| Simulátor 2N zvončeka | `https://2n.c1nk.eu` |
| Signalizačný server | `wss://sig.c1nk.eu/ws` |
| Stav servera | `https://sig.c1nk.eu/health` |

**Ako testovať:** otvor `2n.c1nk.eu` na notebooku, klikni **Spustiť zariadenie** (povolí kameru a mikrofón), potom **ZAZVONIŤ**. Na telefóne má zazvoniť hovor.

---

## 3. Technický základ

### 3.1 Minimálne požiadavky
- iOS 15.0+
- Swift 5.9+, SwiftUI (UIKit len tam, kde je nutný)
- Xcode 15+

### 3.2 Závislosti (Swift Package Manager)

```
https://github.com/stasel/WebRTC.git        // WebRTC.xcframework, branch: latest
```

Žiadne ďalšie tretie strany. PushKit, CallKit, AVFoundation sú systémové.

### 3.3 Capabilities v Xcode

Zapnúť v **Signing & Capabilities**:
- **Push Notifications**
- **Background Modes** → zaškrtnúť: `Voice over IP`, `Audio, AirPlay, and Picture in Picture`, `Remote notifications`
- **Personal VPN** — nie
- App Group (voliteľné, pre neskorší widget)

### 3.4 Info.plist

```xml
<key>NSCameraUsageDescription</key>
<string>C1nk potrebuje kameru na video hovor s návštevníkom.</string>

<key>NSMicrophoneUsageDescription</key>
<string>C1nk potrebuje mikrofón, aby ste sa mohli rozprávať s návštevníkom pri dverách.</string>

<key>NSLocationWhenInUseUsageDescription</key>
<string>Polohu používame na overenie, že ste doma, pri prvom pripojení zariadenia.</string>

<key>UIBackgroundModes</key>
<array>
  <string>voip</string>
  <string>audio</string>
  <string>remote-notification</string>
</array>
```

---

## 4. Signalizačný protokol

WebSocket na `wss://sig.c1nk.eu/ws`. Všetky správy sú JSON s poľom `type`.

### 4.1 Odosielané aplikáciou

**Prihlásenie po štarte**
```json
{
  "type": "hello",
  "role": "app",
  "deviceId": "<UUID zariadenia, perzistentné v Keychain>",
  "voipToken": "<PushKit token, hex string>",
  "bundleId": "eu.c1nk.app"
}
```

**Aktualizácia push tokenu** (keď sa token zmení)
```json
{ "type": "register-token", "deviceId": "...", "voipToken": "...", "bundleId": "eu.c1nk.app" }
```

**Pripojenie ku konkrétnemu hovoru** (po prijatí VoIP pushu)
```json
{ "type": "join", "callId": "<z push payloadu>" }
```

**WebRTC odpoveď**
```json
{ "type": "answer", "callId": "...", "sdp": "<SDP string>" }
```

**ICE kandidát**
```json
{
  "type": "ice",
  "callId": "...",
  "candidate": { "candidate": "...", "sdpMid": "0", "sdpMLineIndex": 0 }
}
```

**Používateľ zdvihol**
```json
{ "type": "accept", "callId": "..." }
```

**Používateľ odmietol**
```json
{ "type": "reject", "callId": "..." }
```

**Zavesenie**
```json
{ "type": "hangup", "callId": "..." }
```

**Otvorenie dverí**
```json
{ "type": "open-door", "callId": "..." }
```

**Udržanie spojenia** — každých 25 s
```json
{ "type": "ping" }
```

### 4.2 Prijímané aplikáciou

| `type` | Kedy | Čo s tým |
|---|---|---|
| `welcome` | hneď po pripojení | nič, len log |
| `hello-ok` | po `hello` | ulož `deviceId` ak ho server vygeneroval |
| `token-ok` | po `register-token` | log |
| `incoming` | zvonenie, keď je appka už pripojená | ak ešte nebeží CallKit pre toto `callId`, spusti ho |
| `join-ok` | po `join` | čakaj na `offer` |
| `offer` | server posiela ponuku od dverí | nastav remote description, vytvor `answer` |
| `ice` | ICE kandidát od dverí | pridaj do peer connection |
| `call-end` | hovor skončil | ukonči CallKit, uprac peer connection |
| `door-open-ok` | dvere otvorené | krátka spätná väzba v UI |
| `error` | chyba | log, prípadne ukonči hovor |
| `pong` | odpoveď na ping | reset watchdogu |

---

## 5. Tok hovoru — presné poradie

Toto poradie je záväzné. Zámena krokov 1 a 2 znamená, že Apple aplikáciu ukončí.

```
1. Príde VoIP push (PushKit)
2. OKAMŽITE reportNewIncomingCall — pred čímkoľvek iným
3. Otvor WebSocket na sig.c1nk.eu
4. Pošli hello + join(callId)
5. Príde offer → vytvor RTCPeerConnection, nastav remote, vytvor answer
6. Pošli answer, vymieňaj ICE kandidátov
7. Používateľ ťukne Prijať v CallKite
8. Aktivuj audio session, pošli accept
9. Hovor beží — video z dverí, audio obojsmerne
10. Používateľ ťukne Otvoriť dvere → pošli open-door
11. Zavesenie → hangup + upratanie
```

### 5.1 Kritické pravidlo Apple

> Po prijatí VoIP pushu **musí** aplikácia zavolať `reportNewIncomingCall` **v tom istom volaní** `didReceiveIncomingPushWith`. Nie po sieťovej operácii. Nie v `Task { }`. Okamžite, synchrónne.

Ak to appka neurobí, iOS ju ukončí. Po niekoľkých porušeniach Apple úplne odoberie právo na VoIP pushe — a tým je produkt mŕtvy.

**Správne:**
```swift
func pushRegistry(_ registry: PKPushRegistry,
                  didReceiveIncomingPushWith payload: PKPushPayload,
                  for type: PKPushType,
                  completion: @escaping () -> Void) {

    let callId = payload.dictionaryPayload["callId"] as? String ?? UUID().uuidString
    let doorName = payload.dictionaryPayload["doorName"] as? String ?? "Vchod"

    // KROK 1 — okamzite, ziadne await pred tymto
    let update = CXCallUpdate()
    update.remoteHandle = CXHandle(type: .generic, value: doorName)
    update.hasVideo = true
    update.supportsDTMF = false
    update.supportsHolding = false
    update.supportsGrouping = false
    update.supportsUngrouping = false

    let uuid = UUID()
    CallManager.shared.map(uuid: uuid, callId: callId)

    provider.reportNewIncomingCall(with: uuid, update: update) { error in
        // KROK 2 — az teraz siet
        SignalClient.shared.connectAndJoin(callId: callId)
        completion()
    }
}
```

---

## 6. Architektúra aplikácie

```
C1nkApp
├── App/
│   ├── C1nkApp.swift              @main, scene setup
│   └── AppDelegate.swift          PushKit registrácia
├── Core/
│   ├── SignalClient.swift         WebSocket, reconnect, protokol
│   ├── WebRTCClient.swift         RTCPeerConnection, media
│   ├── CallManager.swift          CallKit provider + controller
│   ├── DeviceIdentity.swift       UUID v Keychain
│   └── Presence.swift             GPS + WiFi BSSID (fáza 2)
├── Features/
│   ├── Call/
│   │   ├── CallView.swift         video, tlačidlá, otvorenie
│   │   └── CallViewModel.swift
│   ├── Home/
│   │   ├── HomeView.swift         stav, história
│   │   └── HomeViewModel.swift
│   └── Settings/
│       └── SettingsView.swift
└── Resources/
    └── Assets.xcassets
```

---

## 7. Kľúčové triedy — čo majú robiť

### 7.1 `SignalClient`

- Singleton
- `URLSessionWebSocketTask`, nie tretia strana
- Automatický reconnect s exponenciálnym backoffom: 1 s, 2 s, 4 s, 8 s, max 30 s
- Ping každých 25 s, watchdog na 40 s bez odpovede → reconnect
- Publikuje udalosti cez Combine `PassthroughSubject<SignalEvent, Never>`
- Fronta správ, ak socket ešte nie je otvorený (dôležité — `join` môže prísť skôr než sa socket otvorí)

```swift
enum SignalEvent {
    case connected
    case disconnected
    case incoming(callId: String, doorName: String)
    case offer(callId: String, sdp: String)
    case ice(callId: String, candidate: RTCIceCandidate)
    case callEnded(callId: String, reason: String)
    case doorOpened(callId: String)
}
```

### 7.2 `WebRTCClient`

- `RTCPeerConnectionFactory` vytvor **raz**, drž staticky
- ICE servery: `stun:stun.l.google.com:19302`, `stun:stun1.l.google.com:19302`
- Neskôr pridáme vlastný TURN — bez neho hovor zlyhá na ~15 % mobilných sietí (symetrický NAT)
- Aplikácia posiela **len audio**, prijíma **audio + video**
- Video renderuj cez `RTCMTLVideoView` (Metal, nie OpenGL)

```swift
let config = RTCConfiguration()
config.iceServers = [RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"])]
config.sdpSemantics = .unifiedPlan
config.continualGatheringPolicy = .gatherContinually
```

**Audio session — dôležité:** nepreberaj kontrolu nad audio session sám. CallKit ju nastaví. V `didActivate audioSession` len povoľ audio v WebRTC:

```swift
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
    RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
    RTCAudioSession.sharedInstance().isAudioEnabled = true
}

func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
    RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession)
    RTCAudioSession.sharedInstance().isAudioEnabled = false
}
```

Pred aktiváciou nastav kategóriu:
```swift
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.lockForConfiguration()
try? rtcSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .defaultToSpeaker])
rtcSession.unlockForConfiguration()
```

### 7.3 `CallManager`

- `CXProvider` + `CXCallController`
- Konfigurácia providera:

```swift
let cfg = CXProviderConfiguration()
cfg.supportsVideo = true
cfg.maximumCallsPerCallGroup = 1
cfg.maximumCallGroups = 1
cfg.supportedHandleTypes = [.generic]
cfg.iconTemplateImageData = UIImage(named: "CallKitIcon")?.pngData()
cfg.ringtoneSound = "doorbell.caf"   // voliteľné
```

- Mapa `UUID (CallKit) ↔ callId (server)` — obojsmerná, thread-safe
- `CXAnswerCallAction` → pošli `accept`, potom `action.fulfill()`
- `CXEndCallAction` → pošli `reject` alebo `hangup` podľa stavu, potom `action.fulfill()`

### 7.4 `DeviceIdentity`

- `UUID` uložené v **Keychain**, nie UserDefaults (prežije reinštaláciu, ak treba)
- Kľúč: `eu.c1nk.deviceId`

---

## 8. Push notifikácie — nastavenie

### 8.1 Čo treba vytvoriť v Apple Developer účte

1. **App ID** s povoleným Push Notifications
2. **APNs Auth Key** (`.p8` súbor) — Keys → nový kľúč s APNs
3. Poznač si: **Key ID**, **Team ID**, **Bundle ID**

### 8.2 Čo mi pošleš na server

| Údaj | Príklad |
|---|---|
| `.p8` súbor | `AuthKey_ABC123XYZ.p8` |
| Key ID | `ABC123XYZ` |
| Team ID | `DEF456UVW` |
| Bundle ID | `eu.c1nk.app` |

Nahrám do `/opt/c1nk-signal/config.json` a push začne fungovať okamžite. **Dovtedy funguje len fallback** — appka musí byť otvorená a pripojená na WebSocket.

**Dôležité k VoIP topicu:** APNs topic pre VoIP push je `<bundleId>.voip`, teda `eu.c1nk.app.voip`. Server to už rieši automaticky.

### 8.3 PushKit registrácia

```swift
func registerForVoIPPushes() {
    let registry = PKPushRegistry(queue: .main)
    registry.delegate = self
    registry.desiredPushTypes = [.voIP]
}

func pushRegistry(_ registry: PKPushRegistry,
                  didUpdate credentials: PKPushCredentials,
                  for type: PKPushType) {
    let token = credentials.token.map { String(format: "%02x", $0) }.joined()
    SignalClient.shared.registerToken(token)
}
```

---

## 9. Vizuálny jazyk

Aplikácia má vyzerať ako pokračovanie panela pri dverách. Ten istý svet, tá istá reč — len v ruke. Kto stlačí zvonček dole a potom pozrie na telefón, nemá cítiť, že sú to dva rôzne produkty.

Referencia: `https://2n.c1nk.eu` (simulátor panela). Otvor si ho a drž sa ho.

### 9.1 Princíp

Tmavé sklo, zelený akcent, veľa priestoru, málo prvkov. Žiadne karty s tieňmi, žiadne farebné plochy, žiadne dekorácie. Obsah stojí na typografii a na tichu okolo nej.

### 9.2 Farby

Vytvor `Color+C1nk.swift` s týmito hodnotami. Aplikácia je **len v tmavom režime** — v `Info.plist` nastav `UIUserInterfaceStyle` na `Dark`.

```swift
extension Color {
    static let c1Panel     = Color(hex: 0x0A0C10)   // pozadie obrazoviek
    static let c1Surface   = Color(hex: 0x0E1116)   // vyvýšené plochy
    static let c1Surface2  = Color(hex: 0x13171E)   // vnorené plochy
    static let c1Line      = Color.white.opacity(0.14)
    static let c1Text      = Color(hex: 0xF2F5F9)
    static let c1TextDim   = Color(hex: 0x8B94A3)
    static let c1Green     = Color(hex: 0x2AE98D)   // akcent, hovor, potvrdenie
    static let c1GreenDim  = Color(hex: 0x1FBF73)
    static let c1Danger    = Color(hex: 0xF05A5A)   // zavesiť, odmietnuť
}
```

Zelená je jediná akcentová farba. Používaj ju striedmo — na jednu hlavnú akciu na obrazovke. Ak sú na obrazovke dve zelené veci, jedna z nich je zbytočná.

### 9.3 Typografia

Panel používa Montserrat. Aplikácia použije **SF Pro** (systémové) — na iOS pôsobí prirodzenejšie a šetrí veľkosť buildu. Charakter držíme cez váhy a rozpal písmen, nie cez rodinu.

```swift
enum C1Font {
    static let display  = Font.system(size: 28, weight: .bold)      // meno volajúceho
    static let title    = Font.system(size: 20, weight: .semibold)
    static let body     = Font.system(size: 16, weight: .medium)
    static let label    = Font.system(size: 14, weight: .medium)
    static let caption  = Font.system(size: 13, weight: .medium)    // popisky, stav
    static let overline = Font.system(size: 12, weight: .semibold)  // s trackingom 1.2
}
```

Nadpisy sekcií píš verzálkami s rozpalom (`.tracking(1.2)`) a v `c1TextDim` — tak ako "BYTOVÝ DOM · HLAVNÁ 25" na paneli.

### 9.4 Tvary a rozostupy

- Rohy: **10 pt** na tlačidlách a plochách, **kruh** len na avataroch a ikonových tlačidlách
- Základná mriežka: násobky 4 pt, bežné odsadenie od okraja **24 pt**
- Oddeľovače: 1 px `c1Line`, zarovnané s obsahom
- Žiadne tiene. Hĺbku rob rozdielom pozadia, nie tieňom.

### 9.5 Zoznamový riadok

Panel má riadok: meno vľavo, zelené slúchadlo vpravo, tenká linka pod ním. Aplikácia to prevezme pre históriu zvonení aj pre zoznam dverí.

```swift
struct C1Row<Trailing: View>: View {
    let title: String
    var subtitle: String? = nil
    @ViewBuilder var trailing: () -> Trailing

    var body: some View {
        VStack(spacing: 0) {
            HStack(spacing: 12) {
                VStack(alignment: .leading, spacing: 2) {
                    Text(title)
                        .font(C1Font.body)
                        .foregroundStyle(Color.c1Text)
                    if let subtitle {
                        Text(subtitle)
                            .font(C1Font.caption)
                            .foregroundStyle(Color.c1TextDim)
                    }
                }
                Spacer()
                trailing()
            }
            .padding(.vertical, 16)

            Rectangle()
                .fill(Color.c1Line)
                .frame(height: 1)
        }
    }
}
```

### 9.6 Tlačidlá

**Hlavná akcia** (Otvoriť dvere) — plná zelená, tmavý text, výška 60 pt, plná šírka.
**Deštruktívna** (Zavesiť, Odmietnuť) — plná `c1Danger`, biely text.
**Vedľajšia** (Stlmiť, Reproduktor) — kruh 56 pt, výplň `c1Surface2`, ikona `c1Text`.

Každé tlačidlo pri stlačení: `scaleEffect(0.97)` s pružinou 0.18 s. Bez toho pôsobí aplikácia mŕtvo.

### 9.7 Odozva — zvuk a haptika

Panel má na každý dotyk zvuk aj vibráciu. Aplikácia musí mať to isté, inak pôsobí lacnejšie než krabica na stene.

| Udalosť | Haptika | Zvuk |
|---|---|---|
| Ťuknutie na tlačidlo | `.impact(.light)` | systémový klik |
| Prijatie hovoru | `.impact(.medium)` | rieši CallKit |
| Otvorenie dverí | `.notification(.success)` | stúpajúca trojtónová melódia |
| Odmietnutie, zavesenie | `.impact(.rigid)` | klesajúci tón |
| Chyba spojenia | `.notification(.error)` | žiadny |

```swift
enum Haptic {
    private static let light = UIImpactFeedbackGenerator(style: .light)
    private static let med   = UIImpactFeedbackGenerator(style: .medium)
    private static let notif = UINotificationFeedbackGenerator()

    static func prepare() { light.prepare(); med.prepare(); notif.prepare() }
    static func tap()     { light.impactOccurred() }
    static func medium()  { med.impactOccurred() }
    static func success() { notif.notificationOccurred(.success) }
    static func error()   { notif.notificationOccurred(.error) }
}
```

Zavolaj `Haptic.prepare()` pri zobrazení obrazovky, inak má haptika badateľné oneskorenie.

### 9.8 Pohyb

- Prechody obrazoviek: 0,28 s, `.easeOut`
- Pulzovanie pri zvonení: rovnaké ako na paneli — kruh sa rozpína od 110 pt do 240 pt, priehľadnosť 0,8 → 0, cyklus 2 s, druhý kruh oneskorený o 1 s
- Rešpektuj `.accessibilityReduceMotion` — pri zapnutom nahraď pulzovanie statickým kruhom

### 9.9 Ikony

**Len SF Symbols**, váha `.medium`, veľkosť 20 pt v riadkoch, 26 pt v tlačidlách.

| Význam | Symbol |
|---|---|
| Hovor | `phone.fill` |
| Zavesiť | `phone.down.fill` |
| Otvoriť dvere | `lock.open.fill` |
| Zamknuté | `lock.fill` |
| Zvonenie | `bell.badge.fill` |
| Mikrofón vypnutý | `mic.slash.fill` |
| Reproduktor | `speaker.wave.2.fill` |
| História | `clock.arrow.circlepath` |
| Nastavenia | `gearshape.fill` |

---

## 10. Obrazovky

### 10.1 Domov (`HomeView`)

```
┌─────────────────────────┐
│                         │
│  BYTOVÝ DOM        ● on │  overline dim, stav vpravo
│  Hlavná 25              │  title
│                         │
│  ─────────────────────  │
│  Hlavný vchod       🔓  │  riadok s otvorením
│  ─────────────────────  │
│                         │
│  POSLEDNÉ ZVONENIA      │  overline dim
│  ─────────────────────  │
│  Dnes 14:32             │
│  Hlavný vchod · prijaté │
│  ─────────────────────  │
│  Včera 09:11            │
│  Hlavný vchod · zmeškané│
│  ─────────────────────  │
│                         │
└─────────────────────────┘
```

Stav pripojenia je **malá bodka**, nie banner. Zelená = pripojené, sivá = nie. Bez textu, kým sa na ňu neťukne.

### 10.2 Hovor (`CallView`)

Plná obrazovka. Video vypĺňa celú plochu, ovládanie pláva nad ním.

```
┌─────────────────────────┐
│                         │
│                         │
│    VIDEO Z KAMERY       │  RTCMTLVideoView, celá plocha
│                         │
│  HLAVNÝ VCHOD           │  overline, overlay hore vľavo
│                         │
│                         │
│                         │
│   ┌─────────────────┐   │
│   │  OTVORIŤ DVERE  │   │  zelené, 60 pt, plná šírka
│   └─────────────────┘   │
│                         │
│    ( )    ( )    ( )    │  kruhy 56 pt
│   stlmiť  hlas   koniec │
└─────────────────────────┘
```

- Nad ovládaním daj gradient `Color.black.opacity(0.65)` → `.clear` smerom hore, inak sú tlačidlá nad svetlým videom nečitateľné
- Ak video nepríde do 3 s: nahraď plochu `c1Panel` s ikonou `house.fill` v `c1TextDim` a textom "Bez obrazu". Audio pokračuje.
- Po ťuknutí na Otvoriť dvere: tlačidlo na 2,5 s zmení text na "ODOMKNUTÉ", ikonu na `lock.open.fill`, pridá haptiku `.success`. Potom sa vráti.

### 10.3 Prichádzajúci hovor

Toto **nekreslíme** — rieši CallKit systémovou obrazovkou. Naša `CallView` sa zobrazí až po prijatí.

Do CallKit konfigurácie dodaj ikonu: `iconTemplateImageData` — 40×40 pt, jednofarebná, priehľadné pozadie. Systém ju prefarbí sám.

### 10.4 Nastavenia (`SettingsView`)

Zoznamové riadky rovnakého tvaru ako všade. Sekcie oddelené overline nadpismi.

- ZARIADENIE — identifikátor (skrátený), stav push notifikácií
- DOMÁCNOSŤ — adresa, členovia (fáza 3)
- Odhlásiť — `c1Danger`, samostatne dole

---

## 11. Riešenie hraničných prípadov

| Situácia | Očakávané správanie |
|---|---|
| Appka zabitá, príde push | PushKit prebudí appku, CallKit zazvoní |
| Žiadna sieť pri prijatí pushu | CallKit zvoní, po 10 s bez WebRTC ukonči s hláškou „Bez pripojenia" |
| Návštevník odíde počas zvonenia | Server pošle `call-end`, appka ukončí CallKit |
| Používateľ odmietne | Pošli `reject`, ukonči CallKit, zapíš do histórie |
| Prichádzajúci GSM hovor počas C1nk hovoru | CallKit to rieši sám, C1nk hovor sa podrží |
| Dva pushe pre ten istý `callId` | Ignoruj druhý — drž `Set<String>` už spracovaných callId |
| Push príde, ale appka je v popredí | Rovnaký tok, CallKit sa zobrazí ako banner |
| WebSocket spadne počas hovoru | Reconnect, `join` s tým istým `callId`, ICE restart |

---

## 12. Fázy

**Fáza 1 — jadro (prvý cieľ)**
- WebSocket klient, protokol
- WebRTC audio + príjem videa
- CallKit, PushKit
- Obrazovka hovoru s otvorením dverí

Výsledok: zazvoním na `2n.c1nk.eu`, na telefóne zazvoní, zdvihnem, hovorím, otvorím.

**Fáza 2 — použiteľnosť**
- História zvonení
- Tlačidlo na zamknutej ploche (interaktívny widget, iOS 17+)
- Face ID pri otváraní dverí zo zamknutej plochy
- GPS + WiFi BSSID overenie prítomnosti

**Fáza 3 — produkt**
- Prihlásenie cez SMS OTP
- Viac dverí, viac bytov
- Dočasné prístupy pre návštevy
- Pozvánky členov domácnosti

---

## 13. Na čo si dať pozor

**TURN server chýba.** Zatiaľ len STUN. Na mobilných sieťach so symetrickým NAT-om spojenie zlyhá. Ak počas testov na LTE hovor nenadviaže, nie je to chyba kódu — treba postaviť coturn. Ozvi sa, nasadím ho.

**Simulátor v Xcode nemá kameru ani PushKit.** Testuj na fyzickom zariadení.

**VoIP push nefunguje v Xcode debug builde spoľahlivo.** Pre reálne testovanie použi TestFlight alebo aspoň build cez Release konfiguráciu.

**Nepoužívaj `localStorage` ekvivalenty pre callId.** Stav hovoru drž v pamäti, po reštarte je hovor aj tak neplatný.

---

## 14. Kontrolný zoznam pred prvým testom

- [ ] SPM závislosť `stasel/WebRTC` pridaná
- [ ] Background Modes: voip + audio + remote-notification
- [ ] Push Notifications capability
- [ ] Camera + Microphone usage descriptions v Info.plist
- [ ] APNs `.p8` kľúč odovzdaný na server
- [ ] Bundle ID sa zhoduje s tým na serveri
- [ ] Testované na fyzickom zariadení, nie simulátore
- [ ] `reportNewIncomingCall` je prvý riadok v push handleri
- [ ] Farby a fonty podľa sekcie 9, tmavý režim vynútený v Info.plist
- [ ] Otvorený https://2n.c1nk.eu ako vizuálna referencia
