mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 07:11:36 +00:00
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:
64
apps/ios/ShareExtension/ShareDraftComposer.swift
Normal file
64
apps/ios/ShareExtension/ShareDraftComposer.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
91
apps/ios/Tests/Logic/ShareDraftComposerTests.swift
Normal file
91
apps/ios/Tests/Logic/ShareDraftComposerTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user