Use distinct directories when forwarding

This commit is contained in:
Max Radermacher 2026-02-25 14:37:40 -06:00 committed by GitHub
parent ffe0dd7070
commit 86751158f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 132 additions and 244 deletions

View File

@ -68,7 +68,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
clearAppropriateNotificationsAndRestoreBadgeCount()
// On every activation, clear old temp directories.
ClearOldTemporaryDirectories()
OWSFileSystem.clearOldTemporaryDirectories()
// Ensure that all windows have the correct frame.
AppEnvironment.shared.windowManagerRef.updateWindowFrames()

View File

@ -16,7 +16,6 @@ enum SignalAttachmentCloner {
throw OWSAssertionError("Missing dataUTI.")
}
// Just use a random file name on the decrypted copy; its internal use only.
let decryptedCopyUrl = try attachment.attachmentStream.makeDecryptedCopy(
filename: attachment.reference.sourceFilename,
)

View File

@ -48,10 +48,7 @@ class DeviceTransferOperation: NSObject {
Logger.warn("Missing file for transfer, it probably disappeared or was otherwise deleted. Sending missing file placeholder.")
url = URL(
fileURLWithPath: UUID().uuidString,
relativeTo: URL(fileURLWithPath: OWSTemporaryDirectory(), isDirectory: true),
)
url = OWSFileSystem.temporaryFileUrl(isAvailableWhileDeviceLocked: false)
guard
FileManager.default.createFile(
atPath: url.path,

View File

@ -16,8 +16,8 @@ enum AttachmentSaving {
static func saveToPhotoLibrary(
referencedAttachmentStreams: [ReferencedAttachmentStream],
) {
let (assetCreationRequests, _) = referencedAttachmentStreams.reduce(
into: (requests: [PHAssetCreationRequestType](), filenames: Set<String>()),
let assetCreationRequests = referencedAttachmentStreams.reduce(
into: [PHAssetCreationRequestType](),
) { result, referencedAttachmentStream in
let reference = referencedAttachmentStream.reference
let attachmentStream = referencedAttachmentStream.attachmentStream
@ -29,16 +29,9 @@ enum AttachmentSaving {
break
}
let filename = uniqueFilename(
sourceFilename: reference.sourceFilename,
existingFilenames: &result.filenames,
)
let decryptedFileUrl: URL
do {
decryptedFileUrl = try attachmentStream.makeDecryptedCopy(
filename: filename,
)
decryptedFileUrl = try attachmentStream.makeDecryptedCopy(filename: reference.sourceFilename)
} catch let error {
owsFailDebug("Failed to save decrypted copy of attachment for photo library! \(error)")
return
@ -48,9 +41,9 @@ enum AttachmentSaving {
case .invalid, .audio, .file:
owsFail("Impossible: checked above!")
case .image, .animatedImage:
result.requests.append(.imageTempFile(tmpFileUrl: decryptedFileUrl))
result.append(.imageTempFile(tmpFileUrl: decryptedFileUrl))
case .video:
result.requests.append(.videoTempFile(tmpFileUrl: decryptedFileUrl))
result.append(.videoTempFile(tmpFileUrl: decryptedFileUrl))
}
}
@ -170,38 +163,6 @@ enum AttachmentSaving {
// MARK: -
static func uniqueFilename(
sourceFilename: String?,
existingFilenames: inout Set<String>,
) -> String? {
if
let sourceFilename,
existingFilenames.contains(sourceFilename)
{
// Avoid source filename collisions.
let pathExtension = (sourceFilename as NSString).pathExtension
let normalizedFilename = (sourceFilename as NSString)
.deletingPathExtension
.trimmingCharacters(in: .whitespaces)
var i = 0
while true {
i += 1
var newSourceFilename = normalizedFilename + "_\(i)"
newSourceFilename = (newSourceFilename as NSString).appendingPathExtension(pathExtension) ?? newSourceFilename
if !existingFilenames.contains(newSourceFilename) {
existingFilenames.insert(newSourceFilename)
return newSourceFilename
}
}
} else {
_ = sourceFilename.map { existingFilenames.insert($0) }
return sourceFilename
}
}
// MARK: -
private enum PHAssetCreationRequestType {
case image(UIImage)
case imageTempFile(tmpFileUrl: URL)

View File

@ -138,8 +138,6 @@ extension Array where Element == ReferencedAttachmentStream {
public func asShareableAttachments() throws -> [ShareableAttachment] {
var hadUrlType = false
var types = [ShareableAttachment.ShareType]()
var dedupedSourceFilenames = [String?]()
var sourceFilenamesSet = Set<String>()
for attachment in self {
let shareType = ShareableAttachment.shareType(attachment.attachmentStream)
switch shareType {
@ -149,23 +147,17 @@ extension Array where Element == ReferencedAttachmentStream {
case .image:
types.append(.image)
}
let filename = AttachmentSaving.uniqueFilename(
sourceFilename: attachment.reference.sourceFilename,
existingFilenames: &sourceFilenamesSet,
)
dedupedSourceFilenames.append(filename)
}
if hadUrlType {
// Once one of them are all file, they all have to be files.
types = [ShareableAttachment.ShareType](repeating: .decryptedFileURL, count: self.count)
}
return try zip(zip(self, types), dedupedSourceFilenames).compactMap {
let ((attachment, shareType), sourceFilename) = $0
return try zip(self, types).compactMap {
let (attachment, shareType) = $0
return try ShareableAttachment(
attachment.attachmentStream,
sourceFilename: sourceFilename,
sourceFilename: attachment.reference.sourceFilename,
shareType: shareType,
)
}

View File

@ -257,15 +257,12 @@ class RequestAccountDataReportViewController: OWSTableViewController2 {
// In practice, this doesn't work when sharing back into Signal. We don't understand why
// but suspect a platform bug (or, at best, an error message that didn't help us figure out
// the source of the problem).
let temporaryDirUrl = URL(
fileURLWithPath: OWSTemporaryDirectory(),
).appendingPathComponent(UUID().uuidString)
let temporaryFileUrl = temporaryDirUrl.appendingPathComponent(
let temporaryFileUrl = OWSFileSystem.temporaryFileUrl(
// This isn't localized because the report is *also* not localized.
"account-data.\(fileExtension)",
isDirectory: false,
fileName: "account-data",
fileExtension: fileExtension,
isAvailableWhileDeviceLocked: false,
)
OWSFileSystem.ensureDirectoryExists(temporaryDirUrl.path)
let activityItem: Any
let cleanup: () -> Void
@ -275,7 +272,7 @@ class RequestAccountDataReportViewController: OWSTableViewController2 {
activityItem = temporaryFileUrl
cleanup = {
do {
try OWSFileSystem.deleteFile(url: temporaryDirUrl)
try OWSFileSystem.deleteFile(url: temporaryFileUrl)
} catch {
owsFailBeta("Failed to delete temporary account data report file")
}

View File

@ -46,7 +46,7 @@ class InternalDiskUsageViewController: OWSTableViewController2 {
let sharedDataSize = folderSizeRecursive(ofPath: OWSFileSystem.appSharedDataDirectoryPath())
let bundleSize = folderSizeRecursive(ofPath: Bundle.main.bundlePath)
let tmpSize = folderSizeRecursive(ofPath: OWSTemporaryDirectory())
let tmpSize = folderSizeRecursive(ofPath: NSTemporaryDirectory())
}
let diskUsage: DiskUsage

View File

@ -197,7 +197,7 @@ enum DebugLogs {
let dateString = dateFormatter.string(from: Date())
let logsName = "\(dateString) \(UUID().uuidString)"
let zipDirUrl = URL(fileURLWithPath: OWSTemporaryDirectory()).appendingPathComponent(logsName)
let zipDirUrl = OWSFileSystem.temporaryFileUrl(isAvailableWhileDeviceLocked: false).appendingPathComponent(logsName)
let zipDirPath = zipDirUrl.path
OWSFileSystem.ensureDirectoryExists(zipDirPath)

View File

@ -951,29 +951,21 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio
// MARK: Temp Directory
public func ensureDownloadFolder() {
private func ensureDownloadFolder() {
// We write assets to the temporary directory so that iOS can clean them up.
// We try to eagerly clean up these assets when they are no longer in use.
let tempDirPath = OWSTemporaryDirectory()
let tempDirPath = OWSFileSystem.temporaryFilePath(isAvailableWhileDeviceLocked: false)
let dirPath = (tempDirPath as NSString).appendingPathComponent(downloadFolderName)
do {
let fileManager = FileManager.default
// Try to delete existing folder if necessary.
if fileManager.fileExists(atPath: dirPath) {
try fileManager.removeItem(atPath: dirPath)
downloadFolderPath = dirPath
}
// Try to create folder if necessary.
if !fileManager.fileExists(atPath: dirPath) {
try fileManager.createDirectory(
atPath: dirPath,
withIntermediateDirectories: true,
attributes: nil,
)
downloadFolderPath = dirPath
}
try fileManager.createDirectory(
atPath: dirPath,
withIntermediateDirectories: true,
attributes: nil,
)
downloadFolderPath = dirPath
// Don't back up ProxiedContent downloads.
OWSFileSystem.protectFileOrFolder(atPath: dirPath)

View File

@ -10,7 +10,7 @@ import Foundation
@objc
public class TestAppContext: NSObject, AppContext {
public static var testDebugLogsDirPath: String {
let dirPath = OWSTemporaryDirectory().appendingPathComponent("TestLogs")
let dirPath = OWSFileSystem.temporaryFilePath(isAvailableWhileDeviceLocked: false).appendingPathComponent("TestLogs")
OWSFileSystem.ensureDirectoryExists(dirPath)
return dirPath
}

View File

@ -5,87 +5,87 @@
import Foundation
private let owsTempDir = {
let dirPath = NSTemporaryDirectory().appendingPathComponent("ows_temp_\(UUID())")
owsPrecondition(OWSFileSystem.ensureDirectoryExists(dirPath, fileProtectionType: .complete))
return dirPath
}()
public enum OWSFileSystem {
/// Use instead of NSTemporaryDirectory()
/// prefer the more restrictice OWSTemporaryDirectory,
/// unless the temp data may need to be accessed while the device is locked.
public func OWSTemporaryDirectory() -> String {
return owsTempDir
}
private static let tempDirComplete = {
let dirPath = NSTemporaryDirectory().appendingPathComponent("ows_temp_\(UUID())")
owsPrecondition(OWSFileSystem.ensureDirectoryExists(dirPath, fileProtectionType: .complete))
return dirPath
}()
public func OWSTemporaryDirectoryAccessibleAfterFirstAuth() -> String {
let dirPath = NSTemporaryDirectory()
owsPrecondition(OWSFileSystem.ensureDirectoryExists(dirPath, fileProtectionType: .completeUntilFirstUserAuthentication))
return dirPath
}
private static let tempDirAfterFirstUnlock = {
let tmpPath = NSTemporaryDirectory()
owsPrecondition(OWSFileSystem.ensureDirectoryExists(tmpPath, fileProtectionType: .completeUntilFirstUserAuthentication))
let dirPath = tmpPath.appendingPathComponent("ows_temp_\(UUID())")
owsPrecondition(OWSFileSystem.ensureDirectoryExists(dirPath, fileProtectionType: .completeUntilFirstUserAuthentication))
return dirPath
}()
private let cleanTmpDispatchQueue = DispatchQueue(label: "org.signal.clean-tmp", qos: .utility)
/// > NOTE: We need to call this method on launch _and_ every time the app becomes active,
/// > since file protection may prevent it from succeeding in the background.
public func ClearOldTemporaryDirectories() {
let dispatchTime = DispatchTime.now() + .seconds(3)
cleanTmpDispatchQueue.asyncAfter(deadline: dispatchTime, execute: DispatchWorkItem(block: {
ClearOldTemporaryDirectoriesSync()
}))
}
private static let cleanTmpDispatchQueue = DispatchQueue(label: "org.signal.clean-tmp", qos: .utility)
private func ClearOldTemporaryDirectoriesSync() {
// Ignore the "current" temp directory.
let currentTempDirName = (OWSTemporaryDirectory() as NSString).lastPathComponent
let thresholdDate = CurrentAppContext().appLaunchTime
let dirPath = NSTemporaryDirectory()
let fileNames: [String]
do {
fileNames = try FileManager.default.contentsOfDirectory(atPath: dirPath)
} catch {
owsFailDebug("contentsOfDirectoryAtPath error: \(error)")
return
/// We need to call this method on launch AND every time the app becomes
/// active because file protection may prevent it from succeeding in the
/// background.
public static func clearOldTemporaryDirectories() {
let dispatchTime = DispatchTime.now() + .seconds(3)
cleanTmpDispatchQueue.asyncAfter(deadline: dispatchTime, execute: DispatchWorkItem(block: {
_clearOldTemporaryDirectories()
}))
}
for fileName in fileNames {
if fileName == currentTempDirName {
continue
private static func _clearOldTemporaryDirectories() {
// Ignore the "current" temp directory.
let currentTempDirNames = [
(tempDirComplete as NSString).lastPathComponent,
(tempDirAfterFirstUnlock as NSString).lastPathComponent,
]
let thresholdDate = CurrentAppContext().appLaunchTime
let dirPath = NSTemporaryDirectory()
let fileNames: [String]
do {
fileNames = try FileManager.default.contentsOfDirectory(atPath: dirPath)
} catch {
owsFailDebug("contentsOfDirectoryAtPath error: \(error)")
return
}
let filePath = dirPath.appendingPathComponent(fileName)
// Delete files with either:
//
// a) "ows_temp" name prefix.
// b) modified time before app launch time.
if !fileName.hasPrefix("ows_temp") {
do {
let attributes = try FileManager.default.attributesOfItem(atPath: filePath)
// Don't delete files which were created in the last N minutes.
let mtime = attributes[.modificationDate] as? Date
guard let mtime else {
Logger.error("failed to get a modification date for file or directory at: \(filePath)")
continue
}
if mtime > thresholdDate {
continue
}
} catch {
// This is fine; the file may have been deleted since we found it.
Logger.error("Could not get attributes of file or directory at: \(filePath)")
for fileName in fileNames {
if currentTempDirNames.contains(fileName) {
continue
}
}
if !OWSFileSystem.deleteFileIfExists(filePath) {
// This can happen if the app launches before the phone is unlocked.
// Clean up will occur when app becomes active.
Logger.warn("Could not delete old temp directory: \(filePath)")
let filePath = dirPath.appendingPathComponent(fileName)
// Delete files with either:
//
// a) "ows_temp" name prefix.
// b) modified time before app launch time.
if !fileName.hasPrefix("ows_temp_") {
do {
let attributes = try FileManager.default.attributesOfItem(atPath: filePath)
// Don't delete files which were created in the last N minutes.
let mtime = attributes[.modificationDate] as? Date
guard let mtime else {
Logger.error("failed to get a modification date for file or directory at: \(filePath)")
continue
}
if mtime > thresholdDate {
continue
}
} catch {
// This is fine; the file may have been deleted since we found it.
Logger.error("Could not get attributes of file or directory at: \(filePath)")
continue
}
}
if !OWSFileSystem.deleteFileIfExists(filePath) {
// This can happen if the app launches before the phone is unlocked.
// Clean up will occur when app becomes active.
Logger.warn("Could not delete old temp directory: \(filePath)")
}
}
}
}
public enum OWSFileSystem {
@discardableResult
private static func protectRecursiveContents(atPath path: String) -> Bool {
@ -207,9 +207,6 @@ public enum OWSFileSystem {
public static func fileSize(of fileUrl: URL) throws -> UInt64 {
return try fileSize(ofPath: fileUrl.path)
}
}
extension OWSFileSystem {
/// - Returns: false iff the directory does not exist and could not be created or setting the file protection type fails
@discardableResult
@ -226,42 +223,40 @@ extension OWSFileSystem {
return false
}
}
}
public extension OWSFileSystem {
static func fileOrFolderExists(atPath filePath: String) -> Bool {
public static func fileOrFolderExists(atPath filePath: String) -> Bool {
FileManager.default.fileExists(atPath: filePath)
}
static func fileOrFolderExists(url: URL) -> Bool {
public static func fileOrFolderExists(url: URL) -> Bool {
fileOrFolderExists(atPath: url.path)
}
static func fileExistsAndIsNotDirectory(atPath filePath: String) -> Bool {
public static func fileExistsAndIsNotDirectory(atPath filePath: String) -> Bool {
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: filePath, isDirectory: &isDirectory)
return exists && !isDirectory.boolValue
}
static func fileExistsAndIsNotDirectory(url: URL) -> Bool {
public static func fileExistsAndIsNotDirectory(url: URL) -> Bool {
fileExistsAndIsNotDirectory(atPath: url.path)
}
@discardableResult
static func deleteFile(_ filePath: String) -> Bool {
public static func deleteFile(_ filePath: String) -> Bool {
deleteFile(filePath, ignoreIfMissing: false)
}
@discardableResult
static func deleteFileIfExists(_ filePath: String) -> Bool {
public static func deleteFileIfExists(_ filePath: String) -> Bool {
return deleteFile(filePath, ignoreIfMissing: true)
}
static func deleteFile(url: URL) throws {
public static func deleteFile(url: URL) throws {
try FileManager.default.removeItem(at: url)
}
static func deleteFileIfExists(url: URL) throws {
public static func deleteFileIfExists(url: URL) throws {
do {
try deleteFile(url: url)
} catch POSIXError.ENOENT, CocoaError.fileNoSuchFile {
@ -269,7 +264,7 @@ public extension OWSFileSystem {
}
}
static func moveFile(from fromUrl: URL, to toUrl: URL) throws {
public static func moveFile(from fromUrl: URL, to toUrl: URL) throws {
guard FileManager.default.fileExists(atPath: fromUrl.path) else {
throw OWSAssertionError("Source file does not exist.")
}
@ -295,7 +290,7 @@ public extension OWSFileSystem {
#endif
}
static func copyFile(from fromUrl: URL, to toUrl: URL) throws {
public static func copyFile(from fromUrl: URL, to toUrl: URL) throws {
guard FileManager.default.fileExists(atPath: fromUrl.path) else {
throw OWSAssertionError("Source file does not exist.")
}
@ -318,7 +313,7 @@ public extension OWSFileSystem {
#endif
}
static func recursiveFilesInDirectory(_ dirPath: String) throws -> [String] {
public static func recursiveFilesInDirectory(_ dirPath: String) throws -> [String] {
owsAssertDebug(!dirPath.isEmpty)
do {
@ -334,86 +329,49 @@ public extension OWSFileSystem {
return []
}
}
}
// MARK: - Temporary Files
// MARK: - Temporary Files
public extension OWSFileSystem {
static func temporaryFileUrl(
fileExtension: String?,
public static func temporaryFileUrl(
fileName: String? = nil,
fileExtension: String? = nil,
isAvailableWhileDeviceLocked: Bool,
) -> URL {
return URL(fileURLWithPath: _temporaryFilePath(
fileName: nil,
fileExtension: fileExtension,
isAvailableWhileDeviceLocked: isAvailableWhileDeviceLocked,
))
}
static func temporaryFileUrl(
fileName: String,
fileExtension: String?,
isAvailableWhileDeviceLocked: Bool,
) -> URL {
return URL(fileURLWithPath: _temporaryFilePath(
return URL(fileURLWithPath: temporaryFilePath(
fileName: fileName,
fileExtension: fileExtension,
isAvailableWhileDeviceLocked: isAvailableWhileDeviceLocked,
))
}
static func temporaryFilePath(
fileName: String,
fileExtension: String?,
public static func temporaryFilePath(
fileName: String? = nil,
fileExtension: String? = nil,
isAvailableWhileDeviceLocked: Bool,
) -> String {
return _temporaryFilePath(
fileName: fileName,
fileExtension: fileExtension,
isAvailableWhileDeviceLocked: isAvailableWhileDeviceLocked,
)
}
static func temporaryFilePath(
fileExtension: String,
isAvailableWhileDeviceLocked: Bool,
) -> String {
return _temporaryFilePath(
fileName: nil,
fileExtension: fileExtension,
isAvailableWhileDeviceLocked: isAvailableWhileDeviceLocked,
)
}
private static func _temporaryFilePath(
fileName: String?,
fileExtension: String?,
isAvailableWhileDeviceLocked: Bool,
) -> String {
let tempDirPath = tempDirPath(availableWhileDeviceLocked: isAvailableWhileDeviceLocked)
var tempDirPath = tempDirPath(availableWhileDeviceLocked: isAvailableWhileDeviceLocked)
if fileName != nil {
tempDirPath = (tempDirPath as NSString).appendingPathComponent(UUID().uuidString)
let fileProtectionType: FileProtectionType = isAvailableWhileDeviceLocked ? .completeUntilFirstUserAuthentication : .complete
guard ensureDirectoryExists(tempDirPath, fileProtectionType: fileProtectionType) else {
owsFail("couldn't create temporary directory")
}
}
var fileName = fileName ?? UUID().uuidString
if
let fileExtension,
!fileExtension.isEmpty
{
fileName = "\(fileName).\(fileExtension)"
if let fileExtension, !fileExtension.isEmpty {
fileName += "." + fileExtension
}
let filePath = (tempDirPath as NSString).appendingPathComponent(fileName)
return filePath
}
private static func tempDirPath(availableWhileDeviceLocked: Bool) -> String {
return availableWhileDeviceLocked
? OWSTemporaryDirectoryAccessibleAfterFirstAuth()
: OWSTemporaryDirectory()
return availableWhileDeviceLocked ? tempDirAfterFirstUnlock : tempDirComplete
}
}
// MARK: -
// MARK: -
public extension OWSFileSystem {
static func deleteFile(_ filePath: String, ignoreIfMissing: Bool = false) -> Bool {
public static func deleteFile(_ filePath: String, ignoreIfMissing: Bool = false) -> Bool {
do {
try FileManager.default.removeItem(atPath: filePath)
return true
@ -432,17 +390,15 @@ public extension OWSFileSystem {
return false
}
}
}
// MARK: - Remaining space
// MARK: - Remaining space
public extension OWSFileSystem {
/// Get the remaining free space for a path's volume in bytes.
///
/// See [Apple's example][0]. It checks "important" storage (versus "opportunistic" storage).
///
/// [0]: https://developer.apple.com/documentation/foundation/nsurlresourcekey/checking_volume_storage_capacity
static func freeSpaceInBytes(forPath path: URL) throws -> UInt64 {
public static func freeSpaceInBytes(forPath path: URL) throws -> UInt64 {
let resourceValues = try path.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
guard let result = resourceValues.volumeAvailableCapacityForImportantUsage else {
throw OWSGenericError("Could not determine remaining disk space")

View File

@ -11,7 +11,7 @@ import Testing
struct ConnectionLockTest {
@Test
func testEachPriority() async throws {
let filePath = OWSTemporaryDirectory().appendingPathComponent("\(UUID().uuidString).lock")
let filePath = OWSFileSystem.temporaryFilePath(fileExtension: "lock", isAvailableWhileDeviceLocked: false)
for priority in 1...3 {
let connectionLock = ConnectionLock(filePath: filePath, priority: priority, of: 3)
defer { connectionLock.close() }

View File

@ -128,12 +128,6 @@ public struct PreviewableAttachment {
)
}
private static var videoTempPath: URL {
let videoDir = URL(fileURLWithPath: OWSTemporaryDirectory()).appendingPathComponent("video")
OWSFileSystem.ensureDirectoryExists(videoDir.path)
return videoDir
}
@MainActor
public static func compressVideoAsMp4(
dataSource: DataSourcePath,
@ -168,7 +162,7 @@ public struct PreviewableAttachment {
sessionCallback(exportSession)
}
let exportURL = videoTempPath.appendingPathComponent(UUID().uuidString).appendingPathExtension("mp4")
let exportURL = OWSFileSystem.temporaryFileUrl(fileExtension: "mp4", isAvailableWhileDeviceLocked: false)
try await exportSession.exportAsync(to: exportURL, as: .mp4)