feat(app): 1:1 voice and video calls
End-to-end encrypted calls between two contacts over WebRTC. Call signalling (offer, answer, hang-up) travels as ordinary Signal-encrypted messages through the relay; media is DTLS-SRTP, peer to peer when possible and through the operator's TURN server otherwise. - Non-trickle ICE (candidates gathered for up to 3 s), 45 s ring timeout, busy handling, and a call log entry in the conversation. - The system call UI rings even when the app is closed: CallKit from a PushKit VoIP push on iOS, a full-screen incoming call from an FCM wake-up on Android. The wake-up carries no caller; the app learns who is calling from the queued offer once it is unlocked. - New permissions: camera for video, audio routing and Bluetooth headsets, network state; iOS background modes audio and voip. Push on Android: a fresh install now picks UnifiedPush when a distributor is installed and FCM otherwise, instead of leaving wake-ups off. Without a wake-up Android puts the app to sleep and messages wait until it is opened. Users can switch at any time in Settings.
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
import CallKit
|
||||
import Flutter
|
||||
import PushKit
|
||||
import UIKit
|
||||
import WebRTC
|
||||
import flutter_callkit_incoming
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {
|
||||
// PushKit registry musí žít po celou dobu běhu appky, jinak VoIP pushe nechodí.
|
||||
private var voipRegistry: PKPushRegistry?
|
||||
private var apnsChannel: FlutterMethodChannel?
|
||||
// Token dorazí asynchronně z APNs; když si o něj Dart řekne dřív, podržíme ho.
|
||||
private var pendingToken: String?
|
||||
@@ -29,13 +35,100 @@ import UIKit
|
||||
apnsChannel = channel
|
||||
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
|
||||
// Hovory: VoIP push z relaye → CallKit.
|
||||
let registry = PKPushRegistry(queue: DispatchQueue.main)
|
||||
registry.delegate = self
|
||||
registry.desiredPushTypes = [.voIP]
|
||||
voipRegistry = registry
|
||||
|
||||
// Zvukovou relaci u hovoru řídí CallKit; WebRTC ji smí zapnout až po
|
||||
// jeho aktivaci (didActivateAudioSession), jinak hovor z uzamčené
|
||||
// obrazovky nemá zvuk.
|
||||
RTCAudioSession.sharedInstance().useManualAudio = true
|
||||
RTCAudioSession.sharedInstance().isAudioEnabled = false
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
// MARK: - PushKit (VoIP)
|
||||
|
||||
func pushRegistry(_ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, for type: PKPushType) {
|
||||
let token = credentials.token.map { String(format: "%02x", $0) }.joined()
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(token)
|
||||
}
|
||||
|
||||
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
|
||||
}
|
||||
|
||||
// iOS vyžaduje, aby každý VoIP push HNED skončil hovorem v CallKitu — jinak
|
||||
// appku ukončí a po opakování VoIP pushe přestane doručovat. Relay neví,
|
||||
// kdo volá (sealed sender), proto zvoní „Šepot“; Dart zvonění spáruje
|
||||
// s nabídkou podle `extra.push`, až ji po připojení dešifruje.
|
||||
func pushRegistry(
|
||||
_ registry: PKPushRegistry,
|
||||
didReceiveIncomingPushWith payload: PKPushPayload,
|
||||
for type: PKPushType,
|
||||
completion: @escaping () -> Void
|
||||
) {
|
||||
guard type == .voIP else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
let data = flutter_callkit_incoming.Data(
|
||||
id: UUID().uuidString, nameCaller: "Šepot", handle: "Šepot", type: 0)
|
||||
data.appName = "Šepot"
|
||||
data.duration = 45000
|
||||
data.extra = ["push": true]
|
||||
data.handleType = "generic"
|
||||
data.supportsVideo = true
|
||||
data.maximumCallGroups = 1
|
||||
data.maximumCallsPerCallGroup = 1
|
||||
data.supportsHolding = false
|
||||
data.supportsDTMF = false
|
||||
data.includesCallsInRecents = false
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming(data, fromPushKit: true) {
|
||||
completion()
|
||||
}
|
||||
// Běží-li Dart, ať se hned připojí a vyzvedne nabídku z fronty.
|
||||
apnsChannel?.invokeMethod("onWake", arguments: nil)
|
||||
}
|
||||
|
||||
// MARK: - CallKit
|
||||
// Rozhodnutí (přijmout/odmítnout) řeší Dart přes události pluginu;
|
||||
// tady jen potvrdit akci CallKitu, jinak by ji systém po čase zrušil.
|
||||
|
||||
func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
func onDecline(_ call: Call, _ action: CXEndCallAction) {
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
func onEnd(_ call: Call, _ action: CXEndCallAction) {
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
func onTimeOut(_ call: Call) {}
|
||||
|
||||
func didActivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
|
||||
RTCAudioSession.sharedInstance().isAudioEnabled = true
|
||||
}
|
||||
|
||||
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession)
|
||||
RTCAudioSession.sharedInstance().isAudioEnabled = false
|
||||
}
|
||||
|
||||
func providerDidReset() {}
|
||||
|
||||
// APNs vydal device token → předáme Dartu (ten ho pošle relayi).
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Foundation.Data
|
||||
) {
|
||||
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
|
||||
pendingToken = token
|
||||
|
||||
Reference in New Issue
Block a user