Files
simplex-chat/apps/ios/SimpleXChat/API.swift
T
Narasimha-sc f48377a3fa ios: review fixes for wrong QR code recognition
Follow-up to "Recognise wrong QR code type in every scanner", from an iOS
review of that commit.

- ScanProtocolServer: a rejected scan now alerts in place instead of
  dismissing, but the sheet had no way out - move its title into the nav bar
  and add a Cancel button. Fixes the preview, which needs AppTheme now that
  the themed background is applied inside the view.
- MigrateToDevice: classify the pasted link off the main thread, as the
  Kotlin paste path already does - pasted text is unbounded and classifying
  a file link parses its YAML description. Guard the result, as the sheet can
  be dismissed while classifying.
- Relay message named the Chat relay screen but not that the address is only
  kept once the relay is tested, so following it lost the pasted address to
  an "Invalid relay name!" alert. The migration message named a label that
  only exists on desktop.
- ContentView: the /r deep link showed the relay alert without the guidance
  added to the other call site; both literals were identical before.
- Document WrongQRCode.swift per apps/ios/CODE.md, and refresh the specs and
  product docs that quote the changed messages.
2026-09-02 19:06:00 +00:00

430 lines
13 KiB
Swift

//
// API.swift
// SimpleX NSE
//
// Created by Evgeny on 26/04/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
private var chatController: chat_ctrl?
private var migrationResult: (Bool, DBMigrationResult)?
public func hasChatCtrl() -> Bool {
chatController != nil
}
public func getChatCtrl() -> chat_ctrl {
if let controller = chatController { return controller }
fatalError("chat controller not initialized")
}
public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: MigrationConfirmation? = nil, backgroundMode: Bool = false) -> (Bool, DBMigrationResult) {
if let res = migrationResult { return res }
let dbPath = getAppDatabasePath().path
var dbKey = ""
let useKeychain = storeDBPassphraseGroupDefault.get()
logger.debug("chatMigrateInit uses keychain: \(useKeychain)")
if let key = useKey {
dbKey = key
} else if useKeychain {
if !hasDatabase() {
logger.debug("chatMigrateInit generating a random DB key")
dbKey = randomDatabasePassword()
initialRandomDBPassphraseGroupDefault.set(true)
} else if let key = kcDatabasePassword.get() {
dbKey = key
}
}
let confirm = confirmMigrations ?? defaultMigrationConfirmation()
logger.debug("chatMigrateInit DB path: \(dbPath), confirm: \(confirm.rawValue)")
// logger.debug("chatMigrateInit DB key: \(dbKey)")
var cPath = dbPath.cString(using: .utf8)!
var cKey = dbKey.cString(using: .utf8)!
var cConfirm = confirm.rawValue.cString(using: .utf8)!
// the last parameter of chat_migrate_init is used to return the pointer to chat controller
let cjson = chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, backgroundMode ? 1 : 0, &chatController)!
let dbRes = dbMigrationResult(dataFromCString(cjson))
let encrypted = dbKey != ""
let keychainErr = dbRes == .ok && useKeychain && encrypted && !kcDatabasePassword.set(dbKey)
let result = (encrypted, keychainErr ? .errorKeychain : dbRes)
migrationResult = result
return result
}
public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation: MigrationConfirmation = .error) -> (DBMigrationResult, chat_ctrl?) {
let dbPath = url.path
let dbKey = key ?? randomDatabasePassword()
logger.debug("chatInitTemporaryDatabase path: \(dbPath)")
var temporaryController: chat_ctrl? = nil
var cPath = dbPath.cString(using: .utf8)!
var cKey = dbKey.cString(using: .utf8)!
var cConfirm = confirmation.rawValue.cString(using: .utf8)!
let cjson = chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, 0, &temporaryController)!
return (dbMigrationResult(dataFromCString(cjson)), temporaryController)
}
public func chatInitControllerRemovingDatabases() {
let dbPath = getAppDatabasePath().path
let fm = FileManager.default
// Remove previous databases, otherwise, can be .errorNotADatabase with nil controller
try? fm.removeItem(atPath: dbPath + CHAT_DB)
try? fm.removeItem(atPath: dbPath + AGENT_DB)
let dbKey = randomDatabasePassword()
logger.debug("chatInitControllerRemovingDatabases path: \(dbPath)")
var cPath = dbPath.cString(using: .utf8)!
var cKey = dbKey.cString(using: .utf8)!
var cConfirm = MigrationConfirmation.error.rawValue.cString(using: .utf8)!
chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, 0, &chatController)
// We need only controller, not databases
try? fm.removeItem(atPath: dbPath + CHAT_DB)
try? fm.removeItem(atPath: dbPath + AGENT_DB)
}
public func chatCloseStore() {
// Prevent crash when exiting the app with already closed store (for example, after changing a database passpharase)
guard hasChatCtrl() else {
logger.error("chatCloseStore: already closed, chatCtrl is nil")
return
}
let err = fromCString(chat_close_store(getChatCtrl()))
if err != "" {
logger.error("chatCloseStore error: \(err)")
}
}
public func chatReopenStore() {
let err = fromCString(chat_reopen_store(getChatCtrl()))
if err != "" {
logger.error("chatReopenStore error: \(err)")
}
}
public func resetChatCtrl() {
chatController = nil
migrationResult = nil
}
// Spec: spec/api.md#sendSimpleXCmd
@inline(__always)
public func sendSimpleXCmd<R: ChatAPIResult>(_ cmd: ChatCmdProtocol, _ ctrl: chat_ctrl? = nil, retryNum: Int32 = 0) -> APIResult<R> {
if let d = sendSimpleXCmdStr(cmd.cmdString, ctrl, retryNum: retryNum) {
decodeAPIResult(d)
} else {
APIResult.error(.invalidJSON(json: nil))
}
}
@inline(__always)
public func sendSimpleXCmdStr(_ cmd: String, _ ctrl: chat_ctrl? = nil, retryNum: Int32) -> Data? {
var c = cmd.cString(using: .utf8)!
return if let cjson = chat_send_cmd_retry(ctrl ?? getChatCtrl(), &c, retryNum) {
dataFromCString(cjson)
} else {
nil
}
}
// in microseconds
public let MESSAGE_TIMEOUT: Int32 = 15_000_000
@inline(__always)
public func recvSimpleXMsg<R: ChatAPIResult>(_ ctrl: chat_ctrl? = nil, messageTimeout: Int32 = MESSAGE_TIMEOUT) -> APIResult<R>? {
if let cjson = chat_recv_msg_wait(ctrl ?? getChatCtrl(), messageTimeout),
let d = dataFromCString(cjson) {
decodeAPIResult(d)
} else {
nil
}
}
public func parseSimpleXMarkdown(_ s: String) -> [FormattedText]? {
var c = s.cString(using: .utf8)!
if let cjson = chat_parse_markdown(&c) {
if let d = dataFromCString(cjson) {
do {
let r = try jsonDecoder.decode(ParsedMarkdown.self, from: d)
return r.formattedText
} catch {
logger.error("parseSimpleXMarkdown jsonDecoder.decode error: \(error.localizedDescription)")
}
}
}
return nil
}
public func chatJsonLength(_ s: String) -> Int {
var c = s.cString(using: .utf8)!
return Int(chat_json_length(&c))
}
struct ParsedMarkdown: Decodable {
var formattedText: [FormattedText]?
}
public func parseServerAddress(_ s: String) -> ServerAddress? {
var c = s.cString(using: .utf8)!
if let cjson = chat_parse_server(&c) {
if let d = dataFromCString(cjson) {
do {
let r = try jsonDecoder.decode(ParsedServerAddress.self, from: d)
return r.serverAddress
} catch {
logger.error("parseServerAddress jsonDecoder.decode error: \(error.localizedDescription)")
}
}
}
return nil
}
struct ParsedServerAddress: Decodable {
var serverAddress: ServerAddress?
var parseError: String
}
public enum ScannedLinkType: Decodable {
case connection(linkType: SimplexLinkType)
case server
case fileDescription
case desktopCtrl
case verificationCode
}
struct CheckedLink: Decodable {
var linkType: ScannedLinkType?
}
public func checkLink(_ s: String) -> ScannedLinkType? {
var c = s.cString(using: .utf8)!
if let cjson = chat_check_link(&c) {
if let d = dataFromCString(cjson) {
do {
return try jsonDecoder.decode(CheckedLink.self, from: d).linkType
} catch {
logger.error("checkLink jsonDecoder.decode error: \(error.localizedDescription)")
}
}
}
return nil
}
// nonisolated async, so callers on the main actor offload to the global executor:
// pasted text is unbounded, and classifying a file link parses its YAML description
public func checkLinkAsync(_ s: String) async -> ScannedLinkType? {
checkLink(s)
}
public func parseSanitizeUri(_ s: String, safe: Bool) -> ParsedUri? {
var c = s.cString(using: .utf8)!
if let cjson = chat_parse_uri(&c, safe ? 1 : 0) {
if let d = dataFromCString(cjson) {
do {
return try jsonDecoder.decode(ParsedUri.self, from: d)
} catch {
logger.error("parseSanitizeUri jsonDecoder.decode error: \(error.localizedDescription)")
}
}
}
return nil
}
public struct ParsedUri: Decodable {
public var uriInfo: UriInfo?
public var parseError: String
}
public struct UriInfo: Decodable {
public var scheme: String
public var sanitized: String?
}
@inline(__always)
public func fromCString(_ c: UnsafeMutablePointer<CChar>) -> String {
let s = String.init(cString: c)
free(c)
return s
}
@inline(__always)
public func dataFromCString(_ c: UnsafeMutablePointer<CChar>) -> Data? {
let len = strlen(c)
if len > 0 {
return Data(bytesNoCopy: c, count: len, deallocator: .free)
} else {
free(c)
return nil
}
}
@inline(__always)
public func dataToString(_ d: Data?) -> String {
if let d {
String(data: d, encoding: .utf8) ?? "invalid string"
} else {
"no data"
}
}
public func decodeUser_(_ jDict: NSDictionary) -> UserRef? {
if let user_ = jDict["user_"] {
try? decodeObject(user_ as Any)
} else {
nil
}
}
public func errorJson(_ jDict: NSDictionary) -> Data? {
if let chatError = jDict["chatError"] {
serializeJSON(chatError)
} else {
serializeJSON(jDict)
}
}
public func parseChatData(_ jChat: Any, _ jNavInfo: Any? = nil) throws -> (ChatData, NavigationInfo) {
let jChatDict = jChat as! NSDictionary
let chatInfo: ChatInfo = try decodeObject(jChatDict["chatInfo"]!)
let chatStats: ChatStats = try decodeObject(jChatDict["chatStats"]!)
let navInfo: NavigationInfo = if let jNavInfo = jNavInfo as? NSDictionary, let jNav = jNavInfo["navInfo"] {
try decodeObject(jNav)
} else {
NavigationInfo()
}
let jChatItems = jChatDict["chatItems"] as! NSArray
let chatItems = jChatItems.map { jCI in
if let ci: ChatItem = try? decodeObject(jCI) {
return ci
}
return ChatItem.invalidJSON(
chatDir: decodeProperty(jCI, "chatDir"),
meta: decodeProperty(jCI, "meta"),
json: serializeJSON(jCI, options: .prettyPrinted)
)
}
return (ChatData(chatInfo: chatInfo, chatItems: chatItems, chatStats: chatStats), navInfo)
}
@inline(__always)
public func decodeObject<T: Decodable>(_ obj: Any) throws -> T {
try jsonDecoder.decode(T.self, from: JSONSerialization.data(withJSONObject: obj))
}
@inline(__always)
func decodeProperty<T: Decodable>(_ obj: Any, _ prop: NSString) -> T? {
if let jProp = (obj as? NSDictionary)?[prop] {
return try? decodeObject(jProp)
}
return nil
}
@inline(__always)
func getOWSF(_ obj: NSDictionary, _ prop: NSString) -> (type: String, object: NSDictionary)? {
if let j = obj[prop] as? NSDictionary, j.count == 1 || j.count == 2 {
var type = j.allKeys[0] as? String
if j.count == 2 && type == "_owsf" {
type = j.allKeys[1] as? String
}
if let type {
return (type, j)
}
}
return nil
}
@inline(__always)
public func serializeJSON(_ obj: Any, options: JSONSerialization.WritingOptions = []) -> Data? {
if let d = try? JSONSerialization.data(withJSONObject: obj, options: options) {
dataPrefix(d)
} else {
nil
}
}
let MAX_JSON_VIEW_LENGTH = 2048
@inline(__always)
public func dataPrefix(_ d: Data) -> Data {
d.count > MAX_JSON_VIEW_LENGTH
? Data(d.prefix(MAX_JSON_VIEW_LENGTH))
: d
}
public func responseError(_ err: Error) -> String {
if let e = err as? ChatError {
chatErrorString(e)
} else {
String(describing: err)
}
}
public func chatErrorString(_ err: ChatError) -> String {
switch err {
case let .invalidJSON(json): dataToString(json)
case let .unexpectedResult(type): "unexpected result: \(type)"
default: String(describing: err)
}
}
public enum DBMigrationResult: Decodable, Equatable {
case ok
case invalidConfirmation
case errorNotADatabase(dbFile: String)
case errorMigration(dbFile: String, migrationError: MigrationError)
case errorSQL(dbFile: String, migrationSQLError: String)
case errorKeychain
case unknown(json: String)
}
public enum MigrationConfirmation: String {
case yesUp
case yesUpDown
case error
}
public func defaultMigrationConfirmation() -> MigrationConfirmation {
confirmDBUpgradesGroupDefault.get() ? .error : .yesUp
}
public enum MigrationError: Decodable, Equatable {
case upgrade(upMigrations: [UpMigration])
case downgrade(downMigrations: [String])
case migrationError(mtrError: MTRError)
}
public struct UpMigration: Decodable, Equatable {
public var upName: String
// public var withDown: Bool
}
public func downMigrationWarnings(_ downMigrations: [String]) -> [String] {
let warnings: [(String, String)] = [
("20260222_chat_relays", NSLocalizedString("If you joined or created channels, they will stop working permanently.", comment: "down migration warning"))
]
return warnings.compactMap { (key, message) in
downMigrations.contains(key) ? message : nil
}
}
public enum MTRError: Decodable, Equatable {
case noDown(dbMigrations: [String])
case different(appMigration: String, dbMigration: String)
}
func dbMigrationResult(_ d: Data?) -> DBMigrationResult {
if let d {
do {
return try jsonDecoder.decode(DBMigrationResult.self, from: d)
} catch let error {
logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)")
return .unknown(json: dataToString(d))
}
} else {
return .unknown(json: "no data")
}
}