此版本包含多项功能更新与问题修复: 1. 新增iOS ShareExtension分享扩展,支持多类型内容分享 2. 修复认证流程日志提示,更新用户名检测逻辑 3. 优化会话列表UI,替换emoji为CupertinoIcons原生图标 4. 修正搜索类型与频道名称映射,新增音频类型支持 5. 调整启动页布局与多语言配置 6. 重构布局约束,修复无界布局崩溃问题 7. 迁移开发者设置到更多设置页,新增日志级别配置 8. 优化TTS健康检查与自动回退逻辑 9. 新增笔记置顶会话跳转功能 10. 更新后端配置与本地化字符串 11. 重构稍后读模块,支持音频内容处理 12. 优化编辑器功能与字体管理页面 13. 新增本地数据库置顶笔记表 14. 修复Android MANAGE_STORAGE权限配置
475 lines
17 KiB
Swift
475 lines
17 KiB
Swift
// ============================================================
|
||
// 闲言ShareExtension — 分享扩展控制器
|
||
// 创建时间: 2026-06-08
|
||
// 更新时间: 2026-06-08
|
||
// 作用: 接收其他App通过系统分享面板发送的内容,保存到App Group
|
||
// UserDefaults,然后重定向到主App由receive_sharing_intent插件处理
|
||
// 上次更新: 初始版本
|
||
// 注意: 数据格式必须与receive_sharing_intent的SharedMediaFile完全一致
|
||
// 使用App Group (group.apps.xy.xianyan.share) 与主App共享数据
|
||
// ============================================================
|
||
|
||
import UIKit
|
||
import Social
|
||
import MobileCoreServices
|
||
import Photos
|
||
import AVFoundation
|
||
import UniformTypeIdentifiers
|
||
|
||
// MARK: - 常量定义(与receive_sharing_intent插件保持一致)
|
||
|
||
/// UserDefaults存储key - 与SwiftReceiveSharingIntentPlugin.kUserDefaultsKey一致
|
||
private let kUserDefaultsKey = "ShareKey"
|
||
|
||
/// 消息内容存储key - 与SwiftReceiveSharingIntentPlugin.kUserDefaultsMessageKey一致
|
||
private let kUserDefaultsMessageKey = "ShareMessageKey"
|
||
|
||
/// Info.plist中AppGroupId的key - 与SwiftReceiveSharingIntentPlugin.kAppGroupIdKey一致
|
||
private let kAppGroupIdKey = "AppGroupId"
|
||
|
||
/// URL Scheme前缀 - 与SwiftReceiveSharingIntentPlugin.kSchemePrefix一致
|
||
private let kSchemePrefix = "ShareMedia"
|
||
|
||
// MARK: - 数据模型(与receive_sharing_intent的SharedMediaFile/SharedMediaType格式一致)
|
||
|
||
/// 分享媒体类型 - 必须与receive_sharing_intent的SharedMediaType枚举rawValue一致
|
||
enum ShareMediaType: String, Codable, CaseIterable {
|
||
case image
|
||
case video
|
||
case text
|
||
case file
|
||
case url
|
||
|
||
/// 转换为UTType标识符用于匹配附件类型
|
||
var toUTTypeIdentifier: String {
|
||
if #available(iOS 14.0, *) {
|
||
switch self {
|
||
case .image: return UTType.image.identifier
|
||
case .video: return UTType.movie.identifier
|
||
case .text: return UTType.text.identifier
|
||
case .file: return UTType.fileURL.identifier
|
||
case .url: return UTType.url.identifier
|
||
}
|
||
}
|
||
switch self {
|
||
case .image: return "public.image"
|
||
case .video: return "public.movie"
|
||
case .text: return "public.text"
|
||
case .file: return "public.file-url"
|
||
case .url: return "public.url"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 分享媒体文件 - 必须与receive_sharing_intent的SharedMediaFile属性名一致
|
||
/// JSON编码后存入App Group UserDefaults,由主App的SwiftReceiveSharingIntentPlugin解码
|
||
struct ShareMediaFile: Codable {
|
||
var path: String
|
||
var mimeType: String?
|
||
var thumbnail: String?
|
||
var duration: Double?
|
||
var message: String?
|
||
var type: ShareMediaType
|
||
}
|
||
|
||
// MARK: - ShareViewController
|
||
|
||
/// iOS Share Extension控制器
|
||
/// 继承SLComposeServiceViewController提供标准分享界面
|
||
/// 用户可添加备注文本,点击"发布"后保存内容并跳转主App
|
||
class ShareViewController: SLComposeServiceViewController {
|
||
|
||
// MARK: - 属性
|
||
|
||
/// 主App的Bundle Identifier
|
||
private var hostAppBundleIdentifier = ""
|
||
|
||
/// App Group标识符
|
||
private var appGroupId = ""
|
||
|
||
/// 收集到的分享媒体文件列表
|
||
private var sharedMedia: [ShareMediaFile] = []
|
||
|
||
/// 附件总数(用于判断是否处理完毕)
|
||
private var totalAttachments = 0
|
||
|
||
/// 已处理的附件数
|
||
private var processedAttachments = 0
|
||
|
||
// MARK: - 生命周期
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
loadIds()
|
||
}
|
||
|
||
override func isContentValid() -> Bool {
|
||
return true
|
||
}
|
||
|
||
/// 用户点击"发布"按钮时调用
|
||
override func didSelectPost() {
|
||
saveAndRedirect(message: contentText)
|
||
}
|
||
|
||
/// 视图出现后处理分享内容
|
||
override func viewDidAppear(_ animated: Bool) {
|
||
super.viewDidAppear(animated)
|
||
|
||
guard let content = extensionContext?.inputItems.first as? NSExtensionItem,
|
||
let attachments = content.attachments, !attachments.isEmpty else {
|
||
dismissWithError()
|
||
return
|
||
}
|
||
|
||
totalAttachments = attachments.count
|
||
processedAttachments = 0
|
||
|
||
for (index, attachment) in attachments.enumerated() {
|
||
processAttachment(attachment, index: index, total: attachments.count)
|
||
}
|
||
}
|
||
|
||
override func configurationItems() -> [Any]! {
|
||
return []
|
||
}
|
||
|
||
// MARK: - ID加载
|
||
|
||
/// 从Bundle中提取主App的Bundle ID和App Group ID
|
||
/// 规则: ShareExtension的Bundle ID为 <主AppID>.ShareExtension
|
||
/// 从中移除最后一个组件即可得到主App的Bundle ID
|
||
private func loadIds() {
|
||
guard let shareExtensionBundleId = Bundle.main.bundleIdentifier else { return }
|
||
|
||
// 从ShareExtension Bundle ID提取主App Bundle ID
|
||
// 例如: apps.xy.xianyan.ShareExtension → apps.xy.xianyan
|
||
if let lastDot = shareExtensionBundleId.lastIndex(of: ".") {
|
||
hostAppBundleIdentifier = String(shareExtensionBundleId[..<lastDot])
|
||
}
|
||
|
||
// 优先使用Info.plist中配置的AppGroupId,否则使用默认规则
|
||
// 注意: 默认规则使用 .share 后缀以与entitlements中的配置保持一致
|
||
let customAppGroupId = Bundle.main.object(forInfoDictionaryKey: kAppGroupIdKey) as? String
|
||
let defaultAppGroupId = "group.\(hostAppBundleIdentifier).share"
|
||
appGroupId = customAppGroupId ?? defaultAppGroupId
|
||
}
|
||
|
||
// MARK: - 附件处理
|
||
|
||
/// 处理单个NSItemProvider附件
|
||
private func processAttachment(_ attachment: NSItemProvider, index: Int, total: Int) {
|
||
// 按优先级尝试匹配类型: url > text > image > video > file
|
||
for type in ShareMediaType.allCases {
|
||
if attachment.hasItemConformingToTypeIdentifier(type.toUTTypeIdentifier) {
|
||
attachment.loadItem(forTypeIdentifier: type.toUTTypeIdentifier) { [weak self] data, error in
|
||
guard let self = self else { return }
|
||
if let error = error {
|
||
print("[ShareExtension] 加载附件失败: \(error)")
|
||
self.onAttachmentProcessed()
|
||
return
|
||
}
|
||
self.handleLoadedData(data, type: type)
|
||
self.onAttachmentProcessed()
|
||
}
|
||
return
|
||
}
|
||
}
|
||
// 无法识别的类型
|
||
onAttachmentProcessed()
|
||
}
|
||
|
||
/// 处理已加载的附件数据
|
||
private func handleLoadedData(_ data: Any?, type: ShareMediaType) {
|
||
switch type {
|
||
case .text:
|
||
if let text = data as? String {
|
||
sharedMedia.append(ShareMediaFile(
|
||
path: text,
|
||
mimeType: "text/plain",
|
||
type: .text
|
||
))
|
||
}
|
||
case .url:
|
||
if let url = data as? URL {
|
||
sharedMedia.append(ShareMediaFile(
|
||
path: url.absoluteString,
|
||
mimeType: nil,
|
||
type: .url
|
||
))
|
||
}
|
||
case .image:
|
||
if let url = data as? URL {
|
||
handleFileURL(url, type: .image)
|
||
} else if let image = data as? UIImage {
|
||
handleUIImage(image)
|
||
}
|
||
case .video:
|
||
if let url = data as? URL {
|
||
handleVideoURL(url)
|
||
}
|
||
case .file:
|
||
if let url = data as? URL {
|
||
handleFileURL(url, type: .file)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 处理UIImage类型 - 保存为PNG到App Group容器
|
||
private func handleUIImage(_ image: UIImage) {
|
||
guard let containerURL = FileManager.default.containerURL(
|
||
forSecurityApplicationGroupIdentifier: appGroupId
|
||
) else { return }
|
||
|
||
let fileName = "\(UUID().uuidString).png"
|
||
let destURL = containerURL.appendingPathComponent(fileName)
|
||
|
||
guard let pngData = image.pngData() else { return }
|
||
do {
|
||
try pngData.write(to: destURL)
|
||
let pathDecoded = destURL.absoluteString.removingPercentEncoding ?? destURL.absoluteString
|
||
sharedMedia.append(ShareMediaFile(
|
||
path: pathDecoded,
|
||
mimeType: "image/png",
|
||
type: .image
|
||
))
|
||
} catch {
|
||
print("[ShareExtension] 保存图片失败: \(error)")
|
||
}
|
||
}
|
||
|
||
/// 处理文件URL - 复制到App Group容器
|
||
private func handleFileURL(_ url: URL, type: ShareMediaType) {
|
||
guard let containerURL = FileManager.default.containerURL(
|
||
forSecurityApplicationGroupIdentifier: appGroupId
|
||
) else { return }
|
||
|
||
let fileName = getFileName(from: url, type: type)
|
||
let destURL = containerURL.appendingPathComponent(fileName)
|
||
|
||
if copyFile(at: url, to: destURL) {
|
||
let pathDecoded = destURL.absoluteString.removingPercentEncoding ?? destURL.absoluteString
|
||
let mimeType = url.mimeType()
|
||
sharedMedia.append(ShareMediaFile(
|
||
path: pathDecoded,
|
||
mimeType: mimeType,
|
||
type: type
|
||
))
|
||
}
|
||
}
|
||
|
||
/// 处理视频URL - 复制文件并生成缩略图和时长信息
|
||
private func handleVideoURL(_ url: URL) {
|
||
guard let containerURL = FileManager.default.containerURL(
|
||
forSecurityApplicationGroupIdentifier: appGroupId
|
||
) else { return }
|
||
|
||
let fileName = getFileName(from: url, type: .video)
|
||
let destURL = containerURL.appendingPathComponent(fileName)
|
||
|
||
if copyFile(at: url, to: destURL) {
|
||
let pathDecoded = destURL.absoluteString.removingPercentEncoding ?? destURL.absoluteString
|
||
let mimeType = url.mimeType()
|
||
|
||
// 获取视频缩略图和时长
|
||
var thumbnailPath: String? = nil
|
||
var duration: Double? = nil
|
||
if let videoInfo = getVideoInfo(from: url) {
|
||
thumbnailPath = videoInfo.thumbnail
|
||
duration = videoInfo.duration
|
||
}
|
||
|
||
sharedMedia.append(ShareMediaFile(
|
||
path: pathDecoded,
|
||
mimeType: mimeType,
|
||
thumbnail: thumbnailPath,
|
||
duration: duration,
|
||
type: .video
|
||
))
|
||
}
|
||
}
|
||
|
||
/// 附件处理完毕回调
|
||
private func onAttachmentProcessed() {
|
||
processedAttachments += 1
|
||
// 所有附件处理完毕后自动保存并跳转
|
||
if processedAttachments >= totalAttachments {
|
||
saveAndRedirect(message: contentText)
|
||
}
|
||
}
|
||
|
||
// MARK: - 保存与跳转
|
||
|
||
/// 将分享数据保存到App Group UserDefaults并跳转到主App
|
||
private func saveAndRedirect(message: String? = nil) {
|
||
guard !sharedMedia.isEmpty else {
|
||
dismissWithError()
|
||
return
|
||
}
|
||
|
||
let userDefaults = UserDefaults(suiteName: appGroupId)
|
||
|
||
// 编码分享数据为JSON(格式与receive_sharing_intent一致)
|
||
if let encodedData = try? JSONEncoder().encode(sharedMedia) {
|
||
userDefaults?.set(encodedData, forKey: kUserDefaultsKey)
|
||
}
|
||
|
||
// 保存用户备注文本
|
||
if let message = message, !message.isEmpty {
|
||
userDefaults?.set(message, forKey: kUserDefaultsMessageKey)
|
||
}
|
||
|
||
userDefaults?.synchronize()
|
||
redirectToHostApp()
|
||
}
|
||
|
||
/// 通过URL Scheme跳转回主App
|
||
private func redirectToHostApp() {
|
||
loadIds()
|
||
let urlString = "\(kSchemePrefix)-\(hostAppBundleIdentifier):share"
|
||
guard let url = URL(string: urlString) else {
|
||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||
return
|
||
}
|
||
|
||
// iOS 18+ 使用UIApplication.open
|
||
if #available(iOS 18.0, *) {
|
||
var responder = self as UIResponder?
|
||
while responder != nil {
|
||
if let application = responder as? UIApplication {
|
||
application.open(url, options: [:], completionHandler: nil)
|
||
}
|
||
responder = responder?.next
|
||
}
|
||
} else {
|
||
// iOS 18以下使用perform selector
|
||
let selectorOpenURL = sel_registerName("openURL:")
|
||
var responder = self as UIResponder?
|
||
while responder != nil {
|
||
if let r = responder, r.responds(to: selectorOpenURL) {
|
||
_ = r.perform(selectorOpenURL, with: url)
|
||
}
|
||
responder = responder?.next
|
||
}
|
||
}
|
||
|
||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||
}
|
||
|
||
/// 显示错误提示并关闭扩展
|
||
private func dismissWithError() {
|
||
let alert = UIAlertController(
|
||
title: "无法处理",
|
||
message: "不支持的分享内容类型",
|
||
preferredStyle: .alert
|
||
)
|
||
let action = UIAlertAction(title: "确定", style: .cancel) { _ in
|
||
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||
}
|
||
alert.addAction(action)
|
||
present(alert, animated: true, completion: nil)
|
||
}
|
||
|
||
// MARK: - 文件操作工具
|
||
|
||
/// 从URL提取文件名,若无法提取则根据类型生成默认名称
|
||
private func getFileName(from url: URL, type: ShareMediaType) -> String {
|
||
var name = url.lastPathComponent
|
||
if name.isEmpty {
|
||
switch type {
|
||
case .image: name = "\(UUID().uuidString).png"
|
||
case .video: name = "\(UUID().uuidString).mp4"
|
||
case .text: name = "\(UUID().uuidString).txt"
|
||
case .file: name = UUID().uuidString
|
||
case .url: name = UUID().uuidString
|
||
}
|
||
}
|
||
return name
|
||
}
|
||
|
||
/// 复制文件到目标路径
|
||
private func copyFile(at srcURL: URL, to dstURL: URL) -> Bool {
|
||
do {
|
||
if FileManager.default.fileExists(atPath: dstURL.path) {
|
||
try FileManager.default.removeItem(at: dstURL)
|
||
}
|
||
try FileManager.default.copyItem(at: srcURL, to: dstURL)
|
||
return true
|
||
} catch {
|
||
print("[ShareExtension] 复制文件失败 \(srcURL) → \(dstURL): \(error)")
|
||
return false
|
||
}
|
||
}
|
||
|
||
/// 获取视频缩略图和时长
|
||
private func getVideoInfo(from url: URL) -> (thumbnail: String?, duration: Double)? {
|
||
let asset = AVAsset(url: url)
|
||
let durationMs = (CMTimeGetSeconds(asset.duration) * 1000).rounded()
|
||
|
||
guard let containerURL = FileManager.default.containerURL(
|
||
forSecurityApplicationGroupIdentifier: appGroupId
|
||
) else {
|
||
return (thumbnail: nil, duration: durationMs)
|
||
}
|
||
|
||
// 生成缩略图文件名(使用base64编码避免特殊字符)
|
||
let fileNameBase64 = Data(url.lastPathComponent.utf8)
|
||
.base64EncodedString()
|
||
.replacingOccurrences(of: "==", with: "")
|
||
let thumbnailURL = containerURL.appendingPathComponent("\(fileNameBase64).jpg")
|
||
|
||
// 缩略图已存在则直接返回
|
||
if FileManager.default.fileExists(atPath: thumbnailURL.path) {
|
||
return (thumbnail: thumbnailURL.absoluteString, duration: durationMs)
|
||
}
|
||
|
||
// 生成缩略图
|
||
let assetImgGenerate = AVAssetImageGenerator(asset: asset)
|
||
assetImgGenerate.appliesPreferredTrackTransform = true
|
||
assetImgGenerate.maximumSize = CGSize(width: 360, height: 360)
|
||
|
||
do {
|
||
let cgImage = try assetImgGenerate.copyCGImage(
|
||
at: CMTimeMakeWithSeconds(0.6, preferredTimescale: 1),
|
||
actualTime: nil
|
||
)
|
||
let uiImage = UIImage(cgImage: cgImage)
|
||
if let pngData = uiImage.jpegData(compressionQuality: 0.7) {
|
||
try pngData.write(to: thumbnailURL)
|
||
return (thumbnail: thumbnailURL.absoluteString, duration: durationMs)
|
||
}
|
||
} catch {
|
||
print("[ShareExtension] 生成视频缩略图失败: \(error)")
|
||
}
|
||
|
||
return (thumbnail: nil, duration: durationMs)
|
||
}
|
||
}
|
||
|
||
// MARK: - URL扩展
|
||
|
||
extension URL {
|
||
/// 根据文件扩展名获取MIME类型
|
||
func mimeType() -> String {
|
||
if #available(iOS 14.0, *) {
|
||
if let mimeType = UTType(filenameExtension: self.pathExtension)?.preferredMIMEType {
|
||
return mimeType
|
||
}
|
||
} else {
|
||
if let uti = UTTypeCreatePreferredIdentifierForTag(
|
||
kUTTagClassFilenameExtension,
|
||
self.pathExtension as NSString,
|
||
nil
|
||
)?.takeRetainedValue() {
|
||
if let mimetype = UTTypeCopyPreferredTagWithClass(
|
||
uti,
|
||
kUTTagClassMIMEType
|
||
)?.takeRetainedValue() {
|
||
return mimetype as String
|
||
}
|
||
}
|
||
}
|
||
return "application/octet-stream"
|
||
}
|
||
}
|