TM-SGNL-iOS/SignalServiceKit/Util/DataSource.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

398 lines
12 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
/// A base class that abstracts away a source of NSData
/// and allows us to:
///
/// * Lazy-load if possible.
/// * Avoid duplicate reads & writes.
@objc
public protocol DataSource {
var sourceFilename: String? { get set }
//// Should not be called unless necessary as it can involve an expensive read.
var data: Data { get }
/// The URL for the data. Should always be a File URL.
///
/// Should not be called unless necessary as it can involve an expensive write.
///
/// Will only return nil in the error case.
var dataUrl: URL? { get }
/// Will return zero in the error case.
var dataLength: UInt { get }
var isValidImage: Bool { get }
var isValidVideo: Bool { get }
var hasStickerLikeProperties: Bool { get }
var imageMetadata: ImageMetadata { get }
func writeTo(_ dstUrl: URL) throws
/// Faster than `writeTo(_:)`, but a DataSource can only be moved once,
/// and cannot be used after it's been moved.
func moveToUrlAndConsume(_ dstUrl: URL) throws
func consumeAndDelete() throws
}
// MARK: -
public class DataSourceValue: DataSource {
public init(_ data: Data, fileExtension: String) {
self.data = data
self.fileExtension = fileExtension
self._isConsumed = false
// TODO: Figure out if it's actually necessary to write to disk here; objc was triggering background write to disk so we kept this behavior, but it may not be necessary.
DispatchQueue.global().async { [weak self] in
// relies on the side effect of this computed property writing to disk; don't shoot the messenger...
_ = self?.dataUrl
}
}
public convenience init?(_ data: Data, utiType: String) {
guard let fileExtension = MimeTypeUtil.fileExtensionForUtiType(utiType) else {
return nil
}
self.init(data, fileExtension: fileExtension)
}
public convenience init?(_ data: Data, mimeType: String) {
guard let fileExtension = MimeTypeUtil.fileExtensionForMimeType(mimeType) else {
return nil
}
self.init(data, fileExtension: fileExtension)
}
public convenience init(oversizeText: String) {
let data = Data(oversizeText.filterForDisplay.utf8)
self.init(data, fileExtension: MimeTypeUtil.oversizeTextAttachmentFileExtension)
}
/// Initializes a new empty `DataSourceValue`.
public convenience init() {
self.init(Data(), fileExtension: "bin")
}
deinit {
if let _dataUrl {
try? OWSFileSystem.deleteFileIfExists(url: _dataUrl)
}
}
public let data: Data
private let fileExtension: String
private let lock = NSRecursiveLock()
/// Should only be accessed while holding `lock`.
private var _isConsumed: Bool
private var isConsumed: Bool {
get {
return lock.withLock {
return _isConsumed
}
}
set {
lock.withLock {
_isConsumed = newValue
}
}
}
/// This property is lazily-populated.
/// Should only be accessed while holding `lock`.
private var _imageMetadata: ImageMetadata?
public var imageMetadata: ImageMetadata {
return lock.withLock {
owsAssertDebug(!_isConsumed)
if let _imageMetadata {
return _imageMetadata
}
let mimeType = MimeTypeUtil.mimeTypeForFileExtension(fileExtension)
let cachedImageMetadata = data.imageMetadata(withPath: nil, mimeType: mimeType, ignoreFileSize: true)
_imageMetadata = cachedImageMetadata
return cachedImageMetadata
}
}
private var _sourceFilename: String?
public var sourceFilename: String? {
get {
return _sourceFilename
}
set {
owsAssertDebug(!isConsumed)
_sourceFilename = newValue?.filterFilename()
}
}
/// This property is lazily-populated.
/// Should only be accessed while holding `lock`.
private var _dataUrl: URL?
public var dataUrl: URL? {
lock.withLock {
owsAssertDebug(!_isConsumed)
if let _dataUrl {
return _dataUrl
}
let fileUrl = OWSFileSystem.temporaryFileUrl(fileExtension: fileExtension, isAvailableWhileDeviceLocked: true)
if (try? writeTo(fileUrl)) != nil {
_dataUrl = fileUrl
} else {
owsFailDebug("Could not write data to disk.")
}
return _dataUrl
}
}
public var dataLength: UInt {
owsAssertDebug(!isConsumed)
return UInt(data.count)
}
public func writeTo(_ dstUrl: URL) throws {
owsAssertDebug(!isConsumed)
do {
try data.write(to: dstUrl, options: .atomic)
} catch {
owsFailDebug("Could not write data to disk: \(error)")
throw error
}
}
public func moveToUrlAndConsume(_ dstUrl: URL) throws {
try lock.withLock {
owsAssertDebug(!_isConsumed)
owsAssertDebug(!Thread.isMainThread)
// This method is meant to be fast. If _dataUrl is nil,
// we'll still lazily generate it and this method will work,
// but it will be slower than expected.
owsAssertDebug(_dataUrl != nil)
guard let dataUrl else {
throw OWSAssertionError("Missing data URL.")
}
_dataUrl = nil
_isConsumed = true
do {
try OWSFileSystem.moveFile(from: dataUrl, to: dstUrl)
} catch {
owsFailDebug("Could not write data with error: \(error)")
throw error
}
}
}
public func consumeAndDelete() throws {
try lock.withLock {
owsAssertDebug(!_isConsumed)
_isConsumed = true
guard let _dataUrl else { return }
try OWSFileSystem.deleteFileIfExists(url: _dataUrl)
}
}
public var isValidImage: Bool {
owsAssertDebug(!isConsumed)
return data.ows_isValidImage
}
public var isValidVideo: Bool {
owsAssertDebug(!isConsumed)
guard let path = dataUrl?.path else {
return false
}
guard MimeTypeUtil.isSupportedVideoFile(path) else {
return false
}
owsFailDebug("Are we calling this anywhere? It seems quite inefficient.")
return OWSMediaUtils.isValidVideo(path: path)
}
public var hasStickerLikeProperties: Bool {
owsAssertDebug(!isConsumed)
return Data.ows_hasStickerLikeProperties(withImageMetadata: imageMetadata)
}
}
// MARK: -
public class DataSourcePath: DataSource {
public init(fileUrl: URL, shouldDeleteOnDeallocation: Bool) throws {
guard fileUrl.isFileURL else {
throw OWSAssertionError("unexpected fileUrl: \(fileUrl)")
}
self.fileUrl = fileUrl
self.shouldDeleteOnDeallocation = shouldDeleteOnDeallocation
self._isConsumed = false
}
public convenience init(filePath: String, shouldDeleteOnDeallocation: Bool) throws {
let fileUrl = URL.init(fileURLWithPath: filePath)
try self.init(fileUrl: fileUrl, shouldDeleteOnDeallocation: shouldDeleteOnDeallocation)
}
public convenience init(writingTempFileData: Data, fileExtension: String) throws {
let fileUrl = OWSFileSystem.temporaryFileUrl(fileExtension: fileExtension, isAvailableWhileDeviceLocked: true)
try writingTempFileData.write(to: fileUrl, options: .completeFileProtectionUntilFirstUserAuthentication)
try self.init(fileUrl: fileUrl, shouldDeleteOnDeallocation: true)
}
public convenience init(writingSyncMessageData: Data) throws {
try self.init(writingTempFileData: writingSyncMessageData, fileExtension: MimeTypeUtil.syncMessageFileExtension)
}
deinit {
if shouldDeleteOnDeallocation && !_isConsumed {
// In the ObjC code this would fire into a dispatch queue
do {
try FileManager.default.removeItem(at: fileUrl)
} catch {
owsFailDebug("DataSourcePath could not delete file: \(fileUrl), \(error)")
}
}
}
private var fileUrl: URL
private let shouldDeleteOnDeallocation: Bool
private let lock = NSRecursiveLock()
private var _isConsumed: Bool
private var isConsumed: Bool {
get {
return lock.withLock {
return _isConsumed
}
}
set {
lock.withLock {
_isConsumed = newValue
}
}
}
private var _sourceFilename: String?
public var sourceFilename: String? {
get {
return _sourceFilename
}
set {
owsAssertDebug(!isConsumed)
_sourceFilename = newValue?.filterFilename()
}
}
private var _data: Data?
public var data: Data {
lock.withLock {
owsAssertDebug(!_isConsumed)
if _data == nil {
_data = NSData(contentsOfFile: fileUrl.path) as Data?
}
if _data == nil {
owsFailDebug("Could not read data from disk.")
_data = Data()
}
return _data!
}
}
public var dataLength: UInt {
owsAssertDebug(!isConsumed)
do {
let values = try fileUrl.resourceValues(forKeys: [.fileSizeKey])
return UInt(values.fileSize!)
} catch {
owsFailDebug("Could not read data length from disk with error: \(error)")
return 0
}
}
public var dataUrl: URL? {
owsAssertDebug(!isConsumed)
return fileUrl
}
public var isValidImage: Bool {
owsAssertDebug(!isConsumed)
return Data.ows_isValidImage(at: fileUrl, mimeType: mimeType)
}
public var isValidVideo: Bool {
owsAssertDebug(!isConsumed)
if let mimeType {
return MimeTypeUtil.isSupportedVideoMimeType(mimeType) && OWSMediaUtils.isValidVideo(path: fileUrl.path)
} else {
return MimeTypeUtil.isSupportedVideoFile(fileUrl.path) && OWSMediaUtils.isValidVideo(path: fileUrl.path)
}
}
public var hasStickerLikeProperties: Bool {
owsAssertDebug(!isConsumed)
return Data.ows_hasStickerLikeProperties(withImageMetadata: imageMetadata)
}
private var _imageMetadata: ImageMetadata?
public var imageMetadata: ImageMetadata {
lock.withLock {
owsAssertDebug(!_isConsumed)
if let _imageMetadata {
return _imageMetadata
}
let imageMetadata = Data.imageMetadata(withPath: fileUrl.path, mimeType: mimeType, ignoreFileSize: true)
_imageMetadata = imageMetadata
return imageMetadata
}
}
public func writeTo(_ dstUrl: URL) throws {
owsAssertDebug(!isConsumed)
do {
try FileManager.default.copyItem(at: fileUrl, to: dstUrl)
} catch {
owsFailDebug("Could not write data with error: \(error)")
}
}
public func moveToUrlAndConsume(_ dstUrl: URL) throws {
try lock.withLock {
owsAssertDebug(!_isConsumed)
_isConsumed = true
do {
try OWSFileSystem.moveFile(from: fileUrl, to: dstUrl)
} catch {
Logger.error("File could not be moved. Copying instead. \(error)")
do {
try FileManager.default.copyItem(at: fileUrl, to: dstUrl)
} catch {
owsFailDebug("Could not write data with error: \(error)")
throw error
}
}
}
}
public func consumeAndDelete() throws {
try lock.withLock {
owsAssertDebug(!_isConsumed)
_isConsumed = true
try OWSFileSystem.deleteFileIfExists(url: fileUrl)
}
}
private var mimeType: String? {
owsAssertDebug(!isConsumed)
return MimeTypeUtil.mimeTypeForFileExtension(fileUrl.pathExtension)
}
}