Files
QuickDrop/QuickDropiOS/ReceiveModel.swift
T

320 lines
12 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.
//
// ReceiveModel.swift
// QuickDrop
//
// Created by Leon Böttger on 16.08.25.
//
import SwiftUI
import StoreKit
import UserNotifications
#if os(macOS)
import BezelNotification
#else
import LUI
#endif
class ReceiveModel: ObservableObject, InboundAppDelegate {
#if os(macOS)
@Published var progress: Double? = nil
private var toastWindow: NSWindow?
private var toastHosting: NSHostingView<QuickDropToastView>?
#endif
let controlPlusScreen: (Bool) -> Void
init(controlPlusScreen: @escaping (Bool) -> Void = { _ in }) {
self.controlPlusScreen = controlPlusScreen
NearbyConnectionManager.shared.mainAppDelegate = self
NearbyConnectionManager.shared.becomeVisible()
}
func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo, acceptAutomatically: Bool) {
#if os(macOS)
NSApp.activate(ignoringOtherApps: true)
AudioManager.playIncomingFileSound()
#endif
let fileStr: String
if let textTitle = transfer.textDescription {
fileStr = textTitle
} else if transfer.files.count == 1 {
fileStr = transfer.files[0].name
} else {
fileStr = String.localizedStringWithFormat("NFiles".localized(), transfer.files.count)
}
let mainMessage: String
let name = device.name ?? "Android"
switch transfer.type {
case .file:
mainMessage = String(format: (acceptAutomatically ? "DeviceCurrentlySendingFiles" : "DeviceSendingFiles").localized(), arguments: [name, fileStr])
case .text:
mainMessage = String(format: (acceptAutomatically ? "DeviceCurrentlySendingText" : "DeviceSendingText").localized(), arguments: [name, fileStr])
case .url:
mainMessage = String(format: (acceptAutomatically ? "DeviceCurrentlySendingUrl" : "DeviceSendingUrl").localized(), arguments: [name, fileStr])
}
let pinCodeMessage = String(format: "PinCode".localized(), arguments: [transfer.pinCode ?? "?"])
let transferID = transfer.id
#if os(macOS)
let isMac = true
#else
let isMac = false
#endif
if acceptAutomatically && isMac {
pressAcceptButton(transferID: transfer.id, trustDevice: false)
if transfer.type != .file {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
if granted {
log("[ReceiveModel] User granted notification permissions")
let notificationContent = UNMutableNotificationContent()
notificationContent.title = "QuickDrop"
notificationContent.body = mainMessage
notificationContent.sound = nil
let notificationId = UUID().uuidString
let notificationReq = UNNotificationRequest(identifier: notificationId, content: notificationContent, trigger: nil)
UNUserNotificationCenter.current().add(notificationReq)
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [notificationId])
}
}
}
}
}
else {
let title = "QuickDrop - \(pinCodeMessage)"
let primaryButtonTitle = "Accept".localized()
let primaryButtonAction = { (trustDevice: Bool) in self.pressAcceptButton(transferID: transferID, trustDevice: trustDevice) }
let secondaryButtonTitle = "Decline".localized()
let secondaryButtonAction = { NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: false, trustDevice: false, storeInTemp: false) }
#if os(macOS)
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = title
alert.informativeText = mainMessage
alert.addButton(withTitle: primaryButtonTitle)
alert.addButton(withTitle: secondaryButtonTitle)
if transfer.allowsToBeAddedAsTrustedDevice {
alert.showsSuppressionButton = true
alert.suppressionButton?.title = "AutoAcceptFromThisDevice".localized()
}
let result = alert.runModal()
if result == .alertFirstButtonReturn {
primaryButtonAction(alert.suppressionButton?.state == .on)
} else if result == .alertSecondButtonReturn {
secondaryButtonAction()
}
#else
// iOS
let alwaysAcceptLabel = transfer.allowsToBeAddedAsTrustedDevice ? "AlwaysAccept".localized() : nil
ProgressAlert.shared.askForUserPermission(title: title, message: mainMessage, acceptLabel: primaryButtonTitle, acceptAlwaysLabel: alwaysAcceptLabel, rejectLabel: secondaryButtonTitle, acceptAutomatically: acceptAutomatically) { accepted in
switch accepted {
case .Accept:
primaryButtonAction(false)
case .AcceptAlways:
primaryButtonAction(true)
case .Decline:
secondaryButtonAction()
}
} onCancel: {
NearbyConnectionManager.shared.cancelTransfer(id: transferID)
}
#endif
}
}
func transferProgress(connectionID: String, progress: Double) {
#if os(iOS)
ProgressAlert.shared.updateProgress(progress)
#else
DispatchQueue.main.async {
self.progress = progress
self.showQuickDropToast(for: connectionID)
}
#endif
}
func connectionWasTerminated(from device: RemoteDeviceInfo, wasPlainTextTransfer: Bool, error: (any Error)?) {
#if os(macOS)
DispatchQueue.main.async {
self.hideQuickDropToast()
self.progress = nil
}
finish()
#else
ProgressAlert.shared.updateProgress(nil) {
finish()
}
#endif
func finish() {
if let error = error {
#if os(iOS)
errorVibration()
#endif
controlPlusScreen(false)
ErrorAlertHandler.shared.showErrorAlert(for: device.name ?? "Android", error: error)
} else {
#if os(iOS)
doubleVibration()
#endif
if wasPlainTextTransfer {
showCopiedToClipboardAlert()
}
let currentCount = Settings.shared.incomingTransmissionCount
#if os(macOS)
if currentCount == 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
SKStoreReviewController.requestReview()
}
}
#endif
Settings.shared.incomingTransmissionCount = currentCount + 1
log("[ReceiveModel] Successful transmission. Current count: \(currentCount)")
}
}
}
private func showCopiedToClipboardAlert() {
#if os(macOS)
DispatchQueue.main.async {
BezelNotification.show(messageText: "InsertedIntoClipboard".localized(), icon: .clipboard)
}
#endif
}
private func pressAcceptButton(transferID: String, trustDevice: Bool) {
if isFileTransferRestricted() {
log("[ReceiveModel] Showing plus screen...")
NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: true, trustDevice: trustDevice, storeInTemp: true)
controlPlusScreen(true)
} else {
NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: true, trustDevice: trustDevice, storeInTemp: false)
}
}
// MARK: - macOS Progress Overlay
#if os(macOS)
func showQuickDropToast(for connectionID: String) {
guard toastWindow == nil else { return }
let contentView = QuickDropToastView(
receiveModel: self,
onCancel: {
NearbyConnectionManager.shared.cancelTransfer(id: connectionID)
}
)
let hostingView = NSHostingView(rootView: contentView)
hostingView.frame.size = toastViewSize
// Prefer the screen under the mouse, then the key window’s screen, then main
let screen = NSScreen.screens.first { $0.frame.contains(NSEvent.mouseLocation) }
?? NSApp.keyWindow?.screen
?? NSScreen.main
guard let screen else { return }
let frame = screen.frame
let visible = screen.visibleFrame
let marginX: CGFloat = 24
let marginFromTop: CGFloat = 30
// If the menu bar is auto-hidden, visible.maxY == frame.maxY; subtract the status bar thickness to stay clear
let menuBarHidden = abs(frame.maxY - visible.maxY) < 0.5
let extraTopInset = menuBarHidden ? NSStatusBar.system.thickness : 0
let targetX = visible.maxX - toastViewSize.width - marginX
let targetY = visible.maxY - toastViewSize.height - marginFromTop - extraTopInset
// Start slightly above the final spot for a smooth slide-in
let startY = min(frame.maxY + 10, targetY + 10)
let startFrame = CGRect(x: targetX, y: startY, width: toastViewSize.width, height: toastViewSize.height)
let window = NSWindow(contentRect: startFrame,
styleMask: [.borderless],
backing: .buffered,
defer: false)
window.isOpaque = false
window.backgroundColor = .clear
window.hasShadow = true
window.level = .statusBar
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
window.contentView = hostingView
window.ignoresMouseEvents = false
window.alphaValue = 0
window.makeKeyAndOrderFront(nil)
self.toastWindow = window
self.toastHosting = hostingView
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.35
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
window.animator().setFrameOrigin(CGPoint(x: targetX, y: targetY))
window.animator().alphaValue = 1
}
}
func hideQuickDropToast() {
guard let window = toastWindow,
let screen = NSScreen.main else { return }
let offscreenY = screen.visibleFrame.origin.y + screen.visibleFrame.height + 20
NSAnimationContext.runAnimationGroup({ ctx in
ctx.duration = 0.35
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
window.animator().setFrameOrigin(CGPoint(x: window.frame.origin.x, y: offscreenY))
window.animator().alphaValue = 0
}, completionHandler: {
window.orderOut(nil)
self.toastWindow = nil
self.toastHosting = nil
})
}
#endif
}