Files
iiEsaywebUIapp/ios/Runner/AppDelegate.swift

588 lines
19 KiB
Swift
Raw Normal View History

import AVFoundation
import BackgroundTasks
2025-08-10 01:20:45 +05:30
import Flutter
2025-11-25 00:08:51 +05:30
import AppIntents
import flutter_app_intents
2025-08-10 01:20:45 +05:30
import UIKit
2025-11-25 00:08:51 +05:30
import UniformTypeIdentifiers
2025-08-10 01:20:45 +05:30
final class VoiceBackgroundAudioManager {
static let shared = VoiceBackgroundAudioManager()
private var isActive = false
private init() {}
func activate() {
guard !isActive else { return }
let session = AVAudioSession.sharedInstance()
do {
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() {
guard isActive else { return }
let session = AVAudioSession.sharedInstance()
do {
try session.setActive(false, options: .notifyOthersOnDeactivation)
} catch {
print("VoiceBackgroundAudioManager: Failed to deactivate audio session: \(error)")
}
isActive = false
}
}
// 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()
}
}
@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 "saveStreamStates":
if let args = call.arguments as? [String: Any],
let states = args["states"] as? [[String: Any]] {
saveStreamStates(states)
result(nil)
} else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil))
}
case "recoverStreamStates":
result(recoverStreamStates())
default:
result(FlutterMethodNotImplemented)
}
}
private func startBackgroundExecution(streamIds: [String], requiresMic: Bool) {
activeStreams.formUnion(streamIds)
microphoneStreams.formIntersection(activeStreams)
if requiresMic {
microphoneStreams.formUnion(streamIds)
}
if !microphoneStreams.isEmpty {
VoiceBackgroundAudioManager.shared.activate()
}
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
self?.endBackgroundTask()
}
}
private func endBackgroundTask() {
guard backgroundTask != .invalid else { return }
UIApplication.shared.endBackgroundTask(backgroundTask)
backgroundTask = .invalid
}
private func keepAlive() {
if backgroundTask != .invalid {
endBackgroundTask()
startBackgroundTask()
}
if !microphoneStreams.isEmpty {
VoiceBackgroundAudioManager.shared.activate()
}
}
private func saveStreamStates(_ states: [[String: Any]]) {
do {
let jsonData = try JSONSerialization.data(withJSONObject: states, options: [])
UserDefaults.standard.set(jsonData, forKey: "ConduitActiveStreams")
} catch {
print("BackgroundStreamingHandler: Failed to serialize stream states: \(error)")
}
}
private func recoverStreamStates() -> [[String: Any]] {
guard let jsonData = UserDefaults.standard.data(forKey: "ConduitActiveStreams") else {
return []
}
do {
if let states = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [[String: Any]] {
return states
}
} catch {
print("BackgroundStreamingHandler: Failed to deserialize stream states: \(error)")
}
return []
}
// MARK: - BGTaskScheduler Methods
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
// Schedule for immediate execution when app backgrounds
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
print("BackgroundStreamingHandler: BGProcessingTask expiring")
self?.notifyTaskExpiring()
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
let workItem = DispatchWorkItem { [weak self] in
guard let self = self else { return }
// Keep sending keepAlive signals
let keepAliveInterval: TimeInterval = 30
var elapsedTime: TimeInterval = 0
let maxTime: TimeInterval = 180 // 3 minutes
while !self.activeStreams.isEmpty && elapsedTime < maxTime {
Thread.sleep(forTimeInterval: keepAliveInterval)
elapsedTime += keepAliveInterval
// Notify Flutter to keep streams alive
DispatchQueue.main.async {
self.channel?.invokeMethod("backgroundKeepAlive", arguments: nil)
}
}
// Mark task as complete
task.setTaskCompleted(success: true)
self.bgProcessingTask = nil
}
DispatchQueue.global(qos: .background).async(execute: workItem)
}
private func notifyTaskExpiring() {
channel?.invokeMethod("backgroundTaskExpiring", arguments: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
endBackgroundTask()
VoiceBackgroundAudioManager.shared.deactivate()
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
{
let parameters: [String: Any] = prompt?.isEmpty == false
? ["prompt": prompt ?? ""]
: [:]
let plugin = FlutterAppIntentsPlugin.shared
let result = await plugin.handleIntentInvocation(
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
{
let plugin = FlutterAppIntentsPlugin.shared
let result = await plugin.handleIntentInvocation(
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
{
let plugin = FlutterAppIntentsPlugin.shared
let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines)
let result = await plugin.handleIntentInvocation(
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
{
let plugin = FlutterAppIntentsPlugin.shared
let result = await plugin.handleIntentInvocation(
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
{
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 plugin = FlutterAppIntentsPlugin.shared
let result = await plugin.handleIntentInvocation(
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-10 01:20:45 +05:30
@main
@objc class AppDelegate: FlutterAppDelegate {
private var backgroundStreamingHandler: BackgroundStreamingHandler?
2025-08-10 01:20:45 +05:30
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
2025-09-18 15:01:21 +05:30
// Setup background streaming handler using the plugin registry messenger
if let registrar = self.registrar(forPlugin: "BackgroundStreamingHandler") {
let channel = FlutterMethodChannel(
name: "conduit/background_streaming",
2025-09-18 15:01:21 +05:30
binaryMessenger: registrar.messenger()
)
2025-09-18 15:01:21 +05:30
backgroundStreamingHandler = BackgroundStreamingHandler()
backgroundStreamingHandler?.setup(with: channel)
2025-09-18 15:01:21 +05:30
// Register BGTaskScheduler tasks
backgroundStreamingHandler?.registerBackgroundTasks()
// Register method call handler
channel.setMethodCallHandler { [weak self] (call, result) in
self?.backgroundStreamingHandler?.handle(call, result: result)
}
}
2025-08-10 01:20:45 +05:30
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}