зеркало из https://github.com/nextcloud/talk-ios.git
567 строки
20 KiB
Swift
567 строки
20 KiB
Swift
//
|
|
// SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
//
|
|
|
|
import Foundation
|
|
import CommonCrypto
|
|
import MobileCoreServices
|
|
import UniformTypeIdentifiers
|
|
import AVFoundation
|
|
|
|
@objcMembers public class NCUtils: NSObject {
|
|
|
|
private static let nextcloudScheme = "nextcloud:"
|
|
|
|
public static func previewImage(forFileExtension fileExtension: String) -> String {
|
|
return previewImage(forFileType: UTType(filenameExtension: fileExtension))
|
|
}
|
|
|
|
public static func previewImage(forMimeType mimeType: String?) -> String {
|
|
guard let mimeType else { return "file" }
|
|
|
|
return self.previewImage(forFileType: UTType(mimeType: mimeType))
|
|
}
|
|
|
|
// swiftlint:disable:next cyclomatic_complexity
|
|
public static func previewImage(forFileType fileType: UTType?) -> String {
|
|
guard let fileType else { return "file" }
|
|
|
|
if let mimeType = fileType.preferredMIMEType {
|
|
if mimeType.contains("org.openxmlformats") || mimeType.contains("org.oasis-open.opendocument") ||
|
|
mimeType.contains("officedocument.wordprocessingml") {
|
|
|
|
return "file-document"
|
|
} else if mimeType == "httpd/unix-directory" {
|
|
return "folder"
|
|
}
|
|
}
|
|
|
|
if !fileType.isDeclared {
|
|
return "file"
|
|
}
|
|
|
|
if fileType.conforms(to: .audio) {
|
|
return "file-audio"
|
|
} else if fileType.conforms(to: .movie) {
|
|
return "file-video"
|
|
} else if fileType.conforms(to: .image) {
|
|
return "file-image"
|
|
} else if fileType.conforms(to: .spreadsheet) {
|
|
return "file-spreadsheet"
|
|
} else if fileType.conforms(to: .presentation) {
|
|
return "file-presentation"
|
|
} else if fileType.conforms(to: .pdf) {
|
|
return "file-pdf"
|
|
} else if fileType.conforms(to: .vCard) {
|
|
return "file-vcard"
|
|
} else if fileType.conforms(to: .text) {
|
|
return "file-text"
|
|
} else if fileType.conforms(to: .zip) {
|
|
return "file-zip"
|
|
}
|
|
|
|
return "file"
|
|
}
|
|
|
|
public static func isImage(fileType: String) -> Bool {
|
|
return self.previewImage(forMimeType: fileType) == "file-image"
|
|
}
|
|
|
|
public static func isImage(fileExtension: String) -> Bool {
|
|
return self.previewImage(forFileExtension: fileExtension) == "file-image"
|
|
}
|
|
|
|
public static func isVideo(fileType: String) -> Bool {
|
|
return self.previewImage(forMimeType: fileType) == "file-video"
|
|
}
|
|
|
|
public static func isAudio(fileType: String) -> Bool {
|
|
return self.previewImage(forMimeType: fileType) == "file-audio"
|
|
}
|
|
|
|
public static func isVCard(fileType: String) -> Bool {
|
|
return self.previewImage(forMimeType: fileType) == "file-vcard"
|
|
}
|
|
|
|
public static func isGif(fileType: String) -> Bool {
|
|
return UTType(mimeType: fileType)?.conforms(to: .gif) ?? false
|
|
}
|
|
|
|
public static func isNextcloudAppInstalled() -> Bool {
|
|
var isInstalled = false
|
|
|
|
#if !APP_EXTENSION
|
|
if let URL = URL(string: nextcloudScheme) {
|
|
isInstalled = UIApplication.shared.canOpenURL(URL)
|
|
}
|
|
#endif
|
|
return isInstalled
|
|
}
|
|
|
|
public static func openFileInNextcloudApp(path: String, withFileLink link: String) {
|
|
#if !APP_EXTENSION
|
|
if !self.isNextcloudAppInstalled() {
|
|
return
|
|
}
|
|
|
|
let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
|
|
let nextcloudURLString = "\(nextcloudScheme)//open-file?path=\(path)&user=\(activeAccount.userId)&link=\(link)"
|
|
if let nextcloudEncodedURLString = nextcloudURLString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
|
let nextcloudURL = URL(string: nextcloudEncodedURLString) {
|
|
|
|
UIApplication.shared.open(nextcloudURL)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
public static func openFileInNextcloudAppOrBrowser(path: String, withFileLink link: String) {
|
|
#if !APP_EXTENSION
|
|
if self.isNextcloudAppInstalled() {
|
|
self.openFileInNextcloudApp(path: path, withFileLink: link)
|
|
} else {
|
|
self.openLinkInBrowser(link: link)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
public static func openLinkInBrowser(link: String) {
|
|
#if !APP_EXTENSION
|
|
guard let URL = URL(string: link) else { return }
|
|
|
|
UIApplication.shared.open(URL)
|
|
#endif
|
|
}
|
|
|
|
public static func isInstanceRoomLink(link: String) -> Bool {
|
|
let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
|
|
let roomPrefix1 = "\(activeAccount.server)/call"
|
|
let roomPrefix2 = "\(activeAccount.server)/index.php/call"
|
|
|
|
return link.lowercased().contains(roomPrefix1) || link.lowercased().contains(roomPrefix2)
|
|
}
|
|
|
|
// MARK: - Date utils
|
|
|
|
public static func dateFromDateAtomFormat(dateAtomFormatString: String) -> Date? {
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
|
|
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
|
|
|
|
return dateFormatter.date(from: dateAtomFormatString)
|
|
}
|
|
|
|
public static func dateAtomFormatFromDate(date: Date) -> String {
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
|
|
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
|
|
|
|
return dateFormatter.string(from: date)
|
|
}
|
|
|
|
public static func readableDateTime(fromDate date: Date) -> String {
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.dateStyle = .medium
|
|
dateFormatter.timeStyle = .short
|
|
dateFormatter.doesRelativeDateFormatting = true
|
|
|
|
return dateFormatter.string(from: date)
|
|
}
|
|
|
|
public static func readableTimeOrDate(fromDate date: Date) -> String {
|
|
if Calendar.current.isDateInToday(date) {
|
|
return self.getTime(fromDate: date)
|
|
} else if Calendar.current.isDateInYesterday(date) {
|
|
return NSLocalizedString("Yesterday", comment: "")
|
|
}
|
|
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.dateStyle = .short
|
|
dateFormatter.timeStyle = .none
|
|
|
|
return dateFormatter.string(from: date)
|
|
}
|
|
|
|
public static func readableTimeAndDate(fromDate date: Date) -> String {
|
|
if Calendar.current.isDateInToday(date) {
|
|
return self.getTime(fromDate: date)
|
|
}
|
|
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.dateStyle = .medium
|
|
dateFormatter.timeStyle = .short
|
|
|
|
return dateFormatter.string(from: date)
|
|
}
|
|
|
|
public static func getTime(fromDate date: Date) -> String {
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.dateStyle = .none
|
|
dateFormatter.timeStyle = .short
|
|
|
|
return dateFormatter.string(from: date)
|
|
}
|
|
|
|
public static func relativeTimeFromDate(date: Date) -> String {
|
|
let todayDate = Date()
|
|
var ti = date.timeIntervalSince(todayDate)
|
|
ti *= -1
|
|
|
|
if ti < 60 {
|
|
// This minute
|
|
return NSLocalizedString("less than a minute ago", comment: "")
|
|
} else if ti < 3600 {
|
|
// This hour
|
|
let diff = Int(round(ti / 60))
|
|
return String(format: NSLocalizedString("%d minutes ago", comment: ""), diff)
|
|
} else if ti < 86400 {
|
|
// This day
|
|
let diff = Int(round(ti / 60 / 60))
|
|
return String(format: NSLocalizedString("%d hours ago", comment: ""), diff)
|
|
} else if ti < 86400 * 30 {
|
|
// This month
|
|
let diff = Int(round(ti / 60 / 60 / 24))
|
|
return String(format: NSLocalizedString("%d days ago", comment: ""), diff)
|
|
}
|
|
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.formatterBehavior = .behavior10_4
|
|
dateFormatter.dateStyle = .medium
|
|
|
|
return dateFormatter.string(from: date)
|
|
}
|
|
|
|
public static func today(withHour hour: Int, withMinute minute: Int, withSecond second: Int) -> Date? {
|
|
let calendar = Calendar.current
|
|
let now = Date()
|
|
var components = calendar.dateComponents([.year, .month, .day], from: now)
|
|
components.hour = hour
|
|
components.minute = minute
|
|
components.second = second
|
|
|
|
return calendar.date(from: components)
|
|
}
|
|
|
|
public static func setWeekday(_ weekday: Int, withDate date: Date) -> Date {
|
|
let currentWeekday = Calendar.current.component(.weekday, from: date)
|
|
return Calendar.current.date(byAdding: .day, value: (weekday - currentWeekday), to: date)!
|
|
}
|
|
|
|
// MARK: - Crypto utils
|
|
|
|
public static func sha1(fromString string: String) -> String {
|
|
let data = string.data(using: .utf8)!
|
|
var digest = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
|
|
data.withUnsafeBytes {
|
|
_ = CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest)
|
|
}
|
|
let hexBytes = digest.map { String(format: "%02hhx", $0) }
|
|
return hexBytes.joined()
|
|
}
|
|
|
|
// MARK: - Image utils
|
|
|
|
public static func blurImage(fromImage image: UIImage) -> UIImage? {
|
|
let inputRadius = 8.0
|
|
|
|
guard let inputImage = CIImage(image: image),
|
|
let filter = CIFilter(name: "CIGaussianBlur")
|
|
else { return nil }
|
|
|
|
let context = CIContext()
|
|
filter.setValue(inputImage, forKey: kCIInputImageKey)
|
|
filter.setValue(inputRadius, forKey: "inputRadius")
|
|
|
|
guard let result = filter.value(forKey: kCIOutputImageKey) as? CIImage else { return nil }
|
|
|
|
let imageRect = inputImage.extent
|
|
let cropRect = CGRect(x: imageRect.origin.x + inputRadius, y: imageRect.origin.y + inputRadius, width: imageRect.width - inputRadius * 2, height: imageRect.height - inputRadius * 2)
|
|
|
|
if let cgImage = context.createCGImage(result, from: imageRect)?.cropping(to: cropRect) {
|
|
return UIImage(cgImage: cgImage)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
public static func roundedImage(fromImage image: UIImage) -> UIImage {
|
|
let imageSize = image.size
|
|
let rect = CGRect(x: 0, y: 0, width: imageSize.width, height: imageSize.width)
|
|
|
|
UIGraphicsBeginImageContextWithOptions(imageSize, false, UIScreen.main.scale)
|
|
UIBezierPath(roundedRect: rect, cornerRadius: imageSize.height).addClip()
|
|
image.draw(in: rect)
|
|
|
|
if let resultImage = UIGraphicsGetImageFromCurrentImageContext() {
|
|
UIGraphicsEndImageContext()
|
|
return resultImage
|
|
}
|
|
|
|
UIGraphicsEndImageContext()
|
|
return image
|
|
}
|
|
|
|
public static func renderAspectImage(image: UIImage?, ofSize size: CGSize, centerImage center: Bool) -> UIImage? {
|
|
guard let image else { return nil }
|
|
|
|
let newRect = CGRect(x: 0, y: 0, width: size.width, height: size.height)
|
|
UIGraphicsBeginImageContextWithOptions(newRect.size, false, 0.0)
|
|
|
|
let aspectRatio = AVMakeRect(aspectRatio: image.size, insideRect: newRect)
|
|
var targetOrigin: CGPoint = .zero
|
|
|
|
if center {
|
|
targetOrigin = CGPoint(x: newRect.maxX / 2 - aspectRatio.width / 2, y: newRect.maxY / 2 - aspectRatio.height / 2)
|
|
}
|
|
|
|
image.draw(in: CGRect(origin: targetOrigin, size: aspectRatio.size))
|
|
let newImage = UIGraphicsGetImageFromCurrentImageContext()
|
|
UIGraphicsEndImageContext()
|
|
|
|
return newImage
|
|
}
|
|
|
|
public static func getImage(withString string: String, withBackgroundColor color: UIColor, withBounds bounds: CGRect, isCircular circular: Bool) -> UIImage? {
|
|
// Based on the "UIImageView+Letters" library from Tom Bachant
|
|
|
|
let fontSize = bounds.width * 0.5
|
|
var displayString = ""
|
|
|
|
let word = string.components(separatedBy: .whitespacesAndNewlines)
|
|
|
|
// Get first letter of the first word
|
|
if let firstWord = word.first, !firstWord.isEmpty, let firstCharacter = firstWord.first {
|
|
displayString.append(firstCharacter)
|
|
}
|
|
|
|
let scale = Float(UIScreen.main.scale)
|
|
let width = floorf(Float(bounds.size.width) * scale) / scale
|
|
let height = floorf(Float(bounds.size.height) * scale) / scale
|
|
let size = CGSize(width: CGFloat(width), height: CGFloat(height))
|
|
|
|
UIGraphicsBeginImageContextWithOptions(size, false, CGFloat(scale))
|
|
let context = UIGraphicsGetCurrentContext()!
|
|
|
|
if circular {
|
|
// Clip context to a circle
|
|
let path = CGPath(ellipseIn: bounds, transform: nil)
|
|
context.addPath(path)
|
|
context.clip()
|
|
}
|
|
|
|
// Fill background of context
|
|
context.setFillColor(color.cgColor)
|
|
context.fill(CGRect(x: 0, y: 0, width: size.width, height: size.height))
|
|
|
|
// Draw text in the context
|
|
displayString = displayString.uppercased()
|
|
|
|
let textAttributes = [
|
|
NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: fontSize),
|
|
NSAttributedString.Key.foregroundColor: UIColor.white
|
|
]
|
|
|
|
let textSize = displayString.size(withAttributes: textAttributes)
|
|
let textRect = CGRect(x: bounds.size.width / 2 - textSize.width / 2,
|
|
y: bounds.size.height / 2 - textSize.height / 2,
|
|
width: textSize.width,
|
|
height: textSize.height)
|
|
|
|
displayString.draw(in: textRect, withAttributes: textAttributes)
|
|
|
|
let snapshot = UIGraphicsGetImageFromCurrentImageContext()
|
|
UIGraphicsEndImageContext()
|
|
|
|
return snapshot
|
|
}
|
|
|
|
// MARK: - Color utils
|
|
|
|
public static func searchbarBGColor(forColor color: UIColor) -> UIColor {
|
|
let luma = self.calculateLuma(fromColor: color)
|
|
return (luma > 0.6) ? UIColor(white: 0, alpha: 0.1) : UIColor(white: 1, alpha: 0.2)
|
|
}
|
|
|
|
public static func calculateLuma(fromColor color: UIColor) -> CGFloat {
|
|
var red: CGFloat = 0
|
|
var green: CGFloat = 0
|
|
var blue: CGFloat = 0
|
|
var alpha: CGFloat = 0
|
|
|
|
color.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
|
return (0.2126 * red + 0.7152 * green + 0.0722 * blue)
|
|
}
|
|
|
|
public static func color(fromHexString hexString: String) -> UIColor? {
|
|
if hexString.isEmpty {
|
|
return nil
|
|
}
|
|
|
|
// Check hex color string format (e.g."#00FF00")
|
|
guard let regex = try? NSRegularExpression(pattern: "^#(?:[0-9a-fA-F]{6})$", options: [.caseInsensitive]),
|
|
let match = regex.firstMatch(in: hexString, range: NSRange(location: 0, length: hexString.count))
|
|
else { return nil }
|
|
|
|
if match.numberOfRanges != 1 {
|
|
return nil
|
|
}
|
|
|
|
// Convert Hex color to UIColor
|
|
var rgbValue: UInt64 = 0
|
|
let scanner = Scanner(string: hexString)
|
|
scanner.scanLocation = 1
|
|
scanner.scanHexInt64(&rgbValue)
|
|
|
|
let red = CGFloat((rgbValue & 0xFF0000) >> 16)/255.0
|
|
let green = CGFloat((rgbValue & 0xFF00) >> 8)/255.0
|
|
let blue = CGFloat(rgbValue & 0xFF)/255.0
|
|
return UIColor(red: red, green: green, blue: blue, alpha: 1)
|
|
}
|
|
|
|
public static func hexString(fromColor color: UIColor) -> String {
|
|
// See: https://stackoverflow.com/a/39358741
|
|
var red: CGFloat = 0
|
|
var green: CGFloat = 0
|
|
var blue: CGFloat = 0
|
|
var alpha: CGFloat = 0
|
|
|
|
let multiplier = CGFloat(255.999999)
|
|
|
|
guard color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
|
|
return ""
|
|
}
|
|
|
|
// We don't expect an alpha component right now
|
|
return String(
|
|
format: "#%02lX%02lX%02lX",
|
|
min(Int(red * multiplier), 255),
|
|
min(Int(green * multiplier), 255),
|
|
min(Int(blue * multiplier), 255)
|
|
)
|
|
}
|
|
|
|
// MARK: - UITableView utils
|
|
|
|
public static func isValid(indexPath: IndexPath, forTableView tableView: UITableView) -> Bool {
|
|
indexPath.section < tableView.numberOfSections && indexPath.row < tableView.numberOfRows(inSection: indexPath.section)
|
|
}
|
|
|
|
// MARK: - QueryItems utils
|
|
|
|
public static func value(forKey key: String, fromQueryItems queryItems: NSArray) -> String? {
|
|
let predicate = NSPredicate(format: "name=%@", key)
|
|
if let queryItem = queryItems.filtered(using: predicate).first as? NSURLQueryItem {
|
|
return queryItem.value
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Logging
|
|
|
|
private static var logfilePath: URL? = {
|
|
guard let documentDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
|
|
else { return nil }
|
|
|
|
let fileManager = FileManager.default
|
|
let logDir = documentDir.appendingPathComponent("logs")
|
|
let logPath = logDir.path
|
|
|
|
// Allow writing to files while the app is in the background
|
|
if !fileManager.fileExists(atPath: logPath) {
|
|
try? fileManager.createDirectory(atPath: logPath, withIntermediateDirectories: true, attributes: [FileAttributeKey.protectionKey: FileProtectionType.none])
|
|
}
|
|
|
|
return logDir
|
|
}()
|
|
|
|
public static func removeOldLogfiles() {
|
|
guard let logfilePath else { return }
|
|
|
|
let logPath = logfilePath.path
|
|
let fileManager = FileManager.default
|
|
|
|
var dayComponent = DateComponents()
|
|
dayComponent.day = -10
|
|
|
|
guard let enumerator = fileManager.enumerator(atPath: logPath),
|
|
let thresholdDate = Calendar.current.date(byAdding: dayComponent, to: Date())
|
|
else { return }
|
|
|
|
while let file = enumerator.nextObject() as? String {
|
|
let filePathURL = logfilePath.appendingPathComponent(file)
|
|
let filePath = filePathURL.path
|
|
|
|
guard let creationDate = (try? FileManager.default.attributesOfItem(atPath: filePath))?[.creationDate] as? Date
|
|
else { continue }
|
|
|
|
if creationDate.compare(thresholdDate) == .orderedAscending && file.hasPrefix("debug-") && file.hasSuffix(".log") {
|
|
NSLog("Deleting old logfile %@", filePath)
|
|
try? fileManager.removeItem(atPath: filePath)
|
|
}
|
|
}
|
|
}
|
|
|
|
public static func log(_ message: String) {
|
|
do {
|
|
guard let logfilePath else { return }
|
|
|
|
let currentQueueName = Thread.current.queueName
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.dateFormat = "y-MM-dd H:mm:ss.SSSS"
|
|
|
|
var logMessage = "\(dateFormatter.string(from: Date())) "
|
|
logMessage += "(\(currentQueueName)): \(message)\n"
|
|
|
|
dateFormatter.dateFormat = "yyyy-MM-dd"
|
|
let dateString = dateFormatter.string(from: Date())
|
|
let logFileName = "debug-\(dateString).log"
|
|
let fullPath = logfilePath.appendingPathComponent(logFileName).path
|
|
|
|
if let fileHandle = FileHandle(forWritingAtPath: fullPath) {
|
|
fileHandle.seekToEndOfFile()
|
|
// UTF-8 will never be nil
|
|
try fileHandle.write(contentsOf: logMessage.data(using: .utf8)!)
|
|
try fileHandle.close()
|
|
} else {
|
|
try logMessage.write(toFile: fullPath, atomically: false, encoding: .utf8)
|
|
}
|
|
|
|
NSLog("%@", logMessage)
|
|
} catch {
|
|
NSLog("Exception in NCUtils.log: %@", error.localizedDescription)
|
|
NSLog("Message: %@", message)
|
|
}
|
|
}
|
|
|
|
// MARK: - iOS on Mac
|
|
|
|
public static func isiOSAppOnMac() -> Bool {
|
|
return ProcessInfo.processInfo.isiOSAppOnMac
|
|
}
|
|
}
|
|
|
|
extension Thread {
|
|
var threadName: String {
|
|
if isMainThread {
|
|
return "main"
|
|
} else if let threadName = Thread.current.name, !threadName.isEmpty {
|
|
return threadName
|
|
} else {
|
|
return description
|
|
}
|
|
}
|
|
|
|
var queueName: String {
|
|
if let queueName = String(validatingUTF8: __dispatch_queue_get_label(nil)) {
|
|
return queueName
|
|
} else if let operationQueueName = OperationQueue.current?.name, !operationQueueName.isEmpty {
|
|
return operationQueueName
|
|
} else if let dispatchQueueName = OperationQueue.current?.underlyingQueue?.label, !dispatchQueueName.isEmpty {
|
|
return dispatchQueueName
|
|
} else {
|
|
return "n/a"
|
|
}
|
|
}
|
|
}
|