feat: background streaming of responses

This commit is contained in:
cogwheel0
2025-08-16 20:27:44 +05:30
parent 33fc26d755
commit 9be04ef2b9
23 changed files with 2676 additions and 322 deletions

View File

@@ -6,6 +6,12 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
<!-- Background streaming permissions -->
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="Conduit"
android:name="${applicationName}"
@@ -33,6 +39,14 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Background streaming service -->
<service
android:name=".BackgroundStreamingService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync"/>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View File

@@ -0,0 +1,392 @@
package app.cogwheel.conduit
import android.app.*
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import kotlinx.coroutines.*
import org.json.JSONArray
import org.json.JSONObject
class BackgroundStreamingService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
private val activeStreams = mutableSetOf<String>()
companion object {
const val CHANNEL_ID = "conduit_streaming_channel"
const val NOTIFICATION_ID = 1001
const val ACTION_START = "START_STREAMING"
const val ACTION_STOP = "STOP_STREAMING"
}
override fun onCreate() {
super.onCreate()
println("BackgroundStreamingService: Service created")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
val streamCount = intent.getIntExtra("streamCount", 1)
acquireWakeLock()
startForegroundWithNotification(streamCount)
println("BackgroundStreamingService: Started foreground service for $streamCount streams")
}
ACTION_STOP -> {
stopStreaming()
}
"KEEP_ALIVE" -> {
val streamCount = intent.getIntExtra("streamCount", 1)
keepAlive()
updateNotification(streamCount)
}
}
return START_STICKY // Restart if killed by system
}
private fun startForegroundWithNotification(streamCount: Int) {
val notification = createNotification(streamCount)
startForeground(NOTIFICATION_ID, notification)
}
private fun createNotification(streamCount: Int): Notification {
val title = if (streamCount == 1) {
"Chat streaming in progress"
} else {
"$streamCount chats streaming"
}
// Create intent to return to app
val intent = packageManager.getLaunchIntentForPackage(packageName)
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText("Processing chat responses...")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setShowWhen(false)
.setAutoCancel(false)
.build()
}
private fun updateNotification(streamCount: Int) {
val notification = createNotification(streamCount)
val notificationManager = NotificationManagerCompat.from(this)
try {
notificationManager.notify(NOTIFICATION_ID, notification)
} catch (e: SecurityException) {
println("BackgroundStreamingService: Notification permission not granted")
}
}
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"Conduit::StreamingWakeLock"
).apply {
acquire(15 * 60 * 1000L) // 15 minutes max
}
println("BackgroundStreamingService: Wake lock acquired")
}
private fun releaseWakeLock() {
wakeLock?.let {
if (it.isHeld) {
it.release()
println("BackgroundStreamingService: Wake lock released")
}
}
wakeLock = null
}
private fun keepAlive() {
// Refresh wake lock to extend background processing time
releaseWakeLock()
acquireWakeLock()
println("BackgroundStreamingService: Keep alive - wake lock refreshed")
}
private fun stopStreaming() {
activeStreams.clear()
releaseWakeLock()
stopForeground(true)
stopSelf()
println("BackgroundStreamingService: Service stopped")
}
override fun onDestroy() {
releaseWakeLock()
super.onDestroy()
println("BackgroundStreamingService: Service destroyed")
}
override fun onBind(intent: Intent?): IBinder? = null
}
class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCallHandler {
private lateinit var channel: MethodChannel
private lateinit var context: Context
private lateinit var sharedPrefs: SharedPreferences
private val activeStreams = mutableSetOf<String>()
private var backgroundJob: Job? = null
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
companion object {
private const val CHANNEL_NAME = "conduit/background_streaming"
private const val PREFS_NAME = "conduit_stream_states"
private const val STREAM_STATES_KEY = "active_streams"
}
fun setup(flutterEngine: FlutterEngine) {
channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL_NAME)
channel.setMethodCallHandler(this)
context = activity.applicationContext
sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
createNotificationChannel()
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"startBackgroundExecution" -> {
val streamIds = call.argument<List<String>>("streamIds")
if (streamIds != null) {
startBackgroundExecution(streamIds)
result.success(null)
} else {
result.error("INVALID_ARGS", "Stream IDs required", null)
}
}
"stopBackgroundExecution" -> {
val streamIds = call.argument<List<String>>("streamIds")
if (streamIds != null) {
stopBackgroundExecution(streamIds)
result.success(null)
} else {
result.error("INVALID_ARGS", "Stream IDs required", null)
}
}
"keepAlive" -> {
keepAlive()
result.success(null)
}
"saveStreamStates" -> {
val states = call.argument<List<Map<String, Any>>>("states")
val reason = call.argument<String>("reason")
if (states != null) {
saveStreamStates(states, reason ?: "unknown")
result.success(null)
} else {
result.error("INVALID_ARGS", "States required", null)
}
}
"recoverStreamStates" -> {
result.success(recoverStreamStates())
}
else -> {
result.notImplemented()
}
}
}
private fun startBackgroundExecution(streamIds: List<String>) {
activeStreams.addAll(streamIds)
if (activeStreams.isNotEmpty()) {
startForegroundService()
startBackgroundMonitoring()
}
}
private fun stopBackgroundExecution(streamIds: List<String>) {
activeStreams.removeAll(streamIds.toSet())
if (activeStreams.isEmpty()) {
stopForegroundService()
stopBackgroundMonitoring()
}
}
private fun startForegroundService() {
val serviceIntent = Intent(context, BackgroundStreamingService::class.java)
serviceIntent.putExtra("streamCount", activeStreams.size)
serviceIntent.action = BackgroundStreamingService.ACTION_START
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(serviceIntent)
} else {
context.startService(serviceIntent)
}
}
private fun stopForegroundService() {
val serviceIntent = Intent(context, BackgroundStreamingService::class.java)
serviceIntent.action = BackgroundStreamingService.ACTION_STOP
context.startService(serviceIntent)
}
private fun startBackgroundMonitoring() {
backgroundJob?.cancel()
backgroundJob = scope.launch {
while (activeStreams.isNotEmpty()) {
delay(30000) // Check every 30 seconds
// Notify Dart side to check stream health
channel.invokeMethod("checkStreams", null, object : MethodChannel.Result {
override fun success(result: Any?) {
when (result) {
is Int -> {
if (result == 0) {
activeStreams.clear()
stopForegroundService()
}
}
}
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
println("BackgroundStreamingHandler: Error checking streams: $errorMessage")
}
override fun notImplemented() {
println("BackgroundStreamingHandler: checkStreams method not implemented")
}
})
}
}
}
private fun stopBackgroundMonitoring() {
backgroundJob?.cancel()
backgroundJob = null
}
private fun keepAlive() {
// Just notify the service to refresh
val serviceIntent = Intent(context, BackgroundStreamingService::class.java)
serviceIntent.action = "KEEP_ALIVE"
serviceIntent.putExtra("streamCount", activeStreams.size)
context.startService(serviceIntent)
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = "Conduit Streaming"
val descriptionText = "Keeps chat streams active in background"
val importance = NotificationManager.IMPORTANCE_LOW
val channel = NotificationChannel(BackgroundStreamingService.CHANNEL_ID, name, importance).apply {
description = descriptionText
setShowBadge(false)
enableLights(false)
enableVibration(false)
setSound(null, null)
}
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
private fun saveStreamStates(states: List<Map<String, Any>>, reason: String) {
try {
val jsonArray = JSONArray()
for (state in states) {
val jsonObject = JSONObject()
for ((key, value) in state) {
jsonObject.put(key, value)
}
jsonArray.put(jsonObject)
}
sharedPrefs.edit()
.putString(STREAM_STATES_KEY, jsonArray.toString())
.putLong("saved_timestamp", System.currentTimeMillis())
.putString("saved_reason", reason)
.apply()
println("BackgroundStreamingHandler: Saved ${states.size} stream states (reason: $reason)")
} catch (e: Exception) {
println("BackgroundStreamingHandler: Failed to save stream states: ${e.message}")
}
}
private fun recoverStreamStates(): List<Map<String, Any>> {
return try {
val savedStates = sharedPrefs.getString(STREAM_STATES_KEY, null) ?: return emptyList()
val timestamp = sharedPrefs.getLong("saved_timestamp", 0)
val reason = sharedPrefs.getString("saved_reason", "unknown")
// Check if states are not too old (max 1 hour)
val age = System.currentTimeMillis() - timestamp
if (age > 3600000) { // 1 hour in milliseconds
println("BackgroundStreamingHandler: Stream states too old (${age / 1000}s), discarding")
sharedPrefs.edit().remove(STREAM_STATES_KEY).apply()
return emptyList()
}
val jsonArray = JSONArray(savedStates)
val result = mutableListOf<Map<String, Any>>()
for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(i)
val map = mutableMapOf<String, Any>()
val keys = jsonObject.keys()
while (keys.hasNext()) {
val key = keys.next()
val value = jsonObject.get(key)
map[key] = value
}
result.add(map)
}
println("BackgroundStreamingHandler: Recovered ${result.size} stream states (reason: $reason, age: ${age / 1000}s)")
// Clear saved states after recovery
sharedPrefs.edit().remove(STREAM_STATES_KEY).apply()
result
} catch (e: Exception) {
println("BackgroundStreamingHandler: Failed to recover stream states: ${e.message}")
emptyList()
}
}
fun cleanup() {
scope.cancel()
stopBackgroundMonitoring()
stopForegroundService()
}
}

View File

@@ -1,6 +1,23 @@
package app.cogwheel.conduit
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
private lateinit var backgroundStreamingHandler: BackgroundStreamingHandler
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// Initialize background streaming handler
backgroundStreamingHandler = BackgroundStreamingHandler(this)
backgroundStreamingHandler.setup(flutterEngine)
}
override fun onDestroy() {
super.onDestroy()
if (::backgroundStreamingHandler.isInitialized) {
backgroundStreamingHandler.cleanup()
}
}
}