Files
iiEsaywebUIapp/ios/Runner/AppDelegate.swift
cogwheel 6a07855c9b fix(background): Improve background streaming reliability and error handling
feat(background): Add initialization and error handling for background streaming
feat(background): Improve background streaming reliability and error handling
feat(background): Improve iOS background task and stream management
refactor(android): Remove unused stream state persistence logic
feat(android): Improve wake lock and broadcast receiver handling
2025-12-20 22:10:28 +05:30

830 lines
29 KiB
Swift

import AVFoundation
import BackgroundTasks
import Flutter
import AppIntents
import UIKit
import UniformTypeIdentifiers
import WebKit
/// 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.
final class VoiceBackgroundAudioManager {
static let shared = VoiceBackgroundAudioManager()
private var isActive = false
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
private init() {}
/// 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
}
func activate() {
lock.lock()
defer { lock.unlock() }
guard !isActive else { return }
// Skip if another component is managing the audio session
if externalSessionOwner {
print("VoiceBackgroundAudioManager: Skipping activation - external session owner active")
return
}
let session = AVAudioSession.sharedInstance()
do {
// 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,
]
)
}
try session.setActive(true, options: .notifyOthersOnDeactivation)
isActive = true
} catch {
print("VoiceBackgroundAudioManager: Failed to activate audio session: \(error)")
}
}
func deactivate() {
lock.lock()
defer { lock.unlock() }
guard isActive else { return }
// Don't deactivate if external owner - they manage their own lifecycle
if externalSessionOwner {
print("VoiceBackgroundAudioManager: Skipping deactivation - external session owner active")
isActive = false
return
}
let session = AVAudioSession.sharedInstance()
do {
try session.setActive(false, options: .notifyOthersOnDeactivation)
} catch {
print("VoiceBackgroundAudioManager: Failed to deactivate audio session: \(error)")
}
isActive = false
}
/// Check if audio session is currently active (thread-safe).
var isSessionActive: Bool {
lock.lock()
defer { lock.unlock() }
return isActive
}
}
// Background streaming handler class
class BackgroundStreamingHandler: NSObject {
private var backgroundTask: UIBackgroundTaskIdentifier = .invalid
private var bgProcessingTask: BGTask?
private var activeStreams: Set<String> = []
private var microphoneStreams: Set<String> = []
private var channel: FlutterMethodChannel?
static let processingTaskIdentifier = "app.cogwheel.conduit.refresh"
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()
scheduleBGProcessingTask()
}
}
@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] {
let requiresMic = args["requiresMicrophone"] as? Bool ?? false
startBackgroundExecution(streamIds: streamIds, requiresMic: requiresMic)
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)
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
if let args = call.arguments as? [String: Any],
let isExternal = args["isExternal"] as? Bool {
VoiceBackgroundAudioManager.shared.setExternalSessionOwner(isExternal)
result(nil)
} else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing isExternal argument", details: nil))
}
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)
default:
result(FlutterMethodNotImplemented)
}
}
private func startBackgroundExecution(streamIds: [String], requiresMic: Bool) {
// Add new stream IDs to active set
activeStreams.formUnion(streamIds)
// Clean up any mic streams that are no longer active (e.g., completed streams)
// This ensures microphoneStreams stays in sync with activeStreams
microphoneStreams.formIntersection(activeStreams)
// If these new streams require microphone, add them to the mic set
if requiresMic {
microphoneStreams.formUnion(streamIds)
}
// Activate audio session for microphone access in background
if !microphoneStreams.isEmpty {
VoiceBackgroundAudioManager.shared.activate()
}
// Start background tasks if app is already backgrounded
if UIApplication.shared.applicationState == .background {
startBackgroundTask()
scheduleBGProcessingTask()
}
}
private func stopBackgroundExecution(streamIds: [String]) {
streamIds.forEach { activeStreams.remove($0) }
streamIds.forEach { microphoneStreams.remove($0) }
if activeStreams.isEmpty {
endBackgroundTask()
cancelBGProcessingTask()
}
if microphoneStreams.isEmpty {
VoiceBackgroundAudioManager.shared.deactivate()
}
}
private func startBackgroundTask() {
guard backgroundTask == .invalid else { return }
backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "ConduitStreaming") { [weak self] in
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()
}
}
private func endBackgroundTask() {
guard backgroundTask != .invalid else { return }
UIApplication.shared.endBackgroundTask(backgroundTask)
backgroundTask = .invalid
}
private func keepAlive() {
// Use atomic task refresh: start new task before ending old one
// This prevents the brief window where iOS could suspend the app
if backgroundTask != .invalid {
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
startBackgroundTask()
}
// Keep audio session active for microphone streams
if !microphoneStreams.isEmpty {
VoiceBackgroundAudioManager.shared.activate()
}
}
private func notifyStreamsSuspending(reason: String) {
guard !activeStreams.isEmpty else { return }
channel?.invokeMethod("streamsSuspending", arguments: [
"streamIds": Array(activeStreams),
"reason": reason
])
}
// MARK: - BGTaskScheduler Methods
//
// 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.
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
// 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.
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
guard let self = self else { return }
print("BackgroundStreamingHandler: BGProcessingTask expiring")
// Notify Flutter about streams being suspended
self.notifyStreamsSuspending(reason: "bg_processing_task_expiring")
self.channel?.invokeMethod("backgroundTaskExpiring", arguments: nil)
self.bgProcessingTask = nil
}
// Notify Flutter that we have extended background time
channel?.invokeMethod("backgroundTaskExtended", arguments: [
"streamIds": Array(activeStreams),
"estimatedTime": 180 // ~3 minutes typical for BGProcessingTask
])
// Keep task alive while streams are active using async Task
Task { [weak self] in
guard let self = self else {
task.setTaskCompleted(success: false)
return
}
let keepAliveInterval: UInt64 = 30_000_000_000 // 30 seconds in nanoseconds
var elapsedTime: TimeInterval = 0
let maxTime: TimeInterval = 180 // 3 minutes
while !self.activeStreams.isEmpty && elapsedTime < maxTime {
try? await Task.sleep(nanoseconds: keepAliveInterval)
elapsedTime += 30
// Notify Flutter to keep streams alive
await MainActor.run {
self.channel?.invokeMethod("backgroundKeepAlive", arguments: nil)
}
}
// Mark task as complete
task.setTaskCompleted(success: true)
self.bgProcessingTask = nil
}
}
deinit {
NotificationCenter.default.removeObserver(self)
endBackgroundTask()
VoiceBackgroundAudioManager.shared.deactivate()
}
}
/// 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"
])
}
}
}
}
}
}
@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
{
guard let channel = AppIntentMethodChannel.shared else {
throw AppIntentError.executionFailed("App not ready")
}
let parameters: [String: Any] = prompt?.isEmpty == false
? ["prompt": prompt ?? ""]
: [:]
let result = await channel.invokeIntent(
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
{
guard let channel = AppIntentMethodChannel.shared else {
throw AppIntentError.executionFailed("App not ready")
}
let result = await channel.invokeIntent(
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
{
guard let channel = AppIntentMethodChannel.shared else {
throw AppIntentError.executionFailed("App not ready")
}
let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines)
let result = await channel.invokeIntent(
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
{
guard let channel = AppIntentMethodChannel.shared else {
throw AppIntentError.executionFailed("App not ready")
}
let result = await channel.invokeIntent(
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
{
guard let channel = AppIntentMethodChannel.shared else {
throw AppIntentError.executionFailed("App not ready")
}
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"
let result = await channel.invokeIntent(
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)",
]
),
]
}
}
@main
@objc class AppDelegate: FlutterAppDelegate {
private var backgroundStreamingHandler: BackgroundStreamingHandler?
/// 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)")
}
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
// Setup App Intents method channel for native -> Flutter communication
if let registrar = self.registrar(forPlugin: "AppIntentMethodChannel") {
AppIntentMethodChannel.shared = AppIntentMethodChannel(
messenger: registrar.messenger()
)
}
// Setup background streaming handler using the plugin registry messenger
if let registrar = self.registrar(forPlugin: "BackgroundStreamingHandler") {
let channel = FlutterMethodChannel(
name: "conduit/background_streaming",
binaryMessenger: registrar.messenger()
)
backgroundStreamingHandler = BackgroundStreamingHandler()
backgroundStreamingHandler?.setup(with: channel)
// Register BGTaskScheduler tasks
backgroundStreamingHandler?.registerBackgroundTasks()
// Register method call handler
channel.setMethodCallHandler { [weak self] (call, result) in
self?.backgroundStreamingHandler?.handle(call, result: result)
}
}
// 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)
}
}
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}