Files
QuickDrop/NearbyShare/Model/DataModel.swift
T

482 lines
16 KiB
Swift

//
// DataModel.swift
// QuickDrop
//
// Created by Leon Böttger on 26.07.25.
//
import Foundation
import UniformTypeIdentifiers
import Network
import CryptoKit
import SwiftECC
import LUI
protocol InboundNearbyConnectionDelegate {
func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo, connection: InboundNearbyConnection)
func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo, connection: InboundNearbyConnection)
func updatedTransferProgress(connection: InboundNearbyConnection, progress: Double)
func connectionWasTerminated(connection: InboundNearbyConnection, savedFiles: [URL], error: Error?)
func showPlusScreen()
func pairingSetupConfirmed(connection: InboundNearbyConnection)
}
protocol OutboundNearbyConnectionDelegate {
func connectionWasEstablished(connection: OutboundNearbyConnection)
func updatedTransferProgress(connection: OutboundNearbyConnection, progress: Double)
func transferAccepted(connection: OutboundNearbyConnection)
func failedWithError(connection: OutboundNearbyConnection, error: Error)
func transferFinished(connection: OutboundNearbyConnection)
func receiverAuthenticationPending(
connection: OutboundNearbyConnection,
certificateData: Data,
device: RemoteDeviceInfo,
fingerprint: String
)
}
public enum ReceiverAuthenticationPolicy: Equatable {
case none
case trustedReceiver(fingerprint: String)
case bootstrapReceiverTrust(fingerprint: String)
}
public protocol InboundAppDelegate: AnyObject {
func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo)
func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo)
func connectionWasTerminated(connectionID: String, from device: RemoteDeviceInfo?, savedFiles: [URL], wasPlainTextTransfer: Bool, wasClipboardSync: Bool, error: Error?)
func transferProgress(connectionID: String, progress: Double)
func showPlusScreen()
func pairingSetupConfirmed()
}
extension InboundAppDelegate {
func pairingSetupConfirmed() {}
}
public protocol OutboundAppDelegate: AnyObject {
func addDevice(device: RemoteDeviceInfo)
func removeDevice(id: String)
func startTransferWithQrCode(device: RemoteDeviceInfo)
func connectionWasEstablished(pinCode: String)
func connectionFailed(error: Error)
func transferAccepted()
func transferProgress(progress: Double)
func transferFinished()
}
public struct RemoteDeviceInfo: Codable, Identifiable, Equatable {
public let name: String?
public let type: DeviceType
public let qrCodeData: Data?
public let isQuickDropPeer: Bool
public var id: String?
public let keyFingerprint: String?
public let supportsNotificationSync: Bool
public let supportsClipboardReceive: Bool
init(
name: String?,
type: DeviceType?,
id: String? = nil,
isQuickDropPeer: Bool = false,
keyFingerprint: String? = nil,
supportsNotificationSync: Bool = false,
supportsClipboardReceive: Bool = false
) {
self.name = name
self.type = type ?? .phone
self.id = id
self.qrCodeData = nil
self.isQuickDropPeer = isQuickDropPeer
self.keyFingerprint = Self.normalizedKeyFingerprint(keyFingerprint)
self.supportsNotificationSync = supportsNotificationSync
self.supportsClipboardReceive = supportsClipboardReceive
}
init(
info: EndpointInfo,
id: String? = nil,
isQuickDropPeer: Bool = false,
keyFingerprint: String? = nil,
supportsNotificationSync: Bool = false,
supportsClipboardReceive: Bool = false
) {
self.name = info.name
self.type = info.deviceType
self.qrCodeData = info.qrCodeData
self.id = id
self.isQuickDropPeer = isQuickDropPeer
self.keyFingerprint = Self.normalizedKeyFingerprint(keyFingerprint)
self.supportsNotificationSync = supportsNotificationSync
self.supportsClipboardReceive = supportsClipboardReceive
}
private enum CodingKeys: String, CodingKey {
case name
case type
case qrCodeData
case isQuickDropPeer
case isVerifiedQuickDropPeer
case id
case keyFingerprint
case supportsNotificationSync
case supportsClipboardReceive
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decodeIfPresent(String.self, forKey: .name)
type = try container.decode(DeviceType.self, forKey: .type)
qrCodeData = try container.decodeIfPresent(Data.self, forKey: .qrCodeData)
isQuickDropPeer = try container.decodeIfPresent(Bool.self, forKey: .isQuickDropPeer)
?? container.decodeIfPresent(Bool.self, forKey: .isVerifiedQuickDropPeer)
?? false
id = try container.decodeIfPresent(String.self, forKey: .id)
keyFingerprint = Self.normalizedKeyFingerprint(
try container.decodeIfPresent(String.self, forKey: .keyFingerprint)
)
supportsNotificationSync = try container.decodeIfPresent(Bool.self, forKey: .supportsNotificationSync) ?? false
supportsClipboardReceive = try container.decodeIfPresent(Bool.self, forKey: .supportsClipboardReceive) ?? false
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(name, forKey: .name)
try container.encode(type, forKey: .type)
try container.encodeIfPresent(qrCodeData, forKey: .qrCodeData)
try container.encode(isQuickDropPeer, forKey: .isQuickDropPeer)
try container.encodeIfPresent(id, forKey: .id)
try container.encodeIfPresent(keyFingerprint, forKey: .keyFingerprint)
try container.encode(supportsNotificationSync, forKey: .supportsNotificationSync)
try container.encode(supportsClipboardReceive, forKey: .supportsClipboardReceive)
}
public enum DeviceType: Int32, Codable {
case unknown = 0
case phone
case tablet
case computer
public static func fromRawValue(value: Int) -> DeviceType {
switch value {
case 0:
return .unknown
case 1:
return .phone
case 2:
return .tablet
case 3:
return .computer
default:
return .unknown
}
}
}
public var icon: String {
switch type {
case .computer:
return "laptopcomputer"
case .tablet:
return "ipad.landscape"
default:
return getSmartphoneIcon()
}
}
private func getSmartphoneIcon() -> String {
if #available(iOS 17.0, macOS 14.0, *) {
return "smartphone"
}
return "iphone"
}
private static func normalizedKeyFingerprint(_ keyFingerprint: String?) -> String? {
let normalized = keyFingerprint?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return normalized?.isEmpty == false ? normalized : nil
}
}
public struct TransferMetadata {
public let files: [FileMetadata]
public let id: String
public let pinCode: String?
public let textDescription: String?
public let type: TransferType
public let allowsToBeAddedAsTrustedDevice: Bool
public let isClipboardSync: Bool
init(files: [FileMetadata], id: String, pinCode: String?, textDescription: String? = nil, transferType: TransferType = .file, allowsToBeAddedAsTrustedDevice: Bool, isClipboardSync: Bool = false) {
self.files = files
self.id = id
self.pinCode = pinCode
self.textDescription = textDescription
self.type = transferType
self.allowsToBeAddedAsTrustedDevice = allowsToBeAddedAsTrustedDevice
self.isClipboardSync = isClipboardSync
}
public enum TransferType {
case file
case text
case url
case wifiPassword
case notificationSync
case clipboardSync
}
private func getSummary() -> String {
if let textTitle = textDescription {
return textTitle
} else if files.count == 1 {
return fileTypeSummary(for: files[0])
} else {
return String.localizedStringWithFormat("NFiles".localized(), files.count)
}
}
private func fileTypeSummary(for file: FileMetadata) -> String {
let mimeType = file.mimeType.lowercased()
let ext = URL(fileURLWithPath: file.name).pathExtension.lowercased()
let type = UTType(mimeType: mimeType) ?? UTType(filenameExtension: ext)
if let type, type.conforms(to: .pdf) {
return UTType.pdf.localizedDescription ?? "PDF"
}
if let type, type.conforms(to: .image) {
return UTType.image.localizedDescription ?? "Image"
}
if let type, type.conforms(to: .video) || type.conforms(to: .movie) {
return UTType.video.localizedDescription ?? "Video"
}
if let type, type.conforms(to: .audio) {
return UTType.audio.localizedDescription ?? "Audio"
}
if let type, type.conforms(to: .text) {
return UTType.text.localizedDescription ?? "Text"
}
if let type, type.conforms(to: .archive) {
return UTType.archive.localizedDescription ?? "Archive"
}
if let type, let description = type.localizedDescription {
return description
}
return "File"
}
public func getDescription(deviceName: String) -> String {
let fileStr = getSummary()
switch type {
case .file:
return "DeviceSendingFiles".localized(with: fileStr, deviceName)
case .text:
return "DeviceSendingText".localized(with: deviceName)
case .url:
return "DeviceSendingUrl".localized(with: deviceName)
case .wifiPassword:
return "DeviceSendingWifiPassword".localized(with: deviceName)
case .notificationSync:
if let textDescription, !textDescription.isEmpty {
return textDescription
}
return "NotificationSyncConsentPromptFromDevice".localized(with: deviceName)
case .clipboardSync:
if let textDescription, !textDescription.isEmpty {
return textDescription
}
return "ClipboardSyncConsentPromptFromDevice".localized(with: deviceName)
}
}
public func getPinCodeMessage() -> String {
return String(format: "PinCode".localized(), arguments: [pinCode ?? "?"])
}
}
enum PairingUseCase: String, Codable, Hashable {
case notificationSync = "notification_sync"
case clipboardSync = "clipboard_sync"
var protoValue: Sharing_Nearby_PairingMetadata.UseCase {
switch self {
case .notificationSync:
return .notificationSync
case .clipboardSync:
return .clipboardSync
}
}
init?(protoValue: Sharing_Nearby_PairingMetadata.UseCase) {
switch protoValue {
case .notificationSync:
self = .notificationSync
case .clipboardSync:
self = .clipboardSync
case .unknown:
return nil
}
}
}
public struct FileMetadata {
public let name: String
public let size: Int64
public let mimeType: String
}
public struct EndpointInfo {
var name: String?
let deviceType: RemoteDeviceInfo.DeviceType
let qrCodeData: Data?
static let maxNameChars = 32
init(name: String, deviceType: RemoteDeviceInfo.DeviceType) {
self.name = String(decoding: Self.sanitizedString(name), as: UTF8.self)
self.deviceType = deviceType
self.qrCodeData = nil
}
init?(data: Data) {
guard data.count > 17 else {
log("[EndpointInfo] Invalid endpoint info data received (too short)")
return nil
}
let hasName = (data[0] & 0x10) == 0
let deviceNameLength: Int
let deviceName: String?
if hasName {
deviceNameLength = Int(data[17])
guard data.count >= deviceNameLength + 18 else {
log("[EndpointInfo] Invalid endpoint info data received (name length mismatch)")
return nil
}
guard let newDeviceName = String(data: data[18..<(18 + deviceNameLength)], encoding: .utf8) else {
log("[EndpointInfo] Invalid endpoint info data received (name decoding failed)")
return nil
}
deviceName = newDeviceName
}
else {
deviceNameLength = 0
deviceName = nil
}
let rawDeviceType: Int = Int(data[0] & 7) >> 1
self.name = deviceName
self.deviceType = RemoteDeviceInfo.DeviceType.fromRawValue(value: rawDeviceType)
var offset = 1 + 16
if hasName {
offset = offset + 1 + deviceNameLength
}
var qrCodeData: Data? = nil
// read TLV records, if any
while data.count - offset > 2 {
let type = data[offset]
let length = Int(data[offset+1])
offset = offset + 2
if data.count - offset >= length{
// QR code data
if type == 1 {
qrCodeData = data.subdata(in: offset..<offset + length)
}
offset = offset + length
}
}
self.qrCodeData = qrCodeData
}
func serialize() -> Data {
// 1 byte: Version(3 bits)|Visibility(1 bit)|Device Type(3 bits)|Reserved(1 bits)
// Device types: unknown=0, phone=1, tablet=2, laptop=3
var endpointInfo: [UInt8] = [UInt8(deviceType.rawValue << 1)]
// 16 bytes: unknown random bytes
for _ in 0 ... 15 {
endpointInfo.append(UInt8.random(in: 0...255))
}
// Device name in UTF-8 prefixed with 1-byte length
let nameChars = Self.sanitizedString(self.name ?? "")
endpointInfo.append(UInt8(nameChars.count))
for ch in nameChars {
endpointInfo.append(UInt8(ch))
}
return Data(endpointInfo)
}
private static func sanitizedString(_ string: String) -> [UInt8] {
var out: [UInt8] = []
out.reserveCapacity(min(Self.maxNameChars, string.utf8.count))
for scalar in string.unicodeScalars {
let bytes = Array(scalar.utf8)
if out.count + bytes.count > Self.maxNameChars { break }
out.append(contentsOf: bytes)
}
return out
}
}
public enum NearbyError: Error {
case protocolError(_ message: String)
case requiredFieldMissing(_ message: String)
case ukey2
case packetFilterError
case firewallError
case inputOutput
case notificationSyncNotTrusted
case canceled(reason: CancellationReason)
public enum CancellationReason {
case userRejected, userCanceled, notEnoughSpace, unsupportedType, timedOut
public func localizedDescription() -> String {
switch self {
case .userRejected:
"TransferDeclined".localized()
case .userCanceled:
"TransferCanceled".localized()
case .notEnoughSpace:
"NotEnoughSpace".localized()
case .unsupportedType:
"UnsupportedType".localized()
case .timedOut:
"TransferTimedOut".localized()
}
}
}
}