NCUtils.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  1. //
  2. // SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
  3. // SPDX-License-Identifier: GPL-3.0-or-later
  4. //
  5. import Foundation
  6. import CommonCrypto
  7. import MobileCoreServices
  8. import UniformTypeIdentifiers
  9. import AVFoundation
  10. @objcMembers public class NCUtils: NSObject {
  11. private static let nextcloudScheme = "nextcloud:"
  12. public static func previewImage(forFileExtension fileExtension: String) -> String {
  13. return previewImage(forFileType: UTType(filenameExtension: fileExtension))
  14. }
  15. public static func previewImage(forMimeType mimeType: String?) -> String {
  16. guard let mimeType else { return "file" }
  17. return self.previewImage(forFileType: UTType(mimeType: mimeType))
  18. }
  19. // swiftlint:disable:next cyclomatic_complexity
  20. public static func previewImage(forFileType fileType: UTType?) -> String {
  21. guard let fileType else { return "file" }
  22. if let mimeType = fileType.preferredMIMEType {
  23. if mimeType.contains("org.openxmlformats") || mimeType.contains("org.oasis-open.opendocument") ||
  24. mimeType.contains("officedocument.wordprocessingml") {
  25. return "file-document"
  26. } else if mimeType == "httpd/unix-directory" {
  27. return "folder"
  28. }
  29. }
  30. if !fileType.isDeclared {
  31. return "file"
  32. }
  33. if fileType.conforms(to: .audio) {
  34. return "file-audio"
  35. } else if fileType.conforms(to: .movie) {
  36. return "file-video"
  37. } else if fileType.conforms(to: .image) {
  38. return "file-image"
  39. } else if fileType.conforms(to: .spreadsheet) {
  40. return "file-spreadsheet"
  41. } else if fileType.conforms(to: .presentation) {
  42. return "file-presentation"
  43. } else if fileType.conforms(to: .pdf) {
  44. return "file-pdf"
  45. } else if fileType.conforms(to: .vCard) {
  46. return "file-vcard"
  47. } else if fileType.conforms(to: .text) {
  48. return "file-text"
  49. } else if fileType.conforms(to: .zip) {
  50. return "file-zip"
  51. }
  52. return "file"
  53. }
  54. public static func isImage(fileType: String) -> Bool {
  55. return self.previewImage(forMimeType: fileType) == "file-image"
  56. }
  57. public static func isImage(fileExtension: String) -> Bool {
  58. return self.previewImage(forFileExtension: fileExtension) == "file-image"
  59. }
  60. public static func isVideo(fileType: String) -> Bool {
  61. return self.previewImage(forMimeType: fileType) == "file-video"
  62. }
  63. public static func isAudio(fileType: String) -> Bool {
  64. return self.previewImage(forMimeType: fileType) == "file-audio"
  65. }
  66. public static func isVCard(fileType: String) -> Bool {
  67. return self.previewImage(forMimeType: fileType) == "file-vcard"
  68. }
  69. public static func isGif(fileType: String) -> Bool {
  70. return UTType(mimeType: fileType)?.conforms(to: .gif) ?? false
  71. }
  72. public static func isNextcloudAppInstalled() -> Bool {
  73. var isInstalled = false
  74. #if !APP_EXTENSION
  75. if let URL = URL(string: nextcloudScheme) {
  76. isInstalled = UIApplication.shared.canOpenURL(URL)
  77. }
  78. #endif
  79. return isInstalled
  80. }
  81. public static func openFileInNextcloudApp(path: String, withFileLink link: String) {
  82. #if !APP_EXTENSION
  83. if !self.isNextcloudAppInstalled() {
  84. return
  85. }
  86. let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
  87. let nextcloudURLString = "\(nextcloudScheme)//open-file?path=\(path)&user=\(activeAccount.userId)&link=\(link)"
  88. if let nextcloudEncodedURLString = nextcloudURLString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
  89. let nextcloudURL = URL(string: nextcloudEncodedURLString) {
  90. UIApplication.shared.open(nextcloudURL)
  91. }
  92. #endif
  93. }
  94. public static func openFileInNextcloudAppOrBrowser(path: String, withFileLink link: String) {
  95. #if !APP_EXTENSION
  96. if self.isNextcloudAppInstalled() {
  97. self.openFileInNextcloudApp(path: path, withFileLink: link)
  98. } else {
  99. self.openLinkInBrowser(link: link)
  100. }
  101. #endif
  102. }
  103. public static func openLinkInBrowser(link: String) {
  104. #if !APP_EXTENSION
  105. guard let URL = URL(string: link) else { return }
  106. UIApplication.shared.open(URL)
  107. #endif
  108. }
  109. public static func isInstanceRoomLink(link: String) -> Bool {
  110. let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
  111. let roomPrefix1 = "\(activeAccount.server)/call"
  112. let roomPrefix2 = "\(activeAccount.server)/index.php/call"
  113. return link.lowercased().contains(roomPrefix1) || link.lowercased().contains(roomPrefix2)
  114. }
  115. // MARK: - Date utils
  116. public static func dateFromDateAtomFormat(dateAtomFormatString: String) -> Date? {
  117. let dateFormatter = DateFormatter()
  118. dateFormatter.locale = Locale(identifier: "en_US_POSIX")
  119. dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
  120. return dateFormatter.date(from: dateAtomFormatString)
  121. }
  122. public static func dateAtomFormatFromDate(date: Date) -> String {
  123. let dateFormatter = DateFormatter()
  124. dateFormatter.locale = Locale(identifier: "en_US_POSIX")
  125. dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
  126. return dateFormatter.string(from: date)
  127. }
  128. public static func readableDateTime(fromDate date: Date) -> String {
  129. let dateFormatter = DateFormatter()
  130. dateFormatter.dateStyle = .medium
  131. dateFormatter.timeStyle = .short
  132. dateFormatter.doesRelativeDateFormatting = true
  133. return dateFormatter.string(from: date)
  134. }
  135. public static func readableTimeOrDate(fromDate date: Date) -> String {
  136. if Calendar.current.isDateInToday(date) {
  137. return self.getTime(fromDate: date)
  138. } else if Calendar.current.isDateInYesterday(date) {
  139. return NSLocalizedString("Yesterday", comment: "")
  140. }
  141. let dateFormatter = DateFormatter()
  142. dateFormatter.dateStyle = .short
  143. dateFormatter.timeStyle = .none
  144. return dateFormatter.string(from: date)
  145. }
  146. public static func readableTimeAndDate(fromDate date: Date) -> String {
  147. if Calendar.current.isDateInToday(date) {
  148. return self.getTime(fromDate: date)
  149. }
  150. let dateFormatter = DateFormatter()
  151. dateFormatter.dateStyle = .medium
  152. dateFormatter.timeStyle = .short
  153. return dateFormatter.string(from: date)
  154. }
  155. public static func getTime(fromDate date: Date) -> String {
  156. let dateFormatter = DateFormatter()
  157. dateFormatter.dateStyle = .none
  158. dateFormatter.timeStyle = .short
  159. return dateFormatter.string(from: date)
  160. }
  161. public static func getDate(fromDate date: Date) -> String {
  162. let dateFormatter = DateFormatter()
  163. dateFormatter.dateStyle = .short
  164. dateFormatter.timeStyle = .none
  165. return dateFormatter.string(from: date)
  166. }
  167. public static func relativeTimeFromDate(date: Date) -> String {
  168. let todayDate = Date()
  169. var ti = date.timeIntervalSince(todayDate)
  170. ti *= -1
  171. if ti < 60 {
  172. // This minute
  173. return NSLocalizedString("less than a minute ago", comment: "")
  174. } else if ti < 3600 {
  175. // This hour
  176. let diff = Int(round(ti / 60))
  177. return String(format: NSLocalizedString("%d minutes ago", comment: ""), diff)
  178. } else if ti < 86400 {
  179. // This day
  180. let diff = Int(round(ti / 60 / 60))
  181. return String(format: NSLocalizedString("%d hours ago", comment: ""), diff)
  182. } else if ti < 86400 * 30 {
  183. // This month
  184. let diff = Int(round(ti / 60 / 60 / 24))
  185. return String(format: NSLocalizedString("%d days ago", comment: ""), diff)
  186. }
  187. let dateFormatter = DateFormatter()
  188. dateFormatter.formatterBehavior = .behavior10_4
  189. dateFormatter.dateStyle = .medium
  190. return dateFormatter.string(from: date)
  191. }
  192. public static func today(withHour hour: Int, withMinute minute: Int, withSecond second: Int) -> Date? {
  193. let calendar = Calendar.current
  194. let now = Date()
  195. var components = calendar.dateComponents([.year, .month, .day], from: now)
  196. components.hour = hour
  197. components.minute = minute
  198. components.second = second
  199. return calendar.date(from: components)
  200. }
  201. public static func setWeekday(_ weekday: Int, withDate date: Date) -> Date {
  202. let currentWeekday = Calendar.current.component(.weekday, from: date)
  203. return Calendar.current.date(byAdding: .day, value: (weekday - currentWeekday), to: date)!
  204. }
  205. // MARK: - Crypto utils
  206. public static func sha1(fromString string: String) -> String {
  207. let data = string.data(using: .utf8)!
  208. var digest = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
  209. data.withUnsafeBytes {
  210. _ = CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest)
  211. }
  212. let hexBytes = digest.map { String(format: "%02hhx", $0) }
  213. return hexBytes.joined()
  214. }
  215. // MARK: - Image utils
  216. public static func blurImage(fromImage image: UIImage) -> UIImage? {
  217. let inputRadius = 8.0
  218. guard let inputImage = CIImage(image: image),
  219. let filter = CIFilter(name: "CIGaussianBlur")
  220. else { return nil }
  221. let context = CIContext()
  222. filter.setValue(inputImage, forKey: kCIInputImageKey)
  223. filter.setValue(inputRadius, forKey: "inputRadius")
  224. guard let result = filter.value(forKey: kCIOutputImageKey) as? CIImage else { return nil }
  225. let imageRect = inputImage.extent
  226. let cropRect = CGRect(x: imageRect.origin.x + inputRadius, y: imageRect.origin.y + inputRadius, width: imageRect.width - inputRadius * 2, height: imageRect.height - inputRadius * 2)
  227. if let cgImage = context.createCGImage(result, from: imageRect)?.cropping(to: cropRect) {
  228. return UIImage(cgImage: cgImage)
  229. }
  230. return nil
  231. }
  232. public static func roundedImage(fromImage image: UIImage) -> UIImage {
  233. let imageSize = image.size
  234. let rect = CGRect(x: 0, y: 0, width: imageSize.width, height: imageSize.width)
  235. UIGraphicsBeginImageContextWithOptions(imageSize, false, UIScreen.main.scale)
  236. UIBezierPath(roundedRect: rect, cornerRadius: imageSize.height).addClip()
  237. image.draw(in: rect)
  238. if let resultImage = UIGraphicsGetImageFromCurrentImageContext() {
  239. UIGraphicsEndImageContext()
  240. return resultImage
  241. }
  242. UIGraphicsEndImageContext()
  243. return image
  244. }
  245. public static func renderAspectImage(image: UIImage?, ofSize size: CGSize, centerImage center: Bool) -> UIImage? {
  246. guard let image else { return nil }
  247. let newRect = CGRect(x: 0, y: 0, width: size.width, height: size.height)
  248. UIGraphicsBeginImageContextWithOptions(newRect.size, false, 0.0)
  249. let aspectRatio = AVMakeRect(aspectRatio: image.size, insideRect: newRect)
  250. var targetOrigin: CGPoint = .zero
  251. if center {
  252. targetOrigin = CGPoint(x: newRect.maxX / 2 - aspectRatio.width / 2, y: newRect.maxY / 2 - aspectRatio.height / 2)
  253. }
  254. image.draw(in: CGRect(origin: targetOrigin, size: aspectRatio.size))
  255. let newImage = UIGraphicsGetImageFromCurrentImageContext()
  256. UIGraphicsEndImageContext()
  257. return newImage
  258. }
  259. public static func getImage(withString string: String, withBackgroundColor color: UIColor, withBounds bounds: CGRect, isCircular circular: Bool) -> UIImage? {
  260. // Based on the "UIImageView+Letters" library from Tom Bachant
  261. let fontSize = bounds.width * 0.5
  262. var displayString = ""
  263. let word = string.components(separatedBy: .whitespacesAndNewlines)
  264. // Get first letter of the first word
  265. if let firstWord = word.first, !firstWord.isEmpty, let firstCharacter = firstWord.first {
  266. displayString.append(firstCharacter)
  267. }
  268. let scale = Float(UIScreen.main.scale)
  269. let width = floorf(Float(bounds.size.width) * scale) / scale
  270. let height = floorf(Float(bounds.size.height) * scale) / scale
  271. let size = CGSize(width: CGFloat(width), height: CGFloat(height))
  272. UIGraphicsBeginImageContextWithOptions(size, false, CGFloat(scale))
  273. let context = UIGraphicsGetCurrentContext()!
  274. if circular {
  275. // Clip context to a circle
  276. let path = CGPath(ellipseIn: bounds, transform: nil)
  277. context.addPath(path)
  278. context.clip()
  279. }
  280. // Fill background of context
  281. context.setFillColor(color.cgColor)
  282. context.fill(CGRect(x: 0, y: 0, width: size.width, height: size.height))
  283. // Draw text in the context
  284. displayString = displayString.uppercased()
  285. let textAttributes = [
  286. NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: fontSize),
  287. NSAttributedString.Key.foregroundColor: UIColor.white
  288. ]
  289. let textSize = displayString.size(withAttributes: textAttributes)
  290. let textRect = CGRect(x: bounds.size.width / 2 - textSize.width / 2,
  291. y: bounds.size.height / 2 - textSize.height / 2,
  292. width: textSize.width,
  293. height: textSize.height)
  294. displayString.draw(in: textRect, withAttributes: textAttributes)
  295. let snapshot = UIGraphicsGetImageFromCurrentImageContext()
  296. UIGraphicsEndImageContext()
  297. return snapshot
  298. }
  299. // MARK: - Color utils
  300. public static func searchbarBGColor(forColor color: UIColor) -> UIColor {
  301. let luma = self.calculateLuma(fromColor: color)
  302. return (luma > 0.6) ? UIColor(white: 0, alpha: 0.1) : UIColor(white: 1, alpha: 0.2)
  303. }
  304. public static func calculateLuma(fromColor color: UIColor) -> CGFloat {
  305. var red: CGFloat = 0
  306. var green: CGFloat = 0
  307. var blue: CGFloat = 0
  308. var alpha: CGFloat = 0
  309. color.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
  310. return (0.2126 * red + 0.7152 * green + 0.0722 * blue)
  311. }
  312. public static func color(fromHexString hexString: String) -> UIColor? {
  313. if hexString.isEmpty {
  314. return nil
  315. }
  316. // Check hex color string format (e.g."#00FF00")
  317. guard let regex = try? NSRegularExpression(pattern: "^#(?:[0-9a-fA-F]{6})$", options: [.caseInsensitive]),
  318. let match = regex.firstMatch(in: hexString, range: NSRange(location: 0, length: hexString.count))
  319. else { return nil }
  320. if match.numberOfRanges != 1 {
  321. return nil
  322. }
  323. // Convert Hex color to UIColor
  324. var rgbValue: UInt64 = 0
  325. let scanner = Scanner(string: hexString)
  326. scanner.scanLocation = 1
  327. scanner.scanHexInt64(&rgbValue)
  328. let red = CGFloat((rgbValue & 0xFF0000) >> 16)/255.0
  329. let green = CGFloat((rgbValue & 0xFF00) >> 8)/255.0
  330. let blue = CGFloat(rgbValue & 0xFF)/255.0
  331. return UIColor(red: red, green: green, blue: blue, alpha: 1)
  332. }
  333. public static func hexString(fromColor color: UIColor) -> String {
  334. // See: https://stackoverflow.com/a/39358741
  335. var red: CGFloat = 0
  336. var green: CGFloat = 0
  337. var blue: CGFloat = 0
  338. var alpha: CGFloat = 0
  339. let multiplier = CGFloat(255.999999)
  340. guard color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
  341. return ""
  342. }
  343. // We don't expect an alpha component right now
  344. return String(
  345. format: "#%02lX%02lX%02lX",
  346. min(Int(red * multiplier), 255),
  347. min(Int(green * multiplier), 255),
  348. min(Int(blue * multiplier), 255)
  349. )
  350. }
  351. // MARK: - UITableView utils
  352. public static func isValid(indexPath: IndexPath, forTableView tableView: UITableView) -> Bool {
  353. indexPath.section < tableView.numberOfSections && indexPath.row < tableView.numberOfRows(inSection: indexPath.section)
  354. }
  355. // MARK: - QueryItems utils
  356. public static func value(forKey key: String, fromQueryItems queryItems: NSArray) -> String? {
  357. let predicate = NSPredicate(format: "name=%@", key)
  358. if let queryItem = queryItems.filtered(using: predicate).first as? NSURLQueryItem {
  359. return queryItem.value
  360. }
  361. return nil
  362. }
  363. // MARK: - Logging
  364. private static var logfilePath: URL? = {
  365. guard let documentDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
  366. else { return nil }
  367. let fileManager = FileManager.default
  368. let logDir = documentDir.appendingPathComponent("logs")
  369. let logPath = logDir.path
  370. // Allow writing to files while the app is in the background
  371. if !fileManager.fileExists(atPath: logPath) {
  372. try? fileManager.createDirectory(atPath: logPath, withIntermediateDirectories: true, attributes: [FileAttributeKey.protectionKey: FileProtectionType.none])
  373. }
  374. return logDir
  375. }()
  376. public static func removeOldLogfiles() {
  377. guard let logfilePath else { return }
  378. let logPath = logfilePath.path
  379. let fileManager = FileManager.default
  380. var dayComponent = DateComponents()
  381. dayComponent.day = -10
  382. guard let enumerator = fileManager.enumerator(atPath: logPath),
  383. let thresholdDate = Calendar.current.date(byAdding: dayComponent, to: Date())
  384. else { return }
  385. while let file = enumerator.nextObject() as? String {
  386. let filePathURL = logfilePath.appendingPathComponent(file)
  387. let filePath = filePathURL.path
  388. guard let creationDate = (try? FileManager.default.attributesOfItem(atPath: filePath))?[.creationDate] as? Date
  389. else { continue }
  390. if creationDate.compare(thresholdDate) == .orderedAscending && file.hasPrefix("debug-") && file.hasSuffix(".log") {
  391. NSLog("Deleting old logfile %@", filePath)
  392. try? fileManager.removeItem(atPath: filePath)
  393. }
  394. }
  395. }
  396. public static func log(_ message: String) {
  397. do {
  398. guard let logfilePath else { return }
  399. let currentQueueName = Thread.current.queueName
  400. let dateFormatter = DateFormatter()
  401. dateFormatter.dateFormat = "y-MM-dd H:mm:ss.SSSS"
  402. var logMessage = "\(dateFormatter.string(from: Date())) "
  403. logMessage += "(\(currentQueueName)): \(message)\n"
  404. dateFormatter.dateFormat = "yyyy-MM-dd"
  405. let dateString = dateFormatter.string(from: Date())
  406. let logFileName = "debug-\(dateString).log"
  407. let fullPath = logfilePath.appendingPathComponent(logFileName).path
  408. if let fileHandle = FileHandle(forWritingAtPath: fullPath) {
  409. fileHandle.seekToEndOfFile()
  410. // UTF-8 will never be nil
  411. try fileHandle.write(contentsOf: logMessage.data(using: .utf8)!)
  412. try fileHandle.close()
  413. } else {
  414. try logMessage.write(toFile: fullPath, atomically: false, encoding: .utf8)
  415. }
  416. NSLog("%@", logMessage)
  417. } catch {
  418. NSLog("Exception in NCUtils.log: %@", error.localizedDescription)
  419. NSLog("Message: %@", message)
  420. }
  421. }
  422. // MARK: - iOS on Mac
  423. public static func isiOSAppOnMac() -> Bool {
  424. return ProcessInfo.processInfo.isiOSAppOnMac
  425. }
  426. }
  427. extension Thread {
  428. var threadName: String {
  429. if isMainThread {
  430. return "main"
  431. } else if let threadName = Thread.current.name, !threadName.isEmpty {
  432. return threadName
  433. } else {
  434. return description
  435. }
  436. }
  437. var queueName: String {
  438. if let queueName = String(validatingUTF8: __dispatch_queue_get_label(nil)) {
  439. return queueName
  440. } else if let operationQueueName = OperationQueue.current?.name, !operationQueueName.isEmpty {
  441. return operationQueueName
  442. } else if let dispatchQueueName = OperationQueue.current?.underlyingQueue?.label, !dispatchQueueName.isEmpty {
  443. return dispatchQueueName
  444. } else {
  445. return "n/a"
  446. }
  447. }
  448. }