replace app icon and fix bugs
@@ -15,6 +15,7 @@
|
||||
8070A4B4DD1D3DD823A8F923 /* WidgetAppUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0142951DE661E55DC3207327 /* WidgetAppUtils.swift */; };
|
||||
88ABACE0259DE83261737B99 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1285E8A7B7E7359EA11B9591 /* SceneDelegate.swift */; };
|
||||
93FF51B22016695CB74F0AD6 /* HostBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B6B3C131D45A3E52B69B531 /* HostBridge.swift */; };
|
||||
E305C301A1B24DD0AC001002 /* QrScannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E305C301A1B24DD0AC001001 /* QrScannerViewController.swift */; };
|
||||
A1B2C3D4E5F60718293A4B5C /* BackgroundIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */; };
|
||||
A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2016716493FA6F4B6E30EB17 /* PipScreenManager.swift */; };
|
||||
B7C8D9E0F1A234567890ABCD /* InAppWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6789012345678 /* InAppWebViewController.swift */; };
|
||||
@@ -61,6 +62,7 @@
|
||||
310E847101B97B7A36776025 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = "<group>"; };
|
||||
59CDB1C6A666078B3272B409 /* BackgroundIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundIntent.swift; sourceTree = "<group>"; };
|
||||
7B6B3C131D45A3E52B69B531 /* HostBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostBridge.swift; sourceTree = "<group>"; };
|
||||
E305C301A1B24DD0AC001001 /* QrScannerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrScannerViewController.swift; sourceTree = "<group>"; };
|
||||
86038AE1032E284E79B32AD6 /* iosApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
88A292AA7607F14E8C66291E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
8B6C1F485E0CF8BB70C9DDD2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
@@ -111,6 +113,7 @@
|
||||
A1B2C3D4E5F6789012345678 /* InAppWebViewController.swift */,
|
||||
B2C3D4E5F67890123456789 /* WebViewContentBlocker.swift */,
|
||||
7B6B3C131D45A3E52B69B531 /* HostBridge.swift */,
|
||||
E305C301A1B24DD0AC001001 /* QrScannerViewController.swift */,
|
||||
D1E2F3A4B5C60718293A4B01 /* QqShareHost.swift */,
|
||||
D1E2F3A4B5C60718293A4B07 /* iosApp-Bridging-Header.h */,
|
||||
1285E8A7B7E7359EA11B9591 /* SceneDelegate.swift */,
|
||||
@@ -324,6 +327,7 @@
|
||||
FA6C12E55F9D41237B4BDAF1 /* CarPlaySceneDelegate.swift in Sources */,
|
||||
56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */,
|
||||
93FF51B22016695CB74F0AD6 /* HostBridge.swift in Sources */,
|
||||
E305C301A1B24DD0AC001002 /* QrScannerViewController.swift in Sources */,
|
||||
A59815941DC5A1C2B9F349CB /* PipScreenManager.swift in Sources */,
|
||||
88ABACE0259DE83261737B99 /* SceneDelegate.swift in Sources */,
|
||||
8070A4B4DD1D3DD823A8F923 /* WidgetAppUtils.swift in Sources */,
|
||||
|
||||
@@ -48,6 +48,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
}
|
||||
|
||||
private func registerHostNotifications() {
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: Notification.Name("llmpOpenQrScanner"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { notification in
|
||||
HostBridge.shared.presentQrScanner(from: notification.userInfo)
|
||||
}
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: Notification.Name("llmpShareRequest"),
|
||||
object: nil,
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"size": "1024x1024",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"filename": "icon-dark-1024.png"
|
||||
},
|
||||
{
|
||||
"size": "20x20",
|
||||
"idiom": "iphone",
|
||||
@@ -117,6 +129,6 @@
|
||||
],
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "icon.wuruihong.com"
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 701 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -7,6 +7,7 @@ final class HostBridge {
|
||||
static let shared = HostBridge()
|
||||
private var localNetworkBrowser: NWBrowser?
|
||||
private var localNetworkTimeout: DispatchWorkItem?
|
||||
private var qrScanner: QrScannerViewController?
|
||||
|
||||
/// iOS 没有显式的本地网络权限 API;开始 Bonjour 浏览会在首次进入传输页时触发系统授权。
|
||||
func requestLocalNetworkPermission() {
|
||||
@@ -139,6 +140,28 @@ final class HostBridge {
|
||||
root.present(nav, animated: true)
|
||||
}
|
||||
|
||||
func presentQrScanner(from userInfo: [AnyHashable: Any]?) {
|
||||
guard let requestId = userInfo?["requestId"] as? String else { return }
|
||||
let reply: (String) -> Void = { result in
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.Name("llmpQrScanResult"),
|
||||
object: nil,
|
||||
userInfo: ["requestId": requestId, "result": result]
|
||||
)
|
||||
}
|
||||
guard qrScanner == nil, let root = topViewController(), !root.isBeingDismissed else {
|
||||
reply("")
|
||||
return
|
||||
}
|
||||
let scanner = QrScannerViewController(labels: userInfo ?? [:]) { [weak self] result in
|
||||
self?.qrScanner = nil
|
||||
reply(result)
|
||||
}
|
||||
qrScanner = scanner
|
||||
scanner.modalPresentationStyle = .fullScreen
|
||||
root.present(scanner, animated: true)
|
||||
}
|
||||
|
||||
private func topViewController() -> UIViewController? {
|
||||
let scenes = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
/// Native camera scanner shared by song transfer and data sync.
|
||||
final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||
private let labels: [AnyHashable: Any]
|
||||
private let onResult: (String) -> Void
|
||||
private let session = AVCaptureSession()
|
||||
private let sessionQueue = DispatchQueue(label: "llmp.qr-camera")
|
||||
private lazy var preview = AVCaptureVideoPreviewLayer(session: session)
|
||||
// Camera configuration and start/stop are serialized on sessionQueue.
|
||||
private var configured = false
|
||||
private var finished = false
|
||||
private var requestedCamera = false
|
||||
private var foregroundObserver: NSObjectProtocol?
|
||||
|
||||
init(labels: [AnyHashable: Any], onResult: @escaping (String) -> Void) {
|
||||
self.labels = labels
|
||||
self.onResult = onResult
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
deinit {
|
||||
if let foregroundObserver { NotificationCenter.default.removeObserver(foregroundObserver) }
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
preview.videoGravity = .resizeAspectFill
|
||||
view.layer.addSublayer(preview)
|
||||
|
||||
let cancel = UIButton(type: .system)
|
||||
var cancelStyle = UIButton.Configuration.plain()
|
||||
cancelStyle.title = label("cancel")
|
||||
cancelStyle.baseForegroundColor = .white
|
||||
cancelStyle.background.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
||||
cancelStyle.background.cornerRadius = 12
|
||||
cancelStyle.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 20)
|
||||
cancel.configuration = cancelStyle
|
||||
cancel.addTarget(self, action: #selector(cancelScan), for: .touchUpInside)
|
||||
cancel.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(cancel)
|
||||
|
||||
let hint = UILabel()
|
||||
hint.text = label("title")
|
||||
hint.textColor = .white
|
||||
hint.font = .systemFont(ofSize: 18, weight: .medium)
|
||||
hint.textAlignment = .center
|
||||
hint.numberOfLines = 0
|
||||
hint.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
||||
hint.layer.cornerRadius = 12
|
||||
hint.clipsToBounds = true
|
||||
hint.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(hint)
|
||||
NSLayoutConstraint.activate([
|
||||
cancel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 20),
|
||||
cancel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
|
||||
hint.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
hint.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -24),
|
||||
hint.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24),
|
||||
hint.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24),
|
||||
hint.heightAnchor.constraint(greaterThanOrEqualToConstant: 56),
|
||||
])
|
||||
foregroundObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self, !self.finished, self.viewIfLoaded?.window != nil else { return }
|
||||
self.startConfiguredSession()
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
guard !requestedCamera else { startConfiguredSession(); return }
|
||||
requestedCamera = true
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized:
|
||||
configureCamera()
|
||||
case .notDetermined:
|
||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
guard let self, !self.finished else { return }
|
||||
if granted { self.configureCamera() } else { self.showFailure("permission", offerSettings: true) }
|
||||
}
|
||||
}
|
||||
case .denied, .restricted:
|
||||
showFailure("permission", offerSettings: true)
|
||||
@unknown default:
|
||||
showFailure("failed")
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
sessionQueue.async { [session] in
|
||||
if session.isRunning { session.stopRunning() }
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
// Also release the Kotlin request if the host dismisses this screen externally.
|
||||
if isBeingDismissed && !finished {
|
||||
finished = true
|
||||
onResult("")
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
preview.frame = view.bounds
|
||||
guard let orientation = view.window?.windowScene?.interfaceOrientation,
|
||||
let connection = preview.connection else { return }
|
||||
let angle: CGFloat
|
||||
switch orientation {
|
||||
case .landscapeLeft: angle = 180
|
||||
case .landscapeRight: angle = 0
|
||||
case .portraitUpsideDown: angle = 270
|
||||
default: angle = 90
|
||||
}
|
||||
if connection.isVideoRotationAngleSupported(angle) { connection.videoRotationAngle = angle }
|
||||
}
|
||||
|
||||
private func configureCamera() {
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let camera = AVCaptureDevice.default(for: .video) else {
|
||||
self.reportFailure("unavailable")
|
||||
return
|
||||
}
|
||||
do {
|
||||
let input = try AVCaptureDeviceInput(device: camera)
|
||||
let output = AVCaptureMetadataOutput()
|
||||
self.session.beginConfiguration()
|
||||
self.session.sessionPreset = .high
|
||||
guard self.session.canAddInput(input), self.session.canAddOutput(output) else {
|
||||
self.session.commitConfiguration()
|
||||
self.reportFailure("failed")
|
||||
return
|
||||
}
|
||||
self.session.addInput(input)
|
||||
self.session.addOutput(output)
|
||||
guard output.availableMetadataObjectTypes.contains(.qr) else {
|
||||
self.session.commitConfiguration()
|
||||
self.reportFailure("failed")
|
||||
return
|
||||
}
|
||||
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||
output.metadataObjectTypes = [.qr]
|
||||
self.session.commitConfiguration()
|
||||
self.configured = true
|
||||
self.session.startRunning()
|
||||
DispatchQueue.main.async { [weak self] in self?.view.setNeedsLayout() }
|
||||
} catch {
|
||||
self.reportFailure("failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startConfiguredSession() {
|
||||
guard !finished else { return }
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self, self.configured, !self.session.isRunning else { return }
|
||||
self.session.startRunning()
|
||||
}
|
||||
}
|
||||
|
||||
private func reportFailure(_ key: String) {
|
||||
DispatchQueue.main.async { [weak self] in self?.showFailure(key) }
|
||||
}
|
||||
|
||||
private func showFailure(_ key: String, offerSettings: Bool = false) {
|
||||
guard !finished, presentedViewController == nil else { return }
|
||||
let alert = UIAlertController(title: label("title"), message: label(key), preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: label("cancel"), style: .cancel) { [weak self] _ in self?.finish("") })
|
||||
if offerSettings {
|
||||
alert.addAction(UIAlertAction(title: label("settings"), style: .default) { [weak self] _ in
|
||||
self?.finish("") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) }
|
||||
}
|
||||
})
|
||||
}
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func label(_ key: String) -> String { labels[key] as? String ?? "" }
|
||||
|
||||
@objc private func cancelScan() { finish("") }
|
||||
|
||||
private func finish(_ result: String, completion: (() -> Void)? = nil) {
|
||||
guard !finished else { return }
|
||||
finished = true
|
||||
sessionQueue.async { [session] in
|
||||
if session.isRunning { session.stopRunning() }
|
||||
}
|
||||
dismiss(animated: true) { [onResult] in
|
||||
onResult(result)
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput objects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||
guard !finished,
|
||||
let code = objects.compactMap({ $0 as? AVMetadataMachineReadableCodeObject }).first,
|
||||
let value = code.stringValue, !value.isEmpty else { return }
|
||||
finish(value)
|
||||
}
|
||||
}
|
||||