Description
Failed or interrupted network model installations can leave multi-gigabyte
background_downloader temporary files in Android's persistent filesDir.
Repeated retries or application restarts may create additional partial files.
The application can therefore consume several gigabytes even though
FlutterGemma.listInstalledModels() reports no installed model.
I reproduced this with:
- flutter_gemma: 1.2.3
- background_downloader: 9.5.5
- Android, profile build
- A ~2.4 GB
.litertlm model
- A custom server supporting
Range, Accept-Ranges: bytes, and a stable strong ETag
The relevant implementation defects are also still present on current main
/ release 1.3.0.
Example
final cancelToken = CancelToken();
await FlutterGemma.installModel(
modelType: ModelType.gemmaIt,
)
.fromNetwork(
url,
token: bearerToken,
foreground: true,
)
.withCancelToken(cancelToken)
.withProgress((progress) {
// Display progress.
})
.install();
Steps to reproduce
-
Start downloading a multi-gigabyte model on Android.
-
Let the download reach substantial progress, for example 50–60%.
-
Interrupt the network, terminate the process, or trigger a resume/watchdog
failure.
-
Restart the application and retry the installation several times.
-
Inspect application storage:
adb shell run-as sh -c
'ls -lh files/com.bbflight.background_downloader*'
-
Call:
await FlutterGemma.listInstalledModels();
await FlutterGemma.getOrphanedFiles();
await FlutterGemma.cleanupStorage();
Actual result
-
Multiple files named similarly to
com.bbflight.background_downloader remain under Android filesDir.
-
These files can each contain a large portion of the model.
-
Application data grows by several gigabytes.
-
No model is reported as installed.
-
getOrphanedFiles() does not report these files.
-
cleanupStorage() does not delete them.
Expected result
-
A logical model download should have at most one resumable partial file.
-
Retrying after a process restart should reuse that partial or remove it before
starting a replacement.
-
Explicit cancellation should delete the partial file and resume metadata.
-
getOrphanedFiles() should report abandoned downloader fragments.
-
cleanupStorage() should remove abandoned downloader fragments and their
metadata.
Source-level findings
1. Downloader group mismatch
SmartDownloader creates tasks in smart_downloads:
https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/mobile/smart_downloader.dart#L93
However, cleanup resets flutter_gemma_downloads:
https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/core/model_management/managers/mobile_model_manager.dart#L263
https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/core/model_management/managers/mobile_model_manager.dart#L591
Resume detection also queries flutter_gemma_downloads:
https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/core/model_management/utils/resume_checker.dart#L96
As a result, cleanup and resume detection do not operate on the tasks created by
SmartDownloader.
2. Persisted task IDs are derived from hashCode
The task ID is currently constructed from:
'${url.hashCode...}_${targetPath.hashCode...}'
https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/mobile/smart_downloader.dart#L446-L448
Dart documents that hash codes need not be consistent between executions:
https://api.dart.dev/dart-core/Object/hashCode.html
A retry after process restart can therefore receive a different task ID and
create a new temporary download, detaching the previous resumable file.
Signed URLs or changing query parameters can cause the same problem even if a
deterministic hash implementation is used.
3. cleanupStorage() cannot see downloader temporary files
ModelFileSystemManager.getOrphanedFiles() scans only
getModelStorageDirectory() and filters for supported model extensions:
https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/core/model_management/utils/file_system_manager.dart#L103-L112
background_downloader partial files are extensionless files named similarly
to com.bbflight.background_downloader and may live in Android
filesDir, outside the final model directory.
Therefore the public orphan/cleanup API cannot discover them.
4. Resume watchdog does not cancel the native task
When the resume watchdog fires, it closes the Dart progress stream and reports
an error, but does not call cancelTaskWithId(taskId):
https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/mobile/smart_downloader.dart#L1090-L1105
This can leave the native task, resume metadata, and partial file behind after
the Dart installation future has failed.
5. reset() is insufficient for paused/resumable files
Even after correcting the group name, cleanup should enumerate tasks and call
cancelTasksWithIds() before resetting the group.
In background_downloader, cancellation of paused tasks explicitly deletes the
associated temp file. A reset primarily removes task state and is not a
replacement for deleting resumable data.
Why the underlying downloader retains the file
This behavior is intentional in background_downloader: failed downloads may
be resumed when the server supports ranges and provides a compatible ETag.
For large downloads, Android uses persistent filesDir instead of cacheDir.
Its documentation warns that remnant temp files in filesDir must be cleaned
by the application because Android does not remove them automatically:
https://github.com/781flyingdutchman/background_downloader/blob/main/CHANGELOG.md#L866-L874
Suggested fixes
-
Define one download-group constant and reuse it everywhere.
-
Replace runtime hashCode task IDs with a deterministic digest.
-
Ideally allow callers to provide a stable logical downloadId, because
signed URLs may change between retries.
-
When the resume watchdog fires, cancel the native task and await
cancellation before settling the installation future.
-
In explicit cleanup:
- enumerate smart_downloads tasks;
- cancel them using cancelTasksWithIds;
- remove resume metadata;
- remove detached legacy temp files when no matching task exists.
-
Extend getOrphanedFiles() to include downloader fragments and their sizes.
-
Guarantee that only one partial exists for each logical model installation.
-
Document whether a failure remains resumable and how callers can explicitly
discard a partial download.
Suggested tests
-
Interrupt a ranged download after several megabytes and verify one resumable
partial exists.
-
Restart the Dart process and verify the same task ID/partial is reused.
-
Fire the resume watchdog and verify the native task and temp file are removed.
-
Call cleanupStorage() and verify paused-task temp files and metadata are
deleted.
-
Assert that downloader, cleanup, and resume detection use the same group.
-
Retry with a changed bearer token or signed URL and verify the old partial is
reused or safely removed.
Description
Failed or interrupted network model installations can leave multi-gigabyte
background_downloadertemporary files in Android's persistentfilesDir.Repeated retries or application restarts may create additional partial files.
The application can therefore consume several gigabytes even though
FlutterGemma.listInstalledModels()reports no installed model.I reproduced this with:
.litertlmmodelRange,Accept-Ranges: bytes, and a stable strong ETagThe relevant implementation defects are also still present on current
main/ release 1.3.0.
Example
Steps to reproduce
Start downloading a multi-gigabyte model on Android.
Let the download reach substantial progress, for example 50–60%.
Interrupt the network, terminate the process, or trigger a resume/watchdog
failure.
Restart the application and retry the installation several times.
Inspect application storage:
adb shell run-as sh -c
'ls -lh files/com.bbflight.background_downloader*'
Call:
await FlutterGemma.listInstalledModels();
await FlutterGemma.getOrphanedFiles();
await FlutterGemma.cleanupStorage();
Actual result
Multiple files named similarly to
com.bbflight.background_downloader remain under Android filesDir.
These files can each contain a large portion of the model.
Application data grows by several gigabytes.
No model is reported as installed.
getOrphanedFiles() does not report these files.
cleanupStorage() does not delete them.
Expected result
A logical model download should have at most one resumable partial file.
Retrying after a process restart should reuse that partial or remove it before
starting a replacement.
Explicit cancellation should delete the partial file and resume metadata.
getOrphanedFiles() should report abandoned downloader fragments.
cleanupStorage() should remove abandoned downloader fragments and their
metadata.
Source-level findings
1. Downloader group mismatch
SmartDownloader creates tasks in smart_downloads:
https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/mobile/smart_downloader.dart#L93
However, cleanup resets flutter_gemma_downloads:
https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/core/model_management/managers/mobile_model_manager.dart#L263
https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/core/model_management/managers/mobile_model_manager.dart#L591
Resume detection also queries flutter_gemma_downloads:
https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/core/model_management/utils/resume_checker.dart#L96
As a result, cleanup and resume detection do not operate on the tasks created by
SmartDownloader.
2. Persisted task IDs are derived from hashCode
The task ID is currently constructed from:
'${url.hashCode...}_${targetPath.hashCode...}'
https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/mobile/smart_downloader.dart#L446-L448
Dart documents that hash codes need not be consistent between executions:
https://api.dart.dev/dart-core/Object/hashCode.html
A retry after process restart can therefore receive a different task ID and
create a new temporary download, detaching the previous resumable file.
Signed URLs or changing query parameters can cause the same problem even if a
deterministic hash implementation is used.
3. cleanupStorage() cannot see downloader temporary files
ModelFileSystemManager.getOrphanedFiles() scans only
getModelStorageDirectory() and filters for supported model extensions:
https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/core/model_management/utils/file_system_manager.dart#L103-L112
background_downloader partial files are extensionless files named similarly
to com.bbflight.background_downloader and may live in Android
filesDir, outside the final model directory.
Therefore the public orphan/cleanup API cannot discover them.
4. Resume watchdog does not cancel the native task
When the resume watchdog fires, it closes the Dart progress stream and reports
an error, but does not call cancelTaskWithId(taskId):
https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/mobile/smart_downloader.dart#L1090-L1105
This can leave the native task, resume metadata, and partial file behind after
the Dart installation future has failed.
5. reset() is insufficient for paused/resumable files
Even after correcting the group name, cleanup should enumerate tasks and call
cancelTasksWithIds() before resetting the group.
In background_downloader, cancellation of paused tasks explicitly deletes the
associated temp file. A reset primarily removes task state and is not a
replacement for deleting resumable data.
Why the underlying downloader retains the file
This behavior is intentional in background_downloader: failed downloads may
be resumed when the server supports ranges and provides a compatible ETag.
For large downloads, Android uses persistent filesDir instead of cacheDir.
Its documentation warns that remnant temp files in filesDir must be cleaned
by the application because Android does not remove them automatically:
https://github.com/781flyingdutchman/background_downloader/blob/main/CHANGELOG.md#L866-L874
Suggested fixes
Define one download-group constant and reuse it everywhere.
Replace runtime hashCode task IDs with a deterministic digest.
Ideally allow callers to provide a stable logical downloadId, because
signed URLs may change between retries.
When the resume watchdog fires, cancel the native task and await
cancellation before settling the installation future.
In explicit cleanup:
Extend getOrphanedFiles() to include downloader fragments and their sizes.
Guarantee that only one partial exists for each logical model installation.
Document whether a failure remains resumable and how callers can explicitly
discard a partial download.
Suggested tests
Interrupt a ranged download after several megabytes and verify one resumable
partial exists.
Restart the Dart process and verify the same task ID/partial is reused.
Fire the resume watchdog and verify the native task and temp file are removed.
Call cleanupStorage() and verify paused-task temp files and metadata are
deleted.
Assert that downloader, cleanup, and resume detection use the same group.
Retry with a changed bearer token or signed URL and verify the old partial is
reused or safely removed.