@@ -58,9 +58,16 @@ object ModelDownloadManager {
5858 private var downloadJob: Job ? = null
5959 private var isPaused = false
6060
61+ private data class DownloadTarget (
62+ val finalFile : File ,
63+ val tempFile : File ,
64+ val url : String ,
65+ val label : String
66+ )
67+
6168 fun isModelDownloaded (context : Context , model : ModelOption = GenerativeAiViewModelFactory .getCurrentModel()): Boolean {
62- val file = getModelFile (context, model)
63- return file != null && file. exists() && file .length() > 0
69+ val required = getRequiredFiles (context, model)
70+ return required.isNotEmpty() && required.all { it. exists() && it .length() > 0 }
6471 }
6572
6673 fun getModelFile (context : Context , model : ModelOption = GenerativeAiViewModelFactory .getCurrentModel()): File ? {
@@ -74,13 +81,26 @@ object ModelDownloadManager {
7481 }
7582 }
7683
77- private fun getTempFile (context : Context , model : ModelOption ): File ? {
78- val modelFilename = model.offlineModelFilename ? : return null
79- val externalFilesDir = context.getExternalFilesDir(null )
80- return if (externalFilesDir != null ) {
81- File (externalFilesDir, modelFilename + TEMP_SUFFIX )
84+ private fun getRequiredFiles (context : Context , model : ModelOption ): List <File > {
85+ val externalFilesDir = context.getExternalFilesDir(null ) ? : return emptyList()
86+ val requiredNames = if (model.offlineRequiredFilenames.isNotEmpty()) {
87+ model.offlineRequiredFilenames
8288 } else {
83- null
89+ listOfNotNull(model.offlineModelFilename)
90+ }
91+ return requiredNames.map { File (externalFilesDir, it) }
92+ }
93+
94+ fun getMissingRequiredFiles (context : Context , model : ModelOption ): List <String > {
95+ val externalFilesDir = context.getExternalFilesDir(null ) ? : return model.offlineRequiredFilenames
96+ val requiredNames = if (model.offlineRequiredFilenames.isNotEmpty()) {
97+ model.offlineRequiredFilenames
98+ } else {
99+ listOfNotNull(model.offlineModelFilename)
100+ }
101+ return requiredNames.filter { name ->
102+ val f = File (externalFilesDir, name)
103+ ! f.exists() || f.length() <= 0
84104 }
85105 }
86106
@@ -147,7 +167,7 @@ object ModelDownloadManager {
147167
148168 isPaused = false
149169 downloadJob = CoroutineScope (Dispatchers .IO ).launch {
150- downloadWithResume (context, model, url)
170+ downloadModelPackage (context, model, url)
151171 }
152172 }
153173
@@ -164,7 +184,7 @@ object ModelDownloadManager {
164184
165185 isPaused = false
166186 downloadJob = CoroutineScope (Dispatchers .IO ).launch {
167- downloadWithResume (context, model, url)
187+ downloadModelPackage (context, model, url)
168188 }
169189 }
170190
@@ -174,11 +194,16 @@ object ModelDownloadManager {
174194 downloadJob?.cancel()
175195 downloadJob = null
176196
177- // Delete temp file
178- val tempFile = getTempFile(context, model)
179- if (tempFile != null && tempFile.exists()) {
180- tempFile.delete()
181- Log .d(TAG , " Temp file deleted." )
197+ // Delete temp files for full package
198+ val externalFilesDir = context.getExternalFilesDir(null )
199+ if (externalFilesDir != null ) {
200+ val targets = buildDownloadTargets(context, model, model.downloadUrl ? : " " )
201+ targets.forEach { target ->
202+ if (target.tempFile.exists()) {
203+ target.tempFile.delete()
204+ }
205+ }
206+ Log .d(TAG , " Temporary package files deleted." )
182207 }
183208
184209 _downloadState .value = DownloadState .Idle
@@ -188,21 +213,79 @@ object ModelDownloadManager {
188213 }
189214 }
190215
191- private suspend fun downloadWithResume (context : Context , model : ModelOption , url : String ) {
192- val tempFile = getTempFile(context, model) ? : run {
216+ private suspend fun downloadModelPackage (context : Context , model : ModelOption , primaryUrl : String ) {
217+ val targets = buildDownloadTargets(context, model, primaryUrl)
218+ if (targets.isEmpty()) {
193219 _downloadState .value = DownloadState .Error (" Storage not available." )
194220 return
195221 }
196- val finalFile = getModelFile(context, model) ? : run {
197- _downloadState .value = DownloadState .Error (" Storage not available." )
198- return
222+
223+ for ((index, target) in targets.withIndex()) {
224+ if (! coroutineContext.isActive) return
225+ Log .i(TAG , " Downloading package file ${index + 1 } /${targets.size} : ${target.label} " )
226+ val error = downloadSingleFileWithResume(context, target, index, targets.size)
227+ if (error != null ) {
228+ _downloadState .value = DownloadState .Error (error)
229+ cancelDownloadNotification(context)
230+ return
231+ }
232+ }
233+
234+ _downloadState .value = DownloadState .Completed
235+ showDownloadCompleteNotification(context)
236+ withContext(Dispatchers .Main ) {
237+ Toast .makeText(context, " Model download complete!" , Toast .LENGTH_SHORT ).show()
238+ }
239+ }
240+
241+ private fun buildDownloadTargets (context : Context , model : ModelOption , primaryUrl : String ): List <DownloadTarget > {
242+ val externalFilesDir = context.getExternalFilesDir(null ) ? : return emptyList()
243+ val primaryFilename = model.offlineModelFilename ? : return emptyList()
244+ val urls = listOf (primaryUrl) + model.additionalDownloadUrls
245+ val filenames = urls.mapIndexedNotNull { idx, url ->
246+ if (idx == 0 ) primaryFilename else filenameFromUrl(url)
247+ }
248+ if (urls.size != filenames.size) {
249+ Log .e(TAG , " Could not resolve filename for at least one download URL." )
250+ return emptyList()
251+ }
252+ return urls.zip(filenames).map { (url, filename) ->
253+ val finalFile = File (externalFilesDir, filename)
254+ DownloadTarget (
255+ finalFile = finalFile,
256+ tempFile = File (externalFilesDir, " $filename$TEMP_SUFFIX " ),
257+ url = url,
258+ label = filename
259+ )
260+ }
261+ }
262+
263+ private fun filenameFromUrl (url : String ): String? {
264+ val clean = url.substringBefore(' ?' )
265+ val slash = clean.lastIndexOf(' /' )
266+ return if (slash >= 0 && slash + 1 < clean.length) clean.substring(slash + 1 ) else null
267+ }
268+
269+ private suspend fun downloadSingleFileWithResume (
270+ context : Context ,
271+ target : DownloadTarget ,
272+ fileIndex : Int ,
273+ fileCount : Int
274+ ): String? {
275+ val tempFile = target.tempFile
276+ val finalFile = target.finalFile
277+ val url = target.url
278+
279+ if (finalFile.exists() && finalFile.length() > 0L ) {
280+ Log .d(TAG , " Skipping already downloaded file: ${target.label} " )
281+ return null
199282 }
200283
201284 var retryCount = 0
202285 var bytesDownloaded = if (tempFile.exists()) tempFile.length() else 0L
203286
204287 while (retryCount <= MAX_RETRIES ) {
205- if (! coroutineContext.isActive) return // Coroutine was cancelled
288+ if (! coroutineContext.isActive) return null // Coroutine was cancelled
206289
207290 var connection: HttpURLConnection ? = null
208291 try {
@@ -240,9 +323,7 @@ object ModelDownloadManager {
240323 }
241324 }
242325 else -> {
243- _downloadState .value = DownloadState .Error (" Server error: $responseCode " )
244- cancelDownloadNotification(context)
245- return
326+ return " Server error for ${target.label} : $responseCode "
246327 }
247328 }
248329
@@ -264,7 +345,7 @@ object ModelDownloadManager {
264345 if (! coroutineContext.isActive) {
265346 Log .d(TAG , " Download cancelled during read." )
266347 cancelDownloadNotification(context)
267- return
348+ return null
268349 }
269350
270351 if (isPaused) {
@@ -275,7 +356,7 @@ object ModelDownloadManager {
275356 )
276357 // Keep notification showing paused state
277358 showDownloadNotification(context, bytesDownloaded.toFloat() / totalBytes, bytesDownloaded, totalBytes)
278- return
359+ return null
279360 }
280361
281362 output.write(buffer, 0 , bytesRead)
@@ -286,13 +367,14 @@ object ModelDownloadManager {
286367 if (now - lastProgressUpdate >= PROGRESS_UPDATE_INTERVAL_MS ) {
287368 lastProgressUpdate = now
288369 val progress = if (totalBytes > 0 ) bytesDownloaded.toFloat() / totalBytes else 0f
370+ val aggregateProgress = (fileIndex + progress) / fileCount.toFloat()
289371 _downloadState .value = DownloadState .Downloading (
290- progress = progress ,
372+ progress = aggregateProgress ,
291373 bytesDownloaded = bytesDownloaded,
292374 totalBytes = totalBytes
293375 )
294376 // Point 18: Update notification with progress
295- showDownloadNotification(context, progress , bytesDownloaded, totalBytes)
377+ showDownloadNotification(context, aggregateProgress , bytesDownloaded, totalBytes)
296378 }
297379 }
298380 }
@@ -303,30 +385,20 @@ object ModelDownloadManager {
303385 finalFile.delete()
304386 if (tempFile.renameTo(finalFile)) {
305387 Log .i(TAG , " Download complete! File: ${finalFile.absolutePath} (${finalFile.length()} bytes)" )
306- _downloadState .value = DownloadState .Completed
307- showDownloadCompleteNotification(context)
308- withContext(Dispatchers .Main ) {
309- Toast .makeText(context, " Model download complete!" , Toast .LENGTH_SHORT ).show()
310- }
311388 } else {
312- _downloadState .value = DownloadState .Error (" Failed to save model file." )
313- cancelDownloadNotification(context)
389+ return " Failed to save ${target.label} ."
314390 }
315391 }
316- return // Success, exit retry loop
392+ return null // Success, exit retry loop
317393
318394 } catch (e: IOException ) {
319395 Log .e(TAG , " Download error (attempt ${retryCount + 1 } ): ${e.message} " )
320396 retryCount++
321397 if (retryCount > MAX_RETRIES ) {
322- _downloadState .value = DownloadState .Error (" Download failed after $MAX_RETRIES retries: ${e.message} " )
323- cancelDownloadNotification(context)
324- withContext(Dispatchers .Main ) {
325- Toast .makeText(context, " Download failed: ${e.message} " , Toast .LENGTH_LONG ).show()
326- }
398+ return " Download failed for ${target.label} after $MAX_RETRIES retries: ${e.message} "
327399 } else {
328400 _downloadState .value = DownloadState .Downloading (
329- progress = if (bytesDownloaded > 0 ) 0f else 0f ,
401+ progress = fileIndex.toFloat() / fileCount.toFloat() ,
330402 bytesDownloaded = bytesDownloaded,
331403 totalBytes = - 1
332404 )
@@ -337,6 +409,8 @@ object ModelDownloadManager {
337409 connection?.disconnect()
338410 }
339411 }
412+
413+ return " Download failed for ${target.label} ."
340414 }
341415
342416 /* *
0 commit comments