fix(ios): preserve prefixed share text (#103453)

* fix(ios): preserve prefixed share text

* fix(ios): harden shared text normalization

---------

Co-authored-by: lin-hongkuan <lin-hongkuan@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
lin-hongkuan
2026-07-11 11:01:06 +08:00
committed by GitHub
parent dba64d574f
commit f4732576bd
4 changed files with 171 additions and 39 deletions

View File

@@ -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<String> = [
"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
}
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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: