2025-10-09 16:37:58 +05:30
|
|
|
import AVFoundation
|
2025-10-11 13:53:30 +05:30
|
|
|
import BackgroundTasks
|
2025-08-10 01:20:45 +05:30
|
|
|
import Flutter
|
2025-11-25 00:08:51 +05:30
|
|
|
import AppIntents
|
2025-08-10 01:20:45 +05:30
|
|
|
import UIKit
|
2025-11-25 00:08:51 +05:30
|
|
|
import UniformTypeIdentifiers
|
2025-12-18 11:40:16 +05:30
|
|
|
import WebKit
|
2025-08-10 01:20:45 +05:30
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
/// Manages AVAudioSession for voice calls in the background.
|
|
|
|
|
///
|
|
|
|
|
/// IMPORTANT: This manager is ONLY used for server-side STT (speech-to-text).
|
|
|
|
|
/// When using local STT via speech_to_text plugin, that plugin manages its own
|
|
|
|
|
/// audio session. Do NOT activate this manager when local STT is in use to
|
|
|
|
|
/// avoid audio session conflicts.
|
|
|
|
|
///
|
|
|
|
|
/// The voice_call_service.dart checks `useServerMic` before calling
|
|
|
|
|
/// startBackgroundExecution with requiresMicrophone:true.
|
2025-10-09 16:37:58 +05:30
|
|
|
final class VoiceBackgroundAudioManager {
|
|
|
|
|
static let shared = VoiceBackgroundAudioManager()
|
|
|
|
|
|
|
|
|
|
private var isActive = false
|
2025-12-20 22:10:28 +05:30
|
|
|
private let lock = NSLock()
|
|
|
|
|
|
|
|
|
|
/// Flag indicating another component (e.g., speech_to_text plugin) owns the audio session.
|
|
|
|
|
/// When true, this manager will skip activation to avoid conflicts.
|
|
|
|
|
private var externalSessionOwner = false
|
2025-10-09 16:37:58 +05:30
|
|
|
|
|
|
|
|
private init() {}
|
2025-12-20 22:10:28 +05:30
|
|
|
|
|
|
|
|
/// Mark that an external component (e.g., speech_to_text) is managing the audio session.
|
|
|
|
|
/// Call this before starting local STT to prevent conflicts.
|
|
|
|
|
func setExternalSessionOwner(_ isExternal: Bool) {
|
|
|
|
|
lock.lock()
|
|
|
|
|
defer { lock.unlock() }
|
|
|
|
|
externalSessionOwner = isExternal
|
|
|
|
|
|
|
|
|
|
if isExternal {
|
|
|
|
|
print("VoiceBackgroundAudioManager: External session owner active, deferring to external management")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Check if an external component owns the audio session.
|
|
|
|
|
var hasExternalSessionOwner: Bool {
|
|
|
|
|
lock.lock()
|
|
|
|
|
defer { lock.unlock() }
|
|
|
|
|
return externalSessionOwner
|
|
|
|
|
}
|
2025-10-09 16:37:58 +05:30
|
|
|
|
|
|
|
|
func activate() {
|
2025-12-20 22:10:28 +05:30
|
|
|
lock.lock()
|
|
|
|
|
defer { lock.unlock() }
|
|
|
|
|
|
2025-10-09 16:37:58 +05:30
|
|
|
guard !isActive else { return }
|
2025-12-20 22:10:28 +05:30
|
|
|
|
|
|
|
|
// Skip if another component is managing the audio session
|
|
|
|
|
if externalSessionOwner {
|
|
|
|
|
print("VoiceBackgroundAudioManager: Skipping activation - external session owner active")
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-10-09 16:37:58 +05:30
|
|
|
|
|
|
|
|
let session = AVAudioSession.sharedInstance()
|
|
|
|
|
do {
|
2025-12-20 22:10:28 +05:30
|
|
|
// Check current category to avoid unnecessary reconfiguration
|
|
|
|
|
// This helps prevent conflicts if speech_to_text already configured the session
|
|
|
|
|
let currentCategory = session.category
|
|
|
|
|
let needsReconfiguration = currentCategory != .playAndRecord
|
|
|
|
|
|
|
|
|
|
if needsReconfiguration {
|
|
|
|
|
try session.setCategory(
|
|
|
|
|
.playAndRecord,
|
|
|
|
|
mode: .voiceChat,
|
|
|
|
|
options: [
|
|
|
|
|
.allowBluetooth,
|
|
|
|
|
.allowBluetoothA2DP,
|
|
|
|
|
.mixWithOthers,
|
|
|
|
|
.defaultToSpeaker,
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
}
|
2025-10-09 16:37:58 +05:30
|
|
|
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
|
|
|
isActive = true
|
|
|
|
|
} catch {
|
|
|
|
|
print("VoiceBackgroundAudioManager: Failed to activate audio session: \(error)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func deactivate() {
|
2025-12-20 22:10:28 +05:30
|
|
|
lock.lock()
|
|
|
|
|
defer { lock.unlock() }
|
|
|
|
|
|
2025-10-09 16:37:58 +05:30
|
|
|
guard isActive else { return }
|
2025-12-20 22:10:28 +05:30
|
|
|
|
|
|
|
|
// Don't deactivate if external owner - they manage their own lifecycle
|
|
|
|
|
if externalSessionOwner {
|
|
|
|
|
print("VoiceBackgroundAudioManager: Skipping deactivation - external session owner active")
|
|
|
|
|
isActive = false
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-10-09 16:37:58 +05:30
|
|
|
|
|
|
|
|
let session = AVAudioSession.sharedInstance()
|
|
|
|
|
do {
|
|
|
|
|
try session.setActive(false, options: .notifyOthersOnDeactivation)
|
|
|
|
|
} catch {
|
|
|
|
|
print("VoiceBackgroundAudioManager: Failed to deactivate audio session: \(error)")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isActive = false
|
|
|
|
|
}
|
2025-12-20 22:10:28 +05:30
|
|
|
|
|
|
|
|
/// Check if audio session is currently active (thread-safe).
|
|
|
|
|
var isSessionActive: Bool {
|
|
|
|
|
lock.lock()
|
|
|
|
|
defer { lock.unlock() }
|
|
|
|
|
return isActive
|
|
|
|
|
}
|
2025-10-09 16:37:58 +05:30
|
|
|
}
|
|
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Background streaming handler class
|
|
|
|
|
class BackgroundStreamingHandler: NSObject {
|
|
|
|
|
private var backgroundTask: UIBackgroundTaskIdentifier = .invalid
|
2025-10-11 13:53:30 +05:30
|
|
|
private var bgProcessingTask: BGTask?
|
2025-08-16 20:27:44 +05:30
|
|
|
private var activeStreams: Set<String> = []
|
2025-10-09 16:37:58 +05:30
|
|
|
private var microphoneStreams: Set<String> = []
|
2025-08-16 20:27:44 +05:30
|
|
|
private var channel: FlutterMethodChannel?
|
2025-10-11 13:53:30 +05:30
|
|
|
|
|
|
|
|
static let processingTaskIdentifier = "app.cogwheel.conduit.refresh"
|
|
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
override init() {
|
|
|
|
|
super.init()
|
|
|
|
|
setupNotifications()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func setup(with channel: FlutterMethodChannel) {
|
|
|
|
|
self.channel = channel
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func setupNotifications() {
|
|
|
|
|
NotificationCenter.default.addObserver(
|
|
|
|
|
self,
|
|
|
|
|
selector: #selector(appDidEnterBackground),
|
|
|
|
|
name: UIApplication.didEnterBackgroundNotification,
|
|
|
|
|
object: nil
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
NotificationCenter.default.addObserver(
|
|
|
|
|
self,
|
|
|
|
|
selector: #selector(appWillEnterForeground),
|
|
|
|
|
name: UIApplication.willEnterForegroundNotification,
|
|
|
|
|
object: nil
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@objc private func appDidEnterBackground() {
|
|
|
|
|
if !activeStreams.isEmpty {
|
|
|
|
|
startBackgroundTask()
|
2025-12-20 22:10:28 +05:30
|
|
|
scheduleBGProcessingTask()
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@objc private func appWillEnterForeground() {
|
|
|
|
|
endBackgroundTask()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
|
|
|
switch call.method {
|
|
|
|
|
case "startBackgroundExecution":
|
|
|
|
|
if let args = call.arguments as? [String: Any],
|
|
|
|
|
let streamIds = args["streamIds"] as? [String] {
|
2025-10-09 16:37:58 +05:30
|
|
|
let requiresMic = args["requiresMicrophone"] as? Bool ?? false
|
|
|
|
|
startBackgroundExecution(streamIds: streamIds, requiresMic: requiresMic)
|
2025-08-16 20:27:44 +05:30
|
|
|
result(nil)
|
|
|
|
|
} else {
|
|
|
|
|
result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "stopBackgroundExecution":
|
|
|
|
|
if let args = call.arguments as? [String: Any],
|
|
|
|
|
let streamIds = args["streamIds"] as? [String] {
|
|
|
|
|
stopBackgroundExecution(streamIds: streamIds)
|
|
|
|
|
result(nil)
|
|
|
|
|
} else {
|
|
|
|
|
result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "keepAlive":
|
|
|
|
|
keepAlive()
|
|
|
|
|
result(nil)
|
|
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
case "checkBackgroundRefreshStatus":
|
|
|
|
|
// Check if background app refresh is enabled by the user
|
|
|
|
|
let status = UIApplication.shared.backgroundRefreshStatus
|
|
|
|
|
switch status {
|
|
|
|
|
case .available:
|
|
|
|
|
result(true)
|
|
|
|
|
case .denied, .restricted:
|
|
|
|
|
result(false)
|
|
|
|
|
@unknown default:
|
|
|
|
|
result(true) // Assume available for future cases
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "setExternalAudioSessionOwner":
|
|
|
|
|
// Coordinate with speech_to_text plugin to prevent audio session conflicts
|
2025-08-16 20:27:44 +05:30
|
|
|
if let args = call.arguments as? [String: Any],
|
2025-12-20 22:10:28 +05:30
|
|
|
let isExternal = args["isExternal"] as? Bool {
|
|
|
|
|
VoiceBackgroundAudioManager.shared.setExternalSessionOwner(isExternal)
|
2025-08-16 20:27:44 +05:30
|
|
|
result(nil)
|
|
|
|
|
} else {
|
2025-12-20 22:10:28 +05:30
|
|
|
result(FlutterError(code: "INVALID_ARGS", message: "Missing isExternal argument", details: nil))
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
|
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
case "getActiveStreamCount":
|
|
|
|
|
// Return count for Flutter-native state reconciliation
|
|
|
|
|
result(activeStreams.count)
|
|
|
|
|
|
|
|
|
|
case "stopAllBackgroundExecution":
|
|
|
|
|
// Stop all streams (used for reconciliation when orphaned service detected)
|
|
|
|
|
let allStreams = Array(activeStreams)
|
|
|
|
|
stopBackgroundExecution(streamIds: allStreams)
|
|
|
|
|
result(nil)
|
2025-08-16 20:27:44 +05:30
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
result(FlutterMethodNotImplemented)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-09 16:37:58 +05:30
|
|
|
private func startBackgroundExecution(streamIds: [String], requiresMic: Bool) {
|
2025-12-20 22:10:28 +05:30
|
|
|
// Add new stream IDs to active set
|
2025-10-10 20:56:28 +05:30
|
|
|
activeStreams.formUnion(streamIds)
|
2025-12-20 22:10:28 +05:30
|
|
|
|
|
|
|
|
// Clean up any mic streams that are no longer active (e.g., completed streams)
|
|
|
|
|
// This ensures microphoneStreams stays in sync with activeStreams
|
2025-10-10 20:56:28 +05:30
|
|
|
microphoneStreams.formIntersection(activeStreams)
|
2025-12-20 22:10:28 +05:30
|
|
|
|
|
|
|
|
// If these new streams require microphone, add them to the mic set
|
2025-10-09 16:37:58 +05:30
|
|
|
if requiresMic {
|
2025-10-10 20:56:28 +05:30
|
|
|
microphoneStreams.formUnion(streamIds)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
// Activate audio session for microphone access in background
|
2025-10-10 20:56:28 +05:30
|
|
|
if !microphoneStreams.isEmpty {
|
2025-10-09 16:37:58 +05:30
|
|
|
VoiceBackgroundAudioManager.shared.activate()
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
// Start background tasks if app is already backgrounded
|
2025-08-16 20:27:44 +05:30
|
|
|
if UIApplication.shared.applicationState == .background {
|
|
|
|
|
startBackgroundTask()
|
2025-10-11 13:53:30 +05:30
|
|
|
scheduleBGProcessingTask()
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
|
|
|
|
}
|
2025-10-09 16:37:58 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
private func stopBackgroundExecution(streamIds: [String]) {
|
|
|
|
|
streamIds.forEach { activeStreams.remove($0) }
|
2025-10-09 16:37:58 +05:30
|
|
|
streamIds.forEach { microphoneStreams.remove($0) }
|
|
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
if activeStreams.isEmpty {
|
|
|
|
|
endBackgroundTask()
|
2025-10-11 13:53:30 +05:30
|
|
|
cancelBGProcessingTask()
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
2025-10-09 16:37:58 +05:30
|
|
|
|
|
|
|
|
if microphoneStreams.isEmpty {
|
|
|
|
|
VoiceBackgroundAudioManager.shared.deactivate()
|
|
|
|
|
}
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func startBackgroundTask() {
|
|
|
|
|
guard backgroundTask == .invalid else { return }
|
|
|
|
|
|
|
|
|
|
backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "ConduitStreaming") { [weak self] in
|
2025-12-20 22:10:28 +05:30
|
|
|
guard let self = self else { return }
|
|
|
|
|
// Notify Flutter about streams being suspended before task expires
|
|
|
|
|
self.notifyStreamsSuspending(reason: "background_task_expiring")
|
|
|
|
|
self.channel?.invokeMethod("backgroundTaskExpiring", arguments: nil)
|
|
|
|
|
self.endBackgroundTask()
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func endBackgroundTask() {
|
|
|
|
|
guard backgroundTask != .invalid else { return }
|
|
|
|
|
|
|
|
|
|
UIApplication.shared.endBackgroundTask(backgroundTask)
|
|
|
|
|
backgroundTask = .invalid
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func keepAlive() {
|
2025-12-20 22:10:28 +05:30
|
|
|
// Use atomic task refresh: start new task before ending old one
|
|
|
|
|
// This prevents the brief window where iOS could suspend the app
|
2025-08-16 20:27:44 +05:30
|
|
|
if backgroundTask != .invalid {
|
2025-12-20 22:10:28 +05:30
|
|
|
let oldTask = backgroundTask
|
|
|
|
|
|
|
|
|
|
// Begin a new task BEFORE marking old one invalid
|
|
|
|
|
// This ensures continuous background execution coverage
|
|
|
|
|
let newTask = UIApplication.shared.beginBackgroundTask(withName: "ConduitStreaming") { [weak self] in
|
|
|
|
|
guard let self = self else { return }
|
|
|
|
|
self.notifyStreamsSuspending(reason: "keepalive_task_expiring")
|
|
|
|
|
self.channel?.invokeMethod("backgroundTaskExpiring", arguments: nil)
|
|
|
|
|
// End this specific task, not whatever is in backgroundTask
|
|
|
|
|
if self.backgroundTask != .invalid {
|
|
|
|
|
UIApplication.shared.endBackgroundTask(self.backgroundTask)
|
|
|
|
|
self.backgroundTask = .invalid
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only update state if we successfully got a new task
|
|
|
|
|
if newTask != .invalid {
|
|
|
|
|
backgroundTask = newTask
|
|
|
|
|
// Now safe to end old task
|
|
|
|
|
UIApplication.shared.endBackgroundTask(oldTask)
|
|
|
|
|
}
|
|
|
|
|
// If newTask is .invalid, keep the old task running (it's better than nothing)
|
|
|
|
|
} else if !activeStreams.isEmpty {
|
|
|
|
|
// No current task but we have active streams - start one
|
2025-08-16 20:27:44 +05:30
|
|
|
startBackgroundTask()
|
|
|
|
|
}
|
2025-10-09 16:37:58 +05:30
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
// Keep audio session active for microphone streams
|
2025-10-09 16:37:58 +05:30
|
|
|
if !microphoneStreams.isEmpty {
|
|
|
|
|
VoiceBackgroundAudioManager.shared.activate()
|
|
|
|
|
}
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
|
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
private func notifyStreamsSuspending(reason: String) {
|
|
|
|
|
guard !activeStreams.isEmpty else { return }
|
|
|
|
|
channel?.invokeMethod("streamsSuspending", arguments: [
|
|
|
|
|
"streamIds": Array(activeStreams),
|
|
|
|
|
"reason": reason
|
|
|
|
|
])
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
2025-10-11 13:53:30 +05:30
|
|
|
|
|
|
|
|
// MARK: - BGTaskScheduler Methods
|
2025-12-20 22:10:28 +05:30
|
|
|
//
|
|
|
|
|
// IMPORTANT: BGProcessingTask limitations on iOS:
|
|
|
|
|
// - iOS schedules these during opportunistic windows (device charging, overnight, etc.)
|
|
|
|
|
// - The earliestBeginDate is a HINT, not a guarantee of immediate execution
|
|
|
|
|
// - Typical execution time is ~1-3 minutes when granted, but may NOT run at all
|
|
|
|
|
// - BGProcessingTask is "best-effort bonus time", NOT "guaranteed extended execution"
|
|
|
|
|
//
|
|
|
|
|
// For reliable background execution:
|
|
|
|
|
// - Voice calls: UIBackgroundModes "audio" + AVAudioSession keeps app alive reliably
|
|
|
|
|
// - Chat streaming: beginBackgroundTask gives ~30 seconds (only reliable mechanism)
|
|
|
|
|
// - Socket keepalive: Best-effort; iOS may suspend app regardless
|
|
|
|
|
//
|
|
|
|
|
// The BGProcessingTask here provides opportunistic extended time for long-running
|
|
|
|
|
// streams, but callers should NOT depend on it for critical functionality.
|
2025-10-11 13:53:30 +05:30
|
|
|
|
|
|
|
|
func registerBackgroundTasks() {
|
|
|
|
|
BGTaskScheduler.shared.register(
|
|
|
|
|
forTaskWithIdentifier: Self.processingTaskIdentifier,
|
|
|
|
|
using: nil
|
|
|
|
|
) { [weak self] task in
|
|
|
|
|
self?.handleBGProcessingTask(task: task as! BGProcessingTask)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func scheduleBGProcessingTask() {
|
|
|
|
|
// Cancel any existing task
|
|
|
|
|
cancelBGProcessingTask()
|
|
|
|
|
|
|
|
|
|
let request = BGProcessingTaskRequest(identifier: Self.processingTaskIdentifier)
|
|
|
|
|
request.requiresNetworkConnectivity = true
|
|
|
|
|
request.requiresExternalPower = false
|
|
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
// Request execution as soon as possible (best-effort only)
|
|
|
|
|
// WARNING: iOS heavily throttles BGProcessingTask - it may run hours later or not at all.
|
|
|
|
|
// This is supplementary to beginBackgroundTask, which is the primary mechanism.
|
2025-10-11 13:53:30 +05:30
|
|
|
request.earliestBeginDate = Date(timeIntervalSinceNow: 1)
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
try BGTaskScheduler.shared.submit(request)
|
|
|
|
|
print("BackgroundStreamingHandler: Scheduled BGProcessingTask")
|
|
|
|
|
} catch {
|
|
|
|
|
print("BackgroundStreamingHandler: Failed to schedule BGProcessingTask: \(error)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func cancelBGProcessingTask() {
|
|
|
|
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.processingTaskIdentifier)
|
|
|
|
|
print("BackgroundStreamingHandler: Cancelled BGProcessingTask")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func handleBGProcessingTask(task: BGProcessingTask) {
|
|
|
|
|
print("BackgroundStreamingHandler: BGProcessingTask started")
|
|
|
|
|
bgProcessingTask = task
|
|
|
|
|
|
|
|
|
|
// Schedule a new task for continuation if streams are still active
|
|
|
|
|
if !activeStreams.isEmpty {
|
|
|
|
|
scheduleBGProcessingTask()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set expiration handler
|
|
|
|
|
task.expirationHandler = { [weak self] in
|
2025-12-20 22:10:28 +05:30
|
|
|
guard let self = self else { return }
|
2025-10-11 13:53:30 +05:30
|
|
|
print("BackgroundStreamingHandler: BGProcessingTask expiring")
|
2025-12-20 22:10:28 +05:30
|
|
|
// Notify Flutter about streams being suspended
|
|
|
|
|
self.notifyStreamsSuspending(reason: "bg_processing_task_expiring")
|
|
|
|
|
self.channel?.invokeMethod("backgroundTaskExpiring", arguments: nil)
|
|
|
|
|
self.bgProcessingTask = nil
|
2025-10-11 13:53:30 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Notify Flutter that we have extended background time
|
|
|
|
|
channel?.invokeMethod("backgroundTaskExtended", arguments: [
|
|
|
|
|
"streamIds": Array(activeStreams),
|
|
|
|
|
"estimatedTime": 180 // ~3 minutes typical for BGProcessingTask
|
|
|
|
|
])
|
|
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
// Keep task alive while streams are active using async Task
|
|
|
|
|
Task { [weak self] in
|
|
|
|
|
guard let self = self else {
|
|
|
|
|
task.setTaskCompleted(success: false)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-10-11 13:53:30 +05:30
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
let keepAliveInterval: UInt64 = 30_000_000_000 // 30 seconds in nanoseconds
|
2025-10-11 13:53:30 +05:30
|
|
|
var elapsedTime: TimeInterval = 0
|
|
|
|
|
let maxTime: TimeInterval = 180 // 3 minutes
|
|
|
|
|
|
|
|
|
|
while !self.activeStreams.isEmpty && elapsedTime < maxTime {
|
2025-12-20 22:10:28 +05:30
|
|
|
try? await Task.sleep(nanoseconds: keepAliveInterval)
|
|
|
|
|
elapsedTime += 30
|
2025-10-11 13:53:30 +05:30
|
|
|
|
|
|
|
|
// Notify Flutter to keep streams alive
|
2025-12-20 22:10:28 +05:30
|
|
|
await MainActor.run {
|
2025-10-11 13:53:30 +05:30
|
|
|
self.channel?.invokeMethod("backgroundKeepAlive", arguments: nil)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mark task as complete
|
|
|
|
|
task.setTaskCompleted(success: true)
|
|
|
|
|
self.bgProcessingTask = nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
deinit {
|
|
|
|
|
NotificationCenter.default.removeObserver(self)
|
|
|
|
|
endBackgroundTask()
|
2025-10-09 16:37:58 +05:30
|
|
|
VoiceBackgroundAudioManager.shared.deactivate()
|
2025-11-25 00:08:51 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 22:33:48 +05:30
|
|
|
/// Manages the method channel for App Intent invocations to Flutter.
|
|
|
|
|
/// Native Swift intents call this to invoke Flutter-side business logic.
|
|
|
|
|
final class AppIntentMethodChannel {
|
|
|
|
|
static var shared: AppIntentMethodChannel?
|
|
|
|
|
|
|
|
|
|
private let channel: FlutterMethodChannel
|
|
|
|
|
|
|
|
|
|
init(messenger: FlutterBinaryMessenger) {
|
|
|
|
|
channel = FlutterMethodChannel(
|
|
|
|
|
name: "conduit/app_intents",
|
|
|
|
|
binaryMessenger: messenger
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Invokes a Flutter handler for the given intent identifier.
|
|
|
|
|
func invokeIntent(
|
|
|
|
|
identifier: String,
|
|
|
|
|
parameters: [String: Any]
|
|
|
|
|
) async -> [String: Any] {
|
|
|
|
|
// No [weak self] needed here - the closure executes immediately on the
|
|
|
|
|
// main queue and there's no retain cycle risk. Using weak self would
|
|
|
|
|
// risk the continuation never resuming if self became nil.
|
|
|
|
|
return await withCheckedContinuation { continuation in
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
self.channel.invokeMethod(
|
|
|
|
|
identifier,
|
|
|
|
|
arguments: parameters
|
|
|
|
|
) { result in
|
|
|
|
|
if let dict = result as? [String: Any] {
|
|
|
|
|
continuation.resume(returning: dict)
|
|
|
|
|
} else {
|
|
|
|
|
continuation.resume(returning: [
|
|
|
|
|
"success": false,
|
|
|
|
|
"error": "Invalid response from Flutter"
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-25 00:08:51 +05:30
|
|
|
@available(iOS 16.0, *)
|
|
|
|
|
enum AppIntentError: Error {
|
|
|
|
|
case executionFailed(String)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@available(iOS 16.0, *)
|
|
|
|
|
struct AskConduitIntent: AppIntent {
|
|
|
|
|
static var title: LocalizedStringResource = "Ask Conduit"
|
|
|
|
|
static var description = IntentDescription(
|
|
|
|
|
"Start a Conduit chat with an optional prompt."
|
|
|
|
|
)
|
|
|
|
|
static var isDiscoverable = true
|
|
|
|
|
static var openAppWhenRun = true
|
|
|
|
|
|
|
|
|
|
@Parameter(
|
|
|
|
|
title: "Prompt",
|
|
|
|
|
requestValueDialog: IntentDialog("What should Conduit answer?")
|
|
|
|
|
)
|
|
|
|
|
var prompt: String?
|
|
|
|
|
|
|
|
|
|
init() {}
|
|
|
|
|
|
|
|
|
|
init(prompt: String?) {
|
|
|
|
|
self.prompt = prompt
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func perform() async throws
|
|
|
|
|
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
|
|
|
|
{
|
2025-12-04 22:33:48 +05:30
|
|
|
guard let channel = AppIntentMethodChannel.shared else {
|
|
|
|
|
throw AppIntentError.executionFailed("App not ready")
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-25 00:08:51 +05:30
|
|
|
let parameters: [String: Any] = prompt?.isEmpty == false
|
|
|
|
|
? ["prompt": prompt ?? ""]
|
|
|
|
|
: [:]
|
2025-12-04 22:33:48 +05:30
|
|
|
let result = await channel.invokeIntent(
|
2025-11-25 00:08:51 +05:30
|
|
|
identifier: "app.cogwheel.conduit.ask_chat",
|
|
|
|
|
parameters: parameters
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if let success = result["success"] as? Bool, success {
|
|
|
|
|
let value = result["value"] as? String ?? "Opening chat"
|
|
|
|
|
return .result(value: value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let message = result["error"] as? String
|
|
|
|
|
?? "Unable to open Conduit chat"
|
|
|
|
|
throw AppIntentError.executionFailed(message)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@available(iOS 16.0, *)
|
|
|
|
|
struct StartVoiceCallIntent: AppIntent {
|
|
|
|
|
static var title: LocalizedStringResource = "Start Voice Call"
|
|
|
|
|
static var description = IntentDescription(
|
|
|
|
|
"Start a live voice call with Conduit."
|
|
|
|
|
)
|
|
|
|
|
static var isDiscoverable = true
|
|
|
|
|
static var openAppWhenRun = true
|
|
|
|
|
|
|
|
|
|
func perform() async throws
|
|
|
|
|
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
|
|
|
|
{
|
2025-12-04 22:33:48 +05:30
|
|
|
guard let channel = AppIntentMethodChannel.shared else {
|
|
|
|
|
throw AppIntentError.executionFailed("App not ready")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let result = await channel.invokeIntent(
|
2025-11-25 00:08:51 +05:30
|
|
|
identifier: "app.cogwheel.conduit.start_voice_call",
|
|
|
|
|
parameters: [:]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if let success = result["success"] as? Bool, success {
|
|
|
|
|
let value = result["value"] as? String ?? "Starting voice call"
|
|
|
|
|
return .result(value: value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let message = result["error"] as? String
|
|
|
|
|
?? "Unable to start voice call"
|
|
|
|
|
throw AppIntentError.executionFailed(message)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@available(iOS 16.0, *)
|
|
|
|
|
struct ConduitSendTextIntent: AppIntent {
|
|
|
|
|
static var title: LocalizedStringResource = "Send to Conduit"
|
|
|
|
|
static var description = IntentDescription(
|
|
|
|
|
"Start a Conduit chat with provided text."
|
|
|
|
|
)
|
|
|
|
|
static var isDiscoverable = true
|
|
|
|
|
static var openAppWhenRun = true
|
|
|
|
|
|
|
|
|
|
@Parameter(
|
|
|
|
|
title: "Text",
|
|
|
|
|
requestValueDialog: IntentDialog("What should Conduit process?")
|
|
|
|
|
)
|
|
|
|
|
var text: String?
|
|
|
|
|
|
|
|
|
|
func perform() async throws
|
|
|
|
|
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
|
|
|
|
{
|
2025-12-04 22:33:48 +05:30
|
|
|
guard let channel = AppIntentMethodChannel.shared else {
|
|
|
|
|
throw AppIntentError.executionFailed("App not ready")
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-25 00:08:51 +05:30
|
|
|
let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines)
|
2025-12-04 22:33:48 +05:30
|
|
|
let result = await channel.invokeIntent(
|
2025-11-25 00:08:51 +05:30
|
|
|
identifier: "app.cogwheel.conduit.send_text",
|
|
|
|
|
parameters: ["text": trimmed ?? ""]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if let success = result["success"] as? Bool, success {
|
|
|
|
|
let value = result["value"] as? String ?? "Sent to Conduit"
|
|
|
|
|
return .result(value: value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let message = result["error"] as? String ?? "Unable to send text"
|
|
|
|
|
throw AppIntentError.executionFailed(message)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@available(iOS 16.0, *)
|
|
|
|
|
struct ConduitSendUrlIntent: AppIntent {
|
|
|
|
|
static var title: LocalizedStringResource = "Send Link to Conduit"
|
|
|
|
|
static var description = IntentDescription(
|
|
|
|
|
"Send a URL into Conduit for summary or analysis."
|
|
|
|
|
)
|
|
|
|
|
static var isDiscoverable = true
|
|
|
|
|
static var openAppWhenRun = true
|
|
|
|
|
|
|
|
|
|
@Parameter(
|
|
|
|
|
title: "URL",
|
|
|
|
|
requestValueDialog: IntentDialog("Which link should Conduit analyze?")
|
|
|
|
|
)
|
|
|
|
|
var url: URL
|
|
|
|
|
|
|
|
|
|
func perform() async throws
|
|
|
|
|
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
|
|
|
|
{
|
2025-12-04 22:33:48 +05:30
|
|
|
guard let channel = AppIntentMethodChannel.shared else {
|
|
|
|
|
throw AppIntentError.executionFailed("App not ready")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let result = await channel.invokeIntent(
|
2025-11-25 00:08:51 +05:30
|
|
|
identifier: "app.cogwheel.conduit.send_url",
|
|
|
|
|
parameters: ["url": url.absoluteString]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if let success = result["success"] as? Bool, success {
|
|
|
|
|
let value = result["value"] as? String ?? "Sent link to Conduit"
|
|
|
|
|
return .result(value: value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let message = result["error"] as? String ?? "Unable to send link"
|
|
|
|
|
throw AppIntentError.executionFailed(message)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@available(iOS 16.0, *)
|
|
|
|
|
struct ConduitSendImageIntent: AppIntent {
|
|
|
|
|
static var title: LocalizedStringResource = "Send Image to Conduit"
|
|
|
|
|
static var description = IntentDescription(
|
|
|
|
|
"Send an image into Conduit for analysis."
|
|
|
|
|
)
|
|
|
|
|
static var isDiscoverable = true
|
|
|
|
|
static var openAppWhenRun = true
|
|
|
|
|
|
|
|
|
|
@Parameter(
|
|
|
|
|
title: "Image",
|
|
|
|
|
requestValueDialog: IntentDialog("Choose an image for Conduit.")
|
|
|
|
|
)
|
|
|
|
|
var image: IntentFile
|
|
|
|
|
|
|
|
|
|
func perform() async throws
|
|
|
|
|
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
|
|
|
|
{
|
2025-12-04 22:33:48 +05:30
|
|
|
guard let channel = AppIntentMethodChannel.shared else {
|
|
|
|
|
throw AppIntentError.executionFailed("App not ready")
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-25 00:08:51 +05:30
|
|
|
if let type = image.type, !type.conforms(to: .image) {
|
|
|
|
|
throw AppIntentError.executionFailed(
|
|
|
|
|
"Only image files are supported."
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let data = try image.data
|
|
|
|
|
let base64 = data.base64EncodedString()
|
|
|
|
|
let name = image.filename ?? "shared_image.jpg"
|
|
|
|
|
|
2025-12-04 22:33:48 +05:30
|
|
|
let result = await channel.invokeIntent(
|
2025-11-25 00:08:51 +05:30
|
|
|
identifier: "app.cogwheel.conduit.send_image",
|
|
|
|
|
parameters: [
|
|
|
|
|
"filename": name,
|
|
|
|
|
"bytes": base64,
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if let success = result["success"] as? Bool, success {
|
|
|
|
|
let value = result["value"] as? String ?? "Sent image to Conduit"
|
|
|
|
|
return .result(value: value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let message = result["error"] as? String ?? "Unable to send image"
|
|
|
|
|
throw AppIntentError.executionFailed(message)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@available(iOS 16.0, *)
|
|
|
|
|
struct AppShortcuts: AppShortcutsProvider {
|
|
|
|
|
static var appShortcuts: [AppShortcut] {
|
|
|
|
|
return [
|
|
|
|
|
AppShortcut(
|
|
|
|
|
intent: AskConduitIntent(),
|
|
|
|
|
phrases: [
|
|
|
|
|
"Ask with \(.applicationName)",
|
|
|
|
|
"Start chat in \(.applicationName)",
|
|
|
|
|
"Open composer in \(.applicationName)",
|
|
|
|
|
]
|
|
|
|
|
),
|
|
|
|
|
AppShortcut(
|
|
|
|
|
intent: StartVoiceCallIntent(),
|
|
|
|
|
phrases: [
|
|
|
|
|
"Start voice call in \(.applicationName)",
|
|
|
|
|
"Call with \(.applicationName)",
|
|
|
|
|
"Begin voice chat in \(.applicationName)",
|
|
|
|
|
]
|
|
|
|
|
),
|
|
|
|
|
AppShortcut(
|
|
|
|
|
intent: ConduitSendTextIntent(),
|
|
|
|
|
phrases: [
|
|
|
|
|
"Send text to \(.applicationName)",
|
|
|
|
|
"Share text with \(.applicationName)",
|
|
|
|
|
"Summarize this in \(.applicationName)",
|
|
|
|
|
]
|
|
|
|
|
),
|
|
|
|
|
AppShortcut(
|
|
|
|
|
intent: ConduitSendUrlIntent(),
|
|
|
|
|
phrases: [
|
|
|
|
|
"Summarize link in \(.applicationName)",
|
|
|
|
|
"Analyze link with \(.applicationName)",
|
|
|
|
|
"Send URL to \(.applicationName)",
|
|
|
|
|
]
|
|
|
|
|
),
|
|
|
|
|
AppShortcut(
|
|
|
|
|
intent: ConduitSendImageIntent(),
|
|
|
|
|
phrases: [
|
|
|
|
|
"Send image to \(.applicationName)",
|
|
|
|
|
"Analyze image with \(.applicationName)",
|
|
|
|
|
"Share photo to \(.applicationName)",
|
|
|
|
|
]
|
|
|
|
|
),
|
|
|
|
|
]
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
@main
|
|
|
|
|
@objc class AppDelegate: FlutterAppDelegate {
|
2025-08-16 20:27:44 +05:30
|
|
|
private var backgroundStreamingHandler: BackgroundStreamingHandler?
|
2025-10-11 13:53:30 +05:30
|
|
|
|
2025-12-18 11:40:16 +05:30
|
|
|
/// Checks if a cookie matches a given URL based on domain.
|
|
|
|
|
private func cookieMatchesUrl(cookie: HTTPCookie, url: URL) -> Bool {
|
|
|
|
|
guard let host = url.host?.lowercased() else { return false }
|
|
|
|
|
let domain = cookie.domain.lowercased()
|
|
|
|
|
|
|
|
|
|
// Remove leading dot from cookie domain if present
|
|
|
|
|
let cleanDomain = domain.hasPrefix(".") ? String(domain.dropFirst()) : domain
|
|
|
|
|
|
|
|
|
|
// Exact match or subdomain match
|
|
|
|
|
return host == cleanDomain || host.hasSuffix(".\(cleanDomain)")
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
override func application(
|
|
|
|
|
_ application: UIApplication,
|
|
|
|
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
|
|
|
|
) -> Bool {
|
|
|
|
|
GeneratedPluginRegistrant.register(with: self)
|
2025-10-11 13:53:30 +05:30
|
|
|
|
2025-12-04 22:33:48 +05:30
|
|
|
// Setup App Intents method channel for native -> Flutter communication
|
|
|
|
|
if let registrar = self.registrar(forPlugin: "AppIntentMethodChannel") {
|
|
|
|
|
AppIntentMethodChannel.shared = AppIntentMethodChannel(
|
|
|
|
|
messenger: registrar.messenger()
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-18 15:01:21 +05:30
|
|
|
// Setup background streaming handler using the plugin registry messenger
|
|
|
|
|
if let registrar = self.registrar(forPlugin: "BackgroundStreamingHandler") {
|
2025-08-16 20:27:44 +05:30
|
|
|
let channel = FlutterMethodChannel(
|
|
|
|
|
name: "conduit/background_streaming",
|
2025-09-18 15:01:21 +05:30
|
|
|
binaryMessenger: registrar.messenger()
|
2025-08-16 20:27:44 +05:30
|
|
|
)
|
2025-09-18 15:01:21 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
backgroundStreamingHandler = BackgroundStreamingHandler()
|
|
|
|
|
backgroundStreamingHandler?.setup(with: channel)
|
2025-09-18 15:01:21 +05:30
|
|
|
|
2025-10-11 13:53:30 +05:30
|
|
|
// Register BGTaskScheduler tasks
|
|
|
|
|
backgroundStreamingHandler?.registerBackgroundTasks()
|
|
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Register method call handler
|
|
|
|
|
channel.setMethodCallHandler { [weak self] (call, result) in
|
|
|
|
|
self?.backgroundStreamingHandler?.handle(call, result: result)
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-11 13:53:30 +05:30
|
|
|
|
2025-12-18 11:40:16 +05:30
|
|
|
// Setup cookie manager channel for WebView cookie access
|
|
|
|
|
if let registrar = self.registrar(forPlugin: "CookieManagerChannel") {
|
|
|
|
|
let cookieChannel = FlutterMethodChannel(
|
|
|
|
|
name: "com.conduit.app/cookies",
|
|
|
|
|
binaryMessenger: registrar.messenger()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
cookieChannel.setMethodCallHandler { [weak self] (call, result) in
|
|
|
|
|
if call.method == "getCookies" {
|
|
|
|
|
guard let args = call.arguments as? [String: Any],
|
|
|
|
|
let urlString = args["url"] as? String,
|
|
|
|
|
let url = URL(string: urlString) else {
|
|
|
|
|
result(FlutterError(code: "INVALID_ARGS", message: "Invalid URL", details: nil))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get cookies from WKWebView's cookie store
|
|
|
|
|
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { [weak self] cookies in
|
|
|
|
|
guard let self = self else {
|
|
|
|
|
// Always call result to avoid leaving Dart side hanging
|
|
|
|
|
result([:])
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var cookieDict: [String: String] = [:]
|
|
|
|
|
|
|
|
|
|
for cookie in cookies {
|
|
|
|
|
// Filter cookies for this domain
|
|
|
|
|
if self.cookieMatchesUrl(cookie: cookie, url: url) {
|
|
|
|
|
cookieDict[cookie.name] = cookie.value
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result(cookieDict)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
result(FlutterMethodNotImplemented)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
|
|
|
|
}
|
|
|
|
|
}
|