Files
simplex-chat/apps/ios/SimpleXChat/FileUtils.swift
T
EvgenyandEvgeny @ SimpleX Chat 5ffbe733a8 core: attach badge proofs to files over size limit (#7455)
* core: attach badge proofs to files over size limit

* types

* more types

* implement file badge proofs

* tests

* move file limits to config, add tests

* more tests, work correctly in "send as group" case

* fix races in tests

* group badge tests

* query plans

* add history support, fixes

* simplify

* refactor

* refactor

* type

* restructure schema for proofs

* rename, refactor

* refactor

* fix, refactor

* refactor

* ui

* comments

* alerts

* update nix, ios library

* updare sharing

* update simplexmq

* update text

* improve messages

* api types

* postgres schema

* fix

* test

* query plans

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
2026-09-12 14:33:52 +01:00

351 lines
12 KiB
Swift

//
// FileUtils.swift
// SimpleX (iOS)
//
// Created by JRoberts on 15.04.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
// Spec: spec/services/files.md
import Foundation
import OSLog
import UIKit
let logger = Logger()
// image file size for complession
// Spec: spec/services/files.md#MAX_IMAGE_SIZE
public let MAX_IMAGE_SIZE: Int64 = 261_120 // 255KB
// Spec: spec/services/files.md#MAX_IMAGE_SIZE_AUTO_RCV
public let MAX_IMAGE_SIZE_AUTO_RCV: Int64 = MAX_IMAGE_SIZE * 2
// Spec: spec/services/files.md#MAX_VOICE_SIZE_AUTO_RCV
public let MAX_VOICE_SIZE_AUTO_RCV: Int64 = MAX_IMAGE_SIZE * 2
// Spec: spec/services/files.md#MAX_VIDEO_SIZE_AUTO_RCV
public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023KB
// Spec: spec/services/files.md#MAX_FILE_SIZE_XFTP
public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1GB
// raised XFTP limits for a user with a supporter badge (also investor) or a legend badge
public let MAX_FILE_SIZE_XFTP_SUPPORTER: Int64 = 2_147_483_648 // 2GB
public let MAX_FILE_SIZE_XFTP_LEGEND: Int64 = 5_368_709_120 // 5GB
// a badge raises the limit at send for this long after its expiry, shorter than the receiver's grace
public let BADGE_SND_GRACE_INTERVAL = TimeInterval(86400)
public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max
public let MAX_FILE_SIZE_SMP: Int64 = 8000000
public let MAX_VOICE_MESSAGE_LENGTH = TimeInterval(300)
let CHAT_DB: String = "_chat.db"
let AGENT_DB: String = "_agent.db"
private let CHAT_DB_BAK: String = "_chat.db.bak"
private let AGENT_DB_BAK: String = "_agent.db.bak"
// Spec: spec/database.md#getDocumentsDirectory
public func getDocumentsDirectory() -> URL {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
}
// Spec: spec/database.md#getGroupContainerDirectory
public func getGroupContainerDirectory() -> URL {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_NAME)!
}
func getAppDirectory() -> URL {
dbContainerGroupDefault.get() == .group
? getGroupContainerDirectory()
: getDocumentsDirectory()
}
// Spec: spec/database.md#DB_FILE_PREFIX
let DB_FILE_PREFIX = "simplex_v1"
func getLegacyDatabasePath() -> URL {
getDocumentsDirectory().appendingPathComponent("mobile_v1", isDirectory: false)
}
// Spec: spec/database.md#getAppDatabasePath
public func getAppDatabasePath() -> URL {
dbContainerGroupDefault.get() == .group
? getGroupContainerDirectory().appendingPathComponent(DB_FILE_PREFIX, isDirectory: false)
: getLegacyDatabasePath()
}
func fileModificationDate(_ path: String) -> Date? {
do {
let attr = try FileManager.default.attributesOfItem(atPath: path)
return attr[FileAttributeKey.modificationDate] as? Date
} catch {
return nil
}
}
// Spec: spec/services/files.md#deleteAppDatabaseAndFiles
public func deleteAppDatabaseAndFiles() {
let fm = FileManager.default
let dbPath = getAppDatabasePath().path
do {
try fm.removeItem(atPath: dbPath + CHAT_DB)
try fm.removeItem(atPath: dbPath + AGENT_DB)
} catch let error {
logger.error("Failed to delete all databases: \(error)")
}
try? fm.removeItem(atPath: dbPath + CHAT_DB_BAK)
try? fm.removeItem(atPath: dbPath + AGENT_DB_BAK)
try? fm.removeItem(at: getTempFilesDirectory())
try? fm.removeItem(at: getMigrationTempFilesDirectory())
try? fm.createDirectory(at: getTempFilesDirectory(), withIntermediateDirectories: true)
try? fm.removeItem(at: getWallpaperDirectory())
try? fm.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
deleteAppFiles()
_ = kcDatabasePassword.remove()
storeDBPassphraseGroupDefault.set(true)
}
// Spec: spec/services/files.md#deleteAppFiles
public func deleteAppFiles() {
let fm = FileManager.default
do {
try fm.removeItem(at: getAppFilesDirectory())
try fm.createDirectory(at: getAppFilesDirectory(), withIntermediateDirectories: true)
} catch {
logger.error("FileUtils deleteAppFiles error: \(error.localizedDescription)")
}
}
public func fileSize(_ url: URL) -> Int? { // in bytes
do {
let val = try url.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey])
return val.totalFileAllocatedSize ?? val.fileAllocatedSize
} catch {
logger.error("FileUtils fileSize error: \(error.localizedDescription)")
return nil
}
}
public func directoryFileCountAndSize(_ dir: URL) -> (Int, Int)? { // size in bytes
let fm = FileManager.default
if let enumerator = fm.enumerator(at: dir, includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey], options: [], errorHandler: { (_, error) -> Bool in
logger.error("FileUtils directoryFileCountAndSize error: \(error.localizedDescription)")
return false
}) {
var fileCount = 0
var bytes = 0
for case let url as URL in enumerator {
fileCount += 1
bytes += fileSize(url) ?? 0
}
return (fileCount, bytes)
} else {
return nil
}
}
public func hasBackup(newerThan date: Date) -> Bool {
let dbPath = getAppDatabasePath().path
return hasBackupFile(dbPath + AGENT_DB_BAK, newerThan: date)
&& hasBackupFile(dbPath + CHAT_DB_BAK, newerThan: date)
}
private func hasBackupFile(_ path: String, newerThan date: Date) -> Bool {
let fm = FileManager.default
return fm.fileExists(atPath: path)
&& date <= (fileModificationDate(path) ?? Date.distantPast)
}
public func restoreBackup() throws {
let dbPath = getAppDatabasePath().path
try restoreBackupFile(fromPath: dbPath + AGENT_DB_BAK, toPath: dbPath + AGENT_DB)
try restoreBackupFile(fromPath: dbPath + CHAT_DB_BAK, toPath: dbPath + CHAT_DB)
}
private func restoreBackupFile(fromPath: String, toPath: String) throws {
let fm = FileManager.default
if fm.fileExists(atPath: toPath) {
try fm.removeItem(atPath: toPath)
}
try fm.copyItem(atPath: fromPath, toPath: toPath)
}
public func hasLegacyDatabase() -> Bool {
hasDatabaseAtPath(getLegacyDatabasePath())
}
public func hasDatabase() -> Bool {
hasDatabaseAtPath(getAppDatabasePath())
}
func hasDatabaseAtPath(_ dbPath: URL) -> Bool {
let fm = FileManager.default
return fm.isReadableFile(atPath: dbPath.path + AGENT_DB) &&
fm.isReadableFile(atPath: dbPath.path + CHAT_DB)
}
public func removeLegacyDatabaseAndFiles() -> Bool {
let dbPath = getLegacyDatabasePath()
let appFiles = getDocumentsDirectory().appendingPathComponent("app_files", isDirectory: true)
let fm = FileManager.default
let r1 = nil != (try? fm.removeItem(atPath: dbPath.path + AGENT_DB))
let r2 = nil != (try? fm.removeItem(atPath: dbPath.path + CHAT_DB))
try? fm.removeItem(atPath: dbPath.path + AGENT_DB_BAK)
try? fm.removeItem(atPath: dbPath.path + CHAT_DB_BAK)
try? fm.removeItem(at: appFiles)
return r1 && r2
}
// Spec: spec/services/files.md#getTempFilesDirectory
public func getTempFilesDirectory() -> URL {
getAppDirectory().appendingPathComponent("temp_files", isDirectory: true)
}
public func getMigrationTempFilesDirectory() -> URL {
getDocumentsDirectory().appendingPathComponent("migration_temp_files", isDirectory: true)
}
// Spec: spec/services/files.md#getAppFilesDirectory
public func getAppFilesDirectory() -> URL {
getAppDirectory().appendingPathComponent("app_files", isDirectory: true)
}
public func getAppFilePath(_ fileName: String) -> URL {
getAppFilesDirectory().appendingPathComponent(fileName)
}
// Spec: spec/services/files.md#getWallpaperDirectory
public func getWallpaperDirectory() -> URL {
getAppDirectory().appendingPathComponent("assets", isDirectory: true).appendingPathComponent("wallpapers", isDirectory: true)
}
public func getWallpaperFilePath(_ filename: String) -> URL {
getWallpaperDirectory().appendingPathComponent(filename)
}
// Spec: spec/services/files.md#saveFile
public func saveFile(_ data: Data, _ fileName: String, encrypted: Bool) -> CryptoFile? {
let filePath = getAppFilePath(fileName)
do {
if encrypted {
let cfArgs = try writeCryptoFile(path: filePath.path, data: data)
return CryptoFile(filePath: fileName, cryptoArgs: cfArgs)
} else {
try data.write(to: filePath)
return CryptoFile.plain(fileName)
}
} catch {
logger.error("FileUtils.saveFile error: \(error.localizedDescription)")
return nil
}
}
// Spec: spec/services/files.md#removeFile
public func removeFile(_ url: URL) {
do {
try FileManager.default.removeItem(atPath: url.path)
} catch {
logger.error("FileUtils.removeFile error: \(error.localizedDescription)")
}
}
public func removeFile(_ fileName: String) {
do {
try FileManager.default.removeItem(atPath: getAppFilePath(fileName).path)
} catch {
logger.error("FileUtils.removeFile error: \(error.localizedDescription)")
}
}
// Spec: spec/services/files.md#cleanupDirectFile
public func cleanupDirectFile(_ aChatItem: AChatItem) {
if aChatItem.chatInfo.chatType == .direct {
cleanupFile(aChatItem)
}
}
// Spec: spec/services/files.md#cleanupFile
public func cleanupFile(_ aChatItem: AChatItem) {
let cItem = aChatItem.chatItem
let mc = cItem.content.msgContent
if case .file = mc,
let fileName = cItem.file?.fileSource?.filePath {
removeFile(fileName)
}
}
public func badgeMaxFileSize(_ badge: LocalBadge) -> Int64 {
badge.badge.badgeType == .legend ? MAX_FILE_SIZE_XFTP_LEGEND : MAX_FILE_SIZE_XFTP_SUPPORTER
}
// a badge raises the limit at send until one day past its expiry, as the core applies it
public func badgeActiveForSend(_ badge: LocalBadge) -> Bool {
badge.status == .active && badge.badge.badgeExpiry.addingTimeInterval(BADGE_SND_GRACE_INTERVAL) >= Date.now
}
// in incognito chats and above the largest badge's limit no badge applies, so badgeIssue is not used
public func largeFileMessage(_ fileSize: Int64, incognito: Bool = false, badgeIssue: String = "") -> String {
if incognito {
return String.localizedStringWithFormat(
NSLocalizedString("Files larger than %@ cannot be sent in incognito chats.", comment: "file alert"),
ByteCountFormatter.string(fromByteCount: MAX_FILE_SIZE_XFTP, countStyle: .binary)
)
}
if fileSize > MAX_FILE_SIZE_XFTP_LEGEND {
return String.localizedStringWithFormat(
NSLocalizedString("Maximum supported file size is %1$@, with a %2$@.", comment: "file alert"),
ByteCountFormatter.string(fromByteCount: MAX_FILE_SIZE_XFTP_LEGEND, countStyle: .binary),
NSLocalizedString("legend badge", comment: "badge required to send a large file")
)
}
let supporter = fileSize <= MAX_FILE_SIZE_XFTP_SUPPORTER
let message = String.localizedStringWithFormat(
NSLocalizedString("You need a %1$@ to send files larger than %2$@.", comment: "file alert"),
supporter
? NSLocalizedString("supporter badge", comment: "badge required to send a large file")
: NSLocalizedString("legend badge", comment: "badge required to send a large file"),
ByteCountFormatter.string(fromByteCount: supporter ? MAX_FILE_SIZE_XFTP : MAX_FILE_SIZE_XFTP_SUPPORTER, countStyle: .binary)
)
return badgeIssue.isEmpty ? message : message + " " + badgeIssue
}
// the badge lapsed, and while active it would have allowed this file
public func expiredBadgeReason(_ fileSize: Int64, _ senderProfile: LocalProfile?) -> String {
if let badge = senderProfile?.localBadge, !badgeActiveForSend(badge), badgeMaxFileSize(badge) >= fileSize {
NSLocalizedString("Your badge expired.", comment: "file alert")
} else {
""
}
}
public func getMaxFileSize(_ fileProtocol: FileProtocol, _ senderProfile: LocalProfile? = nil) -> Int64 {
switch fileProtocol {
case .smp: MAX_FILE_SIZE_SMP
case .local: MAX_FILE_SIZE_LOCAL
case .xftp:
if let badge = senderProfile?.localBadge, badgeActiveForSend(badge) {
badgeMaxFileSize(badge)
} else {
MAX_FILE_SIZE_XFTP
}
}
}
public struct RuntimeError: Error {
let message: String
public init(_ message: String) {
self.message = message
}
public var localizedDescription: String {
return message
}
}