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
|
|
|
|
|
import UIKit
|
|
|
|
|
|
2025-10-09 16:37:58 +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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-09 16:37:58 +05:30
|
|
|
private func startBackgroundExecution(streamIds: [String], requiresMic: Bool) {
|
2025-10-10 20:56:28 +05:30
|
|
|
activeStreams.formUnion(streamIds)
|
|
|
|
|
microphoneStreams.formIntersection(activeStreams)
|
2025-10-09 16:37:58 +05:30
|
|
|
if requiresMic {
|
2025-10-10 20:56:28 +05:30
|
|
|
microphoneStreams.formUnion(streamIds)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !microphoneStreams.isEmpty {
|
2025-10-09 16:37:58 +05:30
|
|
|
VoiceBackgroundAudioManager.shared.activate()
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
self?.endBackgroundTask()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func endBackgroundTask() {
|
|
|
|
|
guard backgroundTask != .invalid else { return }
|
|
|
|
|
|
|
|
|
|
UIApplication.shared.endBackgroundTask(backgroundTask)
|
|
|
|
|
backgroundTask = .invalid
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func keepAlive() {
|
|
|
|
|
if backgroundTask != .invalid {
|
|
|
|
|
endBackgroundTask()
|
|
|
|
|
startBackgroundTask()
|
|
|
|
|
}
|
2025-10-09 16:37:58 +05:30
|
|
|
|
|
|
|
|
if !microphoneStreams.isEmpty {
|
|
|
|
|
VoiceBackgroundAudioManager.shared.activate()
|
|
|
|
|
}
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func saveStreamStates(_ states: [[String: Any]]) {
|
2025-10-09 00:45:00 +05:30
|
|
|
do {
|
|
|
|
|
let jsonData = try JSONSerialization.data(withJSONObject: states, options: [])
|
|
|
|
|
UserDefaults.standard.set(jsonData, forKey: "ConduitActiveStreams")
|
|
|
|
|
} catch {
|
|
|
|
|
print("BackgroundStreamingHandler: Failed to serialize stream states: \(error)")
|
|
|
|
|
}
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
2025-10-09 00:45:00 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
private func recoverStreamStates() -> [[String: Any]] {
|
2025-10-09 00:45:00 +05:30
|
|
|
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 []
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
2025-10-11 13:53:30 +05:30
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
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-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-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-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-08-10 01:20:45 +05:30
|
|
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
|
|
|
|
}
|
|
|
|
|
}
|