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.
This commit is contained in:
Quentin Gliech
2026-05-22 15:12:58 +02:00
committed by Stefan Ceriu
parent ff4fe6c728
commit 2082e71888
3 changed files with 84 additions and 2 deletions
+4
View File
@@ -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 = "<group>"; };
ADCB8A232D3A8FB3E16A7303 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = "<group>"; };
AE203026B9AD3DB412439866 /* MediaUploadingPreprocessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadingPreprocessorTests.swift; sourceTree = "<group>"; };
5C0EE5C0EE5C0EE5C0EE5C02 /* NSItemProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSItemProviderTests.swift; sourceTree = "<group>"; };
AE40D4A5DD857AC16EED945A /* URLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSession.swift; sourceTree = "<group>"; };
AE52983FAFB4E0998C00EE8A /* CancellableTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancellableTask.swift; sourceTree = "<group>"; };
AE5DDBEBBA17973ED4638823 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = de; path = de.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
@@ -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 */,
@@ -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
}
}
@@ -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 { }