From 2082e71888d8e505b7df96467f96432fa45848c3 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 22 May 2026 15:12:58 +0200 Subject: [PATCH] Recognise dragged iOS screenshots as images instead of raw data When dragging the floating screenshot preview into a room, iOS hands us a provider whose `suggestedName` is e.g. `Screenshot 2026-05-22 at 14.49.40`. `NSString.pathExtension` returns `"40"`, so the previous `!pathExtension.isEmpty` check treated the timestamp's trailing segment as a real extension. The file got written without `.jpg`/`.png`, and the `MediaUploadingPreprocessor` then fell back to `application/octet-stream` which is why screenshots showed up as a "data" tile. --- ElementX.xcodeproj/project.pbxproj | 4 ++ .../Other/Extensions/NSItemProvider.swift | 11 ++- UnitTests/Sources/NSItemProviderTests.swift | 71 +++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 UnitTests/Sources/NSItemProviderTests.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 89463250f..84f3984dc 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -221,6 +221,7 @@ 22C5483D01EEB290B8339817 /* HomeScreenInviteCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8FC33C3F6BF597E095CE9FA /* HomeScreenInviteCell.swift */; }; 2335D1AB954C151FD8779F45 /* RoomPermissionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0096BC5DA86AF6B6E5742AC /* RoomPermissionsTests.swift */; }; 23701DE32ACD6FD40AA992C3 /* MediaUploadingPreprocessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE203026B9AD3DB412439866 /* MediaUploadingPreprocessorTests.swift */; }; + 5C0EE5C0EE5C0EE5C0EE5C01 /* NSItemProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0EE5C0EE5C0EE5C0EE5C02 /* NSItemProviderTests.swift */; }; 237FC70AA257B935F53316BA /* SessionVerificationControllerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C55D7E514F9DE4E3D72FDCAD /* SessionVerificationControllerProxy.swift */; }; 238D561CA231339C6D4D06F3 /* ClientBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A1C33355FFB0F0953C35036 /* ClientBuilder.swift */; }; 23E23736B53946785431A74F /* AppAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FD22AEFFA20065494ED2333 /* AppAppearance.swift */; }; @@ -2622,6 +2623,7 @@ AD9CB3B9DFA353AB2B7CD9F8 /* NotificationSettingsEditScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreenCoordinator.swift; sourceTree = ""; }; ADCB8A232D3A8FB3E16A7303 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; AE203026B9AD3DB412439866 /* MediaUploadingPreprocessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadingPreprocessorTests.swift; sourceTree = ""; }; + 5C0EE5C0EE5C0EE5C0EE5C02 /* NSItemProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSItemProviderTests.swift; sourceTree = ""; }; AE40D4A5DD857AC16EED945A /* URLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSession.swift; sourceTree = ""; }; AE52983FAFB4E0998C00EE8A /* CancellableTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancellableTask.swift; sourceTree = ""; }; AE5DDBEBBA17973ED4638823 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = de; path = de.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -4968,6 +4970,7 @@ 2D7A2C4A3A74F0D2FFE9356A /* MediaPlayerProviderTests.swift */, AE203026B9AD3DB412439866 /* MediaUploadingPreprocessorTests.swift */, 03FABD73FD8086EFAB699F42 /* MediaUploadPreviewScreenViewModelTests.swift */, + 5C0EE5C0EE5C0EE5C0EE5C02 /* NSItemProviderTests.swift */, 6F6E6EDC4BBF962B2ED595A4 /* MessageForwardingScreenViewModelTests.swift */, F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */, 78913D6E120D46138E97C107 /* NavigationSplitCoordinatorTests.swift */, @@ -7952,6 +7955,7 @@ 167D00CAA13FAFB822298021 /* MediaProviderTests.swift in Sources */, B9A8C34A00D03094C0CF56F3 /* MediaUploadPreviewScreenViewModelTests.swift in Sources */, 23701DE32ACD6FD40AA992C3 /* MediaUploadingPreprocessorTests.swift in Sources */, + 5C0EE5C0EE5C0EE5C0EE5C01 /* NSItemProviderTests.swift in Sources */, F777C6FEE7D106136E2ED2B2 /* MessageForwardingScreenViewModelTests.swift in Sources */, 1146E9EDCF8344F7D6E0D553 /* MockCoder.swift in Sources */, DC68E866D6E664B0D2B06E74 /* MockImageCache.swift in Sources */, diff --git a/ElementX/Sources/Other/Extensions/NSItemProvider.swift b/ElementX/Sources/Other/Extensions/NSItemProvider.swift index 3e17d8e64..1c53dc496 100644 --- a/ElementX/Sources/Other/Extensions/NSItemProvider.swift +++ b/ElementX/Sources/Other/Extensions/NSItemProvider.swift @@ -196,8 +196,15 @@ extension NSItemProvider { } } -private extension NSString { +extension NSString { + /// `NSString.pathExtension` returns the trailing dot-segment regardless of whether + /// it's a real extension, and `UTType(filenameExtension:)` synthesises a `dyn.*` + /// placeholder for unknown ones instead of returning nil — so check both. var hasPathExtension: Bool { - !pathExtension.isEmpty + guard !pathExtension.isEmpty, + let type = UTType(filenameExtension: pathExtension) else { + return false + } + return !type.isDynamic } } diff --git a/UnitTests/Sources/NSItemProviderTests.swift b/UnitTests/Sources/NSItemProviderTests.swift new file mode 100644 index 000000000..d9d776ce3 --- /dev/null +++ b/UnitTests/Sources/NSItemProviderTests.swift @@ -0,0 +1,71 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +@testable import ElementX +import Foundation +import Testing + +struct NSItemProviderTests { + // MARK: - hasPathExtension + + @Test + func hasPathExtensionRecognisesRegisteredExtensions() { + #expect(("IMG_1234.jpg" as NSString).hasPathExtension) + #expect(("photo.png" as NSString).hasPathExtension) + #expect(("clip.heic" as NSString).hasPathExtension) + #expect(("doc.pdf" as NSString).hasPathExtension) + } + + @Test + func hasPathExtensionRejectsDateLikeTrailingSegments() { + // iOS screenshot drags expose a `suggestedName` like + // `Screenshot 2026-05-22 at 14.49.40`, whose `pathExtension` is `"40"`. + // `UTType(filenameExtension:)` synthesises a `dyn.*` placeholder for that — + // it must not be treated as a real extension. + #expect(!("Screenshot 2026-05-22 at 14.49.40" as NSString).hasPathExtension) + #expect(!("report 2025.10" as NSString).hasPathExtension) + #expect(!("v1.2.3" as NSString).hasPathExtension) + } + + @Test + func hasPathExtensionRejectsAbsentExtension() { + #expect(!("plainname" as NSString).hasPathExtension) + #expect(!("" as NSString).hasPathExtension) + #expect(!("trailing dot." as NSString).hasPathExtension) + } + + // MARK: - storeData() + + @Test + func storeDataAppendsExtensionWhenSuggestedNameLooksLikeAScreenshot() async throws { + let imageURL = try #require(Bundle(for: BundleAnchor.self).url(forResource: "test_image.png", withExtension: nil), + "Failed retrieving fixture") + let provider = try #require(NSItemProvider(contentsOf: imageURL)) + provider.suggestedName = "Screenshot 2026-05-22 at 14.49.40" + + let storedURL = try #require(await provider.storeData()) + + #expect(storedURL.pathExtension == "png") + #expect(storedURL.deletingPathExtension().lastPathComponent == "Screenshot 2026-05-22 at 14.49.40") + } + + @Test + func storeDataPreservesSuggestedNameWithRealExtension() async throws { + let imageURL = try #require(Bundle(for: BundleAnchor.self).url(forResource: "test_image.png", withExtension: nil), + "Failed retrieving fixture") + let provider = try #require(NSItemProvider(contentsOf: imageURL)) + provider.suggestedName = "IMG_1234.png" + + let storedURL = try #require(await provider.storeData()) + + #expect(storedURL.lastPathComponent == "IMG_1234.png") + } +} + +/// `Bundle(for:)` requires a class; the enclosing test type is a struct, so we keep +/// a private anchor to scope the lookup to the unit test bundle. +private final class BundleAnchor { }