diff --git a/apps/ios/ShareExtension/ShareDraftComposer.swift b/apps/ios/ShareExtension/ShareDraftComposer.swift new file mode 100644 index 000000000000..7f781357276a --- /dev/null +++ b/apps/ios/ShareExtension/ShareDraftComposer.swift @@ -0,0 +1,64 @@ +import Foundation +import OpenClawKit + +enum ShareDraftComposer { + /// These lines came from the legacy generated share template. Match the + /// complete trimmed line so real content such as "Text: details" survives. + private static let legacyScaffoldLines: Set = [ + "shared from ios.", + "text:", + "shared attachment(s):", + "please help me with this.", + ] + + static func compose(from payload: SharedContentPayload) -> String { + var fragments: [String] = [] + let title = self.sanitize(payload.title) + let text = self.sanitize(payload.text) + let url = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + if let title { fragments.append(title) } + if let text { fragments.append(text) } + if !url.isEmpty { fragments.append(url) } + + return fragments.joined(separator: "\n\n") + } + + private static func sanitize(_ raw: String?) -> String? { + guard let raw else { return nil } + let cleanedLines = raw + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { line in + !line.isEmpty && !self.legacyScaffoldLines.contains(line.lowercased()) + } + let cleaned = cleanedLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? nil : cleaned + } +} + +enum SharePayloadNormalizer { + static func distinctAttributedText(_ raw: String?, sharedText: String?, sharedURL: URL?) -> String? { + guard let candidate = self.trimmed(raw) else { return nil } + let duplicates = [self.trimmed(sharedText), self.trimmed(sharedURL?.absoluteString)].compactMap(\.self) + return duplicates.contains(candidate) ? nil : candidate + } + + static func webURL(from raw: String) -> URL? { + guard let trimmed = self.trimmed(raw) else { return nil } + guard let components = URLComponents(string: trimmed), + let scheme = components.scheme?.lowercased(), + scheme == "http" || scheme == "https", + components.host?.isEmpty == false + else { + return nil + } + return components.url + } + + private static func trimmed(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/apps/ios/ShareExtension/ShareViewController.swift b/apps/ios/ShareExtension/ShareViewController.swift index 7c54cf6ea8e2..18ec0bc76c29 100644 --- a/apps/ios/ShareExtension/ShareViewController.swift +++ b/apps/ios/ShareExtension/ShareViewController.swift @@ -96,7 +96,7 @@ final class ShareViewController: UIViewController { "share payload title=\(payload.title?.count ?? 0) text=\(payload.text?.count ?? 0)") self.logger.info( "share attachments hasURL=\(payload.url != nil) images=\(self.pendingAttachments.count)") - let message = self.composeDraft(from: payload) + let message = ShareDraftComposer.compose(from: payload) await MainActor.run { self.draftTextView.text = message self.sendButton.isEnabled = true @@ -346,40 +346,6 @@ final class ShareViewController: UIViewController { } } - private func composeDraft(from payload: SharedContentPayload) -> String { - var lines: [String] = [] - let title = self.sanitizeDraftFragment(payload.title) - let text = self.sanitizeDraftFragment(payload.text) - let url = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - - if let title, !title.isEmpty { lines.append(title) } - if let text, !text.isEmpty { lines.append(text) } - if !url.isEmpty { lines.append(url) } - - return lines.joined(separator: "\n\n") - } - - private func sanitizeDraftFragment(_ raw: String?) -> String? { - guard let raw else { return nil } - let banned = [ - "shared from ios.", - "text:", - "shared attachment(s):", - "please help me with this.", - "please help me with this.w", - ] - let cleanedLines = raw - .components(separatedBy: .newlines) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { line in - guard !line.isEmpty else { return false } - let lowered = line.lowercased() - return !banned.contains { lowered == $0 || lowered.hasPrefix($0) } - } - let cleaned = cleanedLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) - return cleaned.isEmpty ? nil : cleaned - } - private func extractSharedContent() async -> ExtractedShareContent { guard let items = self.extensionContext?.inputItems as? [NSExtensionItem] else { return ExtractedShareContent( @@ -390,6 +356,7 @@ final class ShareViewController: UIViewController { var title: String? var sharedURL: URL? var sharedText: String? + var attributedContentText: String? var imageCount = 0 var videoCount = 0 var fileCount = 0 @@ -399,7 +366,10 @@ final class ShareViewController: UIViewController { for item in items { if title == nil { - title = item.attributedTitle?.string ?? item.attributedContentText?.string + title = item.attributedTitle?.string + } + if attributedContentText == nil { + attributedContentText = item.attributedContentText?.string } for provider in item.attachments ?? [] { @@ -433,8 +403,14 @@ final class ShareViewController: UIViewController { _ = fileCount _ = unknownCount + // Share hosts often mirror provider text in attributedContentText. + // Preserve distinct content as the historical title, but do not duplicate provider data. + let supplementalTitle = SharePayloadNormalizer.distinctAttributedText( + attributedContentText, + sharedText: sharedText, + sharedURL: sharedURL) return ExtractedShareContent( - payload: SharedContentPayload(title: title, url: sharedURL, text: sharedText), + payload: SharedContentPayload(title: title ?? supplementalTitle, url: sharedURL, text: sharedText), attachments: attachments) } @@ -493,8 +469,7 @@ final class ShareViewController: UIViewController { if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) { if let text = await self.loadTextValue(from: provider, typeIdentifier: UTType.text.identifier), - let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)), - url.scheme != nil + let url = SharePayloadNormalizer.webURL(from: text) { return url } diff --git a/apps/ios/Tests/Logic/ShareDraftComposerTests.swift b/apps/ios/Tests/Logic/ShareDraftComposerTests.swift new file mode 100644 index 000000000000..9daa01bca336 --- /dev/null +++ b/apps/ios/Tests/Logic/ShareDraftComposerTests.swift @@ -0,0 +1,91 @@ +import Foundation +import OpenClawKit +import Testing + +struct ShareDraftComposerTests { + @Test(arguments: [ + "sHaReD fRoM iOs.", + "tExT:", + "sHaReD aTtAcHmEnT(s):", + "pLeAsE hElP mE wItH tHiS.", + ]) + func `removes exact legacy scaffold lines case insensitively`(_ marker: String) { + let payload = SharedContentPayload(title: nil, url: nil, text: marker) + #expect(ShareDraftComposer.compose(from: payload).isEmpty) + } + + @Test(arguments: [ + "Shared from iOS.", + "Text:", + "Shared attachment(s):", + "Please help me with this.", + ]) + func `preserves content that starts with a legacy scaffold marker`(_ marker: String) { + let text = "\(marker) legitimate content" + let payload = SharedContentPayload(title: nil, url: nil, text: text) + #expect(ShareDraftComposer.compose(from: payload) == text) + } + + @Test func `removes only exact scaffold lines from multiline content`() { + let payload = SharedContentPayload( + title: nil, + url: nil, + text: "Text: keep this\nShared from iOS.\nsecond line") + + #expect(ShareDraftComposer.compose(from: payload) == "Text: keep this\nsecond line") + } + + @Test func `composes sanitized title text and URL into the final draft`() throws { + let url = try #require(URL(string: "https://example.com/article")) + let payload = SharedContentPayload( + title: "Text: title", + url: url, + text: "Shared attachment(s):\nText: body") + + #expect(ShareDraftComposer.compose(from: payload) == + "Text: title\n\nText: body\n\nhttps://example.com/article") + } + + @Test func `omits nil and blank fragments`() { + let payload = SharedContentPayload(title: nil, url: nil, text: " \n") + #expect(ShareDraftComposer.compose(from: payload).isEmpty) + } + + @Test(arguments: [ + "Text: the quick brown fox", + "tExT:", + "mailto:hello@example.com", + "https:", + ]) + func `does not reinterpret ordinary shared text as a web URL`(_ text: String) { + #expect(SharePayloadNormalizer.webURL(from: text) == nil) + } + + @Test(arguments: [ + "https://example.com/article", + " HTTP://example.com/path ", + ]) + func `accepts web URLs supplied as plain text`(_ text: String) { + #expect(SharePayloadNormalizer.webURL(from: text) != nil) + } + + @Test func `deduplicates attributed content that mirrors provider text or URL`() throws { + let url = try #require(URL(string: "https://example.com/article")) + #expect(SharePayloadNormalizer.distinctAttributedText( + " Text: the quick brown fox ", + sharedText: "Text: the quick brown fox", + sharedURL: nil) == nil) + #expect(SharePayloadNormalizer.distinctAttributedText( + "https://example.com/article", + sharedText: nil, + sharedURL: url) == nil) + } + + @Test func `preserves attributed content that differs from provider data`() throws { + let url = try #require(URL(string: "https://example.com/article")) + #expect(SharePayloadNormalizer.distinctAttributedText( + "Article summary", + sharedText: "Article body", + sharedURL: url) == "Article summary") + } +} diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 190835b3d2c0..60c835de6ff0 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -412,6 +412,8 @@ targets: Release: Signing.xcconfig sources: - path: Tests/Logic + - path: ShareExtension/ShareDraftComposer.swift + group: ShareExtension - path: WatchApp/Sources/WatchVoiceTurnTracker.swift group: WatchApp/Sources dependencies: