Files
xianyan/ios/ShareExtension/ShareViewController.swift
Developer 8cd2703a0b chore: 汇总2026-06-08全量功能迭代与修复
此版本包含多项功能更新与问题修复:
1. 新增iOS ShareExtension分享扩展,支持多类型内容分享
2. 修复认证流程日志提示,更新用户名检测逻辑
3. 优化会话列表UI,替换emoji为CupertinoIcons原生图标
4. 修正搜索类型与频道名称映射,新增音频类型支持
5. 调整启动页布局与多语言配置
6. 重构布局约束,修复无界布局崩溃问题
7. 迁移开发者设置到更多设置页,新增日志级别配置
8. 优化TTS健康检查与自动回退逻辑
9. 新增笔记置顶会话跳转功能
10. 更新后端配置与本地化字符串
11. 重构稍后读模块,支持音频内容处理
12. 优化编辑器功能与字体管理页面
13. 新增本地数据库置顶笔记表
14. 修复Android MANAGE_STORAGE权限配置
2026-06-08 07:55:22 +08:00

475 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// ShareExtension
// : 2026-06-08
// : 2026-06-08
// : AppApp Group
// UserDefaultsAppreceive_sharing_intent
// :
// : receive_sharing_intentSharedMediaFile
// 使App Group (group.apps.xy.xianyan.share) App
// ============================================================
import UIKit
import Social
import MobileCoreServices
import Photos
import AVFoundation
import UniformTypeIdentifiers
// MARK: - receive_sharing_intent
/// UserDefaultskey - SwiftReceiveSharingIntentPlugin.kUserDefaultsKey
private let kUserDefaultsKey = "ShareKey"
/// key - SwiftReceiveSharingIntentPlugin.kUserDefaultsMessageKey
private let kUserDefaultsMessageKey = "ShareMessageKey"
/// Info.plistAppGroupIdkey - SwiftReceiveSharingIntentPlugin.kAppGroupIdKey
private let kAppGroupIdKey = "AppGroupId"
/// URL Scheme - SwiftReceiveSharingIntentPlugin.kSchemePrefix
private let kSchemePrefix = "ShareMedia"
// MARK: - receive_sharing_intentSharedMediaFile/SharedMediaType
/// - receive_sharing_intentSharedMediaTyperawValue
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_intentSharedMediaFile
/// JSONApp Group UserDefaultsAppSwiftReceiveSharingIntentPlugin
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: -
/// AppBundle 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
/// BundleAppBundle IDApp Group ID
/// : ShareExtensionBundle ID <AppID>.ShareExtension
/// AppBundle ID
private func loadIds() {
guard let shareExtensionBundleId = Bundle.main.bundleIdentifier else { return }
// ShareExtension Bundle IDApp Bundle ID
// : apps.xy.xianyan.ShareExtension apps.xy.xianyan
if let lastDot = shareExtensionBundleId.lastIndex(of: ".") {
hostAppBundleIdentifier = String(shareExtensionBundleId[..<lastDot])
}
// 使Info.plistAppGroupId使
// : 使 .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 - PNGApp 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 UserDefaultsApp
private func saveAndRedirect(message: String? = nil) {
guard !sharedMedia.isEmpty else {
dismissWithError()
return
}
let userDefaults = UserDefaults(suiteName: appGroupId)
// JSONreceive_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 SchemeApp
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"
}
}