Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions .github/issues/offline-llm-03-model-download-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,23 +42,24 @@ Downloading large LLM models (1-8GB) requires reliable background downloads that

### Proposed Enhancement
1. Create `ModelDownloadService` for download orchestration
2. Implement Android platform channel for DownloadManager
3. Create iOS background download using URLSession
2. Implement Android platform channel wrapping WorkManager + OkHttp for robust HTTP redirect handling
3. Create iOS background download using URLSession with throttled delegate callbacks
4. Add download progress tracking with Riverpod state
5. Implement download queue with priority support
6. Add storage management (usage tracking, cleanup)
6. Add storage management (usage tracking, cleanup, SHA-256 verification)
7. Handle download resume after network interruption

### User Value
- Reliable downloads that complete even when app is backgrounded
- Clear progress indication
- Clear progress indication without main-thread UI lag
- Ability to pause/resume downloads
- Storage awareness before downloading
- Storage awareness and SHA-256 verified files before loading

## Acceptance Criteria
- [ ] `ModelDownloadService` interface created
- [ ] Android DownloadManager platform channel implemented
- [ ] iOS URLSession background download implemented
- [ ] Android WorkManager + OkHttp background download worker implemented
- [ ] iOS URLSession background download with throttled progress implemented
- [ ] SHA-256 integrity verification implemented
- [ ] Download progress state management working
- [ ] Download queue with pause/resume functionality
- [ ] Storage usage tracking implemented
Expand All @@ -74,10 +75,10 @@ Downloading large LLM models (1-8GB) requires reliable background downloads that

## Files to Modify
```
packages/core_ai/lib/src/download/model_download_service.dart (new)
packages/core_ai/lib/src/download/download_progress.dart (new)
packages/core_ai/lib/src/download/model_download_service.dart (modify)
packages/core_ai/lib/src/download/model_download_progress.dart (modify)
packages/core_ai/lib/src/storage/model_storage_manager.dart (new)
app/android/app/src/main/kotlin/.../ModelDownloadPlugin.kt (new)
app/android/app/src/main/kotlin/io/airo/app/ModelDownloadPlugin.kt (new)
app/ios/Runner/ModelDownloadPlugin.swift (new)
app/lib/core/ai/providers/download_providers.dart (new)
```
Expand Down
4 changes: 4 additions & 0 deletions app/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.11.0")

// WorkManager and OkHttp for background model downloading
implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")

}

flutter {
Expand Down
12 changes: 11 additions & 1 deletion app/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>

<!-- Contacts permission for bill splitting -->
Expand All @@ -14,6 +15,8 @@
<!-- Audio service permissions for background music playback -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>

<application
Expand Down Expand Up @@ -78,6 +81,13 @@
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>

<!-- WorkManager Foreground Service type mapping for downloads -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false"
tools:node="merge" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
Expand Down
6 changes: 6 additions & 0 deletions app/android/app/src/main/kotlin/io/airo/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ class MainActivity : FlutterActivity() {
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, LITERT_LM_CHANNEL)
.setMethodCallHandler(LiteRtLmPlugin(this))

val downloadPlugin = ModelDownloadPlugin(this)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.airo.model_download")
.setMethodCallHandler(downloadPlugin)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, "com.airo.model_download/progress")
.setStreamHandler(downloadPlugin.progressStreamHandler)

MethodChannel(flutterEngine.dartExecutor.binaryMessenger, AGENT_CONNECTORS_CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
Expand Down
247 changes: 247 additions & 0 deletions app/android/app/src/main/kotlin/io/airo/app/ModelDownloadPlugin.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
package io.airo.app

import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.StatFs
import androidx.core.app.NotificationCompat
import androidx.work.*
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException

class ModelDownloadPlugin(private val context: Context) : MethodChannel.MethodCallHandler {

companion object {
private const val TAG = "ModelDownloadPlugin"
private var progressSink: EventChannel.EventSink? = null

fun updateProgress(
modelId: String,
status: String,
downloaded: Long,
total: Long,
speed: Double,
error: String? = null
) {
val progressMap = mapOf(
"modelId" to modelId,
"status" to status,
"downloadedBytes" to downloaded,
"totalBytes" to total,
"speedBytesPerSecond" to speed,
"error" to error
)
Handler(Looper.getMainLooper()).post {
progressSink?.success(progressMap)
}
}
}

val progressStreamHandler = object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
progressSink = events
}

override fun onCancel(arguments: Any?) {
progressSink = null
}
}

override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"startDownload" -> startDownload(call, result)
"cancelDownload" -> cancelDownload(call, result)
"getFreeDiskSpace" -> getFreeDiskSpace(result)
else -> result.notImplemented()
}
}

private fun startDownload(call: MethodCall, result: MethodChannel.Result) {
val modelId = call.argument<String>("modelId")
val url = call.argument<String>("url")
val filePath = call.argument<String>("filePath")

if (modelId == null || url == null || filePath == null) {
result.error("INVALID_ARGUMENTS", "modelId, url, and filePath are required", null)
return
}

val data = workDataOf(
"modelId" to modelId,
"url" to url,
"filePath" to filePath
)

val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()

val downloadWork = OneTimeWorkRequestBuilder<ModelDownloadWorker>()
.setInputData(data)
.setConstraints(constraints)
.addTag(modelId)
.build()

WorkManager.getInstance(context).enqueueUniqueWork(
modelId,
ExistingWorkPolicy.REPLACE,
downloadWork
)

result.success(true)
}

private fun cancelDownload(call: MethodCall, result: MethodChannel.Result) {
val modelId = call.argument<String>("modelId")
if (modelId == null) {
result.error("INVALID_ARGUMENTS", "modelId is required", null)
return
}

WorkManager.getInstance(context).cancelUniqueWork(modelId)
result.success(true)
}

private fun getFreeDiskSpace(result: MethodChannel.Result) {
try {
val stat = StatFs(context.filesDir.path)
val bytesAvailable = stat.blockSizeLong * stat.availableBlocksLong
result.success(bytesAvailable)
} catch (e: Exception) {
result.error("DISK_SPACE_ERROR", e.message, null)
}
}
}

class ModelDownloadWorker(
private val context: Context,
parameters: WorkerParameters
) : CoroutineWorker(context, parameters) {

override suspend fun doWork(): Result {
val modelId = inputData.getString("modelId") ?: return Result.failure()
val url = inputData.getString("url") ?: return Result.failure()
val filePath = inputData.getString("filePath") ?: return Result.failure()

val notificationId = modelId.hashCode()
setForeground(createForegroundInfo(modelId, "Starting download...", 0, 0))

val tempFile = File("$filePath.tmp")
try {
tempFile.parentFile?.mkdirs()

val client = OkHttpClient.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()

val request = Request.Builder()
.url(url)
.build()

var lastProgressUpdate = 0L
var lastBytes = 0L
var lastTime = System.currentTimeMillis()
var currentSpeed = 0.0

client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected HTTP code $response")
val body = response.body ?: throw IOException("Empty response body")
val totalBytes = body.contentLength().takeIf { it > 0 } ?: 0L

body.byteStream().use { input ->
tempFile.outputStream().use { output ->
val buffer = ByteArray(64 * 1024)
var bytesRead: Int
var downloadedBytes = 0L

while (input.read(buffer).also { bytesRead = it } != -1) {
if (isStopped) {
ModelDownloadPlugin.updateProgress(modelId, "cancelled", downloadedBytes, totalBytes, 0.0)
tempFile.delete()
return Result.failure()
}

output.write(buffer, 0, bytesRead)
downloadedBytes += bytesRead

val now = System.currentTimeMillis()
val elapsed = now - lastTime

if (now - lastProgressUpdate > 500) {
if (elapsed >= 500) {
val bytesPerMs = (downloadedBytes - lastBytes).toDouble() / elapsed
currentSpeed = bytesPerMs * 1000.0
lastBytes = downloadedBytes
lastTime = now
}

val percent = if (totalBytes > 0) (downloadedBytes * 100 / totalBytes).toInt() else 0
setForeground(createForegroundInfo(modelId, "Downloading: $percent%", downloadedBytes, totalBytes))

ModelDownloadPlugin.updateProgress(modelId, "downloading", downloadedBytes, totalBytes, currentSpeed)
lastProgressUpdate = now
}
}
}
}

val destFile = File(filePath)
if (destFile.exists()) destFile.delete()
if (!tempFile.renameTo(destFile)) {
throw IOException("Failed to rename temp file to destination")
}

ModelDownloadPlugin.updateProgress(modelId, "completed", totalBytes, totalBytes, 0.0)
return Result.success()
}
} catch (e: Exception) {
tempFile.delete()
ModelDownloadPlugin.updateProgress(modelId, "failed", 0, 0, 0.0, e.message)
return Result.failure()
}
}

private fun createForegroundInfo(
modelId: String,
contentText: String,
downloaded: Long,
total: Long
): ForegroundInfo {
val channelId = "model_download_channel"
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(channelId, "Model Downloads", NotificationManager.IMPORTANCE_LOW)
notificationManager.createNotificationChannel(channel)
}

val max = if (total > 0) 100 else 0
val progress = if (total > 0) (downloaded * 100 / total).toInt() else 0

val notification = NotificationCompat.Builder(context, channelId)
.setContentTitle("Downloading AI Model ($modelId)")
.setContentText(contentText)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
.setProgress(max, progress, total <= 0)
.build()

val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else {
0
}

return ForegroundInfo(modelId.hashCode(), notification, serviceType)
}
}
1 change: 1 addition & 0 deletions app/ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import UIKit
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
ModelDownloadPlugin.register(with: self.registrar(forPlugin: "ModelDownloadPlugin")!)

if let controller = window?.rootViewController as? FlutterViewController {
let channel = FlutterMethodChannel(
Expand Down
Loading
Loading