feat: Download a transfer or folder with api v2#618
Conversation
PR Reviewer Guide 🔍(Review updated until commit 23554ac)Here are some key observations to aid the review process:
|
| private fun hasAvailableSpace(requiredBytes: Long): Boolean { | ||
| val path = when { | ||
| Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> applicationContext.getExternalFilesDir(null)?.path ?: return true | ||
| else -> Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).path | ||
| } | ||
| val availableBytes = StatFs(path).availableBytes | ||
| // Add a safety margin of 10% extra space | ||
| val requiredWithMargin = (requiredBytes * 110) / 100 | ||
| return availableBytes >= requiredWithMargin | ||
| } |
There was a problem hiding this comment.
Suggestion: For API level Q and above, the code incorrectly checks available space in the app's private external directory (getExternalFilesDir) instead of the public Downloads directory where files are actually saved via MediaStore. This can cause incorrect "insufficient space" errors or fail to detect actual lack of space. Use the public Downloads directory path for all API levels. [possible issue, importance: 8]
| private fun hasAvailableSpace(requiredBytes: Long): Boolean { | |
| val path = when { | |
| Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> applicationContext.getExternalFilesDir(null)?.path ?: return true | |
| else -> Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).path | |
| } | |
| val availableBytes = StatFs(path).availableBytes | |
| // Add a safety margin of 10% extra space | |
| val requiredWithMargin = (requiredBytes * 110) / 100 | |
| return availableBytes >= requiredWithMargin | |
| } | |
| private fun hasAvailableSpace(requiredBytes: Long): Boolean { | |
| val path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).path | |
| val availableBytes = StatFs(path).availableBytes | |
| // Add a safety margin of 10% extra space | |
| val requiredWithMargin = (requiredBytes * 110) / 100 | |
| return availableBytes >= requiredWithMargin | |
| } |
|
Persistent review updated to latest commit 23554ac |
| output: OutputStream, | ||
| onDownload: suspend (bytesSentTotal: Long, contentLength: Long?) -> Unit, | ||
| ) { | ||
| createHttpClient(HttpClient.okHttpClient).prepareGet(url) { |
There was a problem hiding this comment.
Suggestion: Reuse a single HttpClient instance instead of creating a new one for each file download. Creating new clients per download wastes resources, causes socket exhaustion under concurrent loads, and bypasses connection pooling benefits. Inject a singleton client or store it as a class property. [possible issue, importance: 6]
| createHttpClient(HttpClient.okHttpClient).prepareGet(url) { | |
| // Inject or define as singleton property | |
| private val httpClient = createHttpClient(HttpClient.okHttpClient) | |
| private suspend fun downloadFile( | |
| url: String, | |
| output: OutputStream, | |
| onDownload: suspend (bytesSentTotal: Long, contentLength: Long?) -> Unit, | |
| ) { | |
| httpClient.prepareGet(url) { |
| private fun buildDownloadNotification(title: String): Notification { | ||
| return NotificationCompat.Builder(appContext, ChannelIds.downloadChannelId) | ||
| .setTicker(title) | ||
| .setContentTitle(title) | ||
| .setContentText(appContext.getString(R.string.notificationDownloadSuccessNotificationTitle)) | ||
| .setSmallIcon(defaultSmallIcon) | ||
| .setColor(notificationIconColor) | ||
| .setAutoCancel(true) | ||
| .build() | ||
| } |
There was a problem hiding this comment.
Do we not put an intent when the notification is clicked?
There was a problem hiding this comment.
Added a content intent in c864e86 that opens the system Downloads folder (DownloadManager.ACTION_VIEW_DOWNLOADS) when the notification is tapped. Also moved the notification ID from a magic number to Ids.DownloadSuccess.
There was a problem hiding this comment.
@copilot There's no point in displaying the download view because they'll never end up there, instead, packageManager.getLaunchIntentForPackage would be better here.
There was a problem hiding this comment.
Updated in 4bb5644 — now using appContext.packageManager.getLaunchIntentForPackage(appContext.packageName) to open the app when the notification is tapped.
| return UniqueDownloadId(999) | ||
| } | ||
|
|
||
| fun downloadStatusFlow(transferId: String, folderId: String?): Flow<DownloadStatus> { |
There was a problem hiding this comment.
Is this really a folderId or rather a fileId?
| } | ||
| }.getOrElse { exception -> | ||
| return when (exception) { | ||
| is CancellationException, is NetworkException -> Result.retry() |
There was a problem hiding this comment.
Why do we want to retry when there's a CancellationException?
There was a problem hiding this comment.
Given that, in a Worker, CancellationExceptions mainly originate from the system, and generally serve to tell us to stop and restart later, and since we don’t perform any jobs that we cancel ourselves, we don’t encounter CancellationExceptions that apply to us and need to be handled, because all our errors are exceptions that provide clear information.
| return workManager.getWorkInfosFlow(workQuery).transform { | ||
| val workInfo = it.firstOrNull() | ||
| if (workInfo == null) { | ||
| emit(DownloadStatus.Complete) | ||
| } else if (workInfo.isPending() && isNetworkAvailableFlow.first().not()) { | ||
| emit(DownloadStatus.Paused(DownloadStatus.Paused.Reason.WaitingForNetwork)) | ||
| } else { | ||
| emit(workInfo.toDownloadStatus()) | ||
| } | ||
| } |
There was a problem hiding this comment.
Why not just use a map instead of a transform?
There was a problem hiding this comment.
Looking at the code, I don't see any particular reason; clearly, a map would suffice here.
I'd even say a mapLatest, since we have a suspend function.
Check out commit 6b6f993
There was a problem hiding this comment.
Pull request overview
This PR adds support for downloading an entire transfer or a folder when using SwissTransfer API v2, shifting those downloads to a WorkManager-based implementation while keeping legacy behavior for API v1.
Changes:
- Bump
swisstransferdependency to7.1.1and add Ktor network dependency. - Introduce
DownloadWorker+AppDownloadManagerto download transfers/folders in the background and report progress via notifications. - Update UI/viewmodels and helpers to route API v2 download/preview flows through the new scheduler and add new strings for download notifications.
Reviewed changes
Copilot reviewed 26 out of 26 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| gradle/libs.versions.toml | Bumps SwissTransfer library version. |
| app/build.gradle.kts | Adds Ktor dependency needed for new download implementation. |
| app/src/main/res/values*/strings.xml | Adds new strings for download notifications and “Feedback” button across locales. |
| app/src/main/java/com/infomaniak/swisstransfer/ui/utils/TransferUiExt.kt | Adds displayTitle helper for transfer display naming. |
| app/src/main/java/com/infomaniak/swisstransfer/ui/utils/NotificationsUtils.kt | Adds a download notification channel and download success/progress notifications. |
| app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/filesdetails/FilesDetailsViewModel.kt | Injects DownloadWorker.Scheduler and passes it to preview/download flows. |
| app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferManagerExt.kt | Routes preview URI/status handling based on API version. |
| app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferDownload.kt | Switches v2 transfer/folder downloads to worker-based scheduling and updates open/delete handling. |
| app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferDetailsViewModel.kt | Injects scheduler and wires it into preview/download flows. |
| app/src/main/java/com/infomaniak/swisstransfer/ui/screen/main/transferdetails/TransferDetailsScreen.kt | Enables download action in the top app bar for sent transfers. |
| app/src/main/java/com/infomaniak/swisstransfer/ui/components/transfer/TransferItem.kt | Uses displayTitle for transfer list items. |
| app/src/main/java/com/infomaniak/swisstransfer/services/DownloadWorker.kt | New WorkManager worker + scheduler for v2 downloads with foreground progress notifications. |
| app/src/main/java/com/infomaniak/swisstransfer/services/AppDownloadManager.kt | New downloader that streams files into Public Downloads (MediaStore / filesystem). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private suspend fun isFileDeleted(id: UniqueDownloadId, transfer: TransferUi, targetFile: FileUi?): Boolean { | ||
| repeat(2) { attemptIndex -> | ||
| runCatching { | ||
| return downloadManager.doesFileExist(id).not() | ||
| return when { | ||
| transfer.isV1() -> downloadManager.doesFileExist(id).not() | ||
| targetFile != null -> { | ||
| downloadManager.doesFileExist(id).not() && AppDownloadManager.doesFileExists(transfer, targetFile).not() | ||
| } | ||
| else -> true | ||
| } |
| val downloadStatusFlow = when { | ||
| transfer.isV2() -> downloadWorkerScheduler.downloadStatusFlow( | ||
| transferId = transfer.uuid, | ||
| folderId = file.uid, | ||
| ) | ||
| else -> downloadManager.downloadStatusFlow(uniqueDownloadId) | ||
| } | ||
| val uriFlow = downloadStatusFlow.transformLatest { status -> | ||
| if (status !is DownloadStatus.Complete) { | ||
| emit(null) | ||
| awaitCancellation() | ||
| } | ||
|
|
||
| val uri = downloadManager.uriFor(uniqueDownloadId) | ||
| val uri = when { | ||
| transfer.isV2() -> AppDownloadManager.uriFor(transfer, file) | ||
| else -> downloadManager.uriFor(uniqueDownloadId) | ||
| } |
| return workManager.getWorkInfosFlow(workQuery).transform { | ||
| val workInfo = it.firstOrNull() | ||
| if (workInfo == null) { | ||
| emit(DownloadStatus.Complete) | ||
| } else if (workInfo.isPending() && isNetworkAvailableFlow.first().not()) { | ||
| emit(DownloadStatus.Paused(DownloadStatus.Paused.Reason.WaitingForNetwork)) | ||
| } else { | ||
| emit(workInfo.toDownloadStatus()) | ||
| } | ||
| } |
| val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI | ||
| val itemUri = resolver.insert(collection, contentValues) ?: return@withContext null | ||
|
|
| val createdDate = createdDateTimestamp.toDateFromSeconds().format(FORMAT_DATE_TITLE) | ||
| val title = title ?: createdDate | ||
| return title |
| fun downloadSucceeded(tag: String, title: String) { | ||
| val notification = buildDownloadNotification(title) | ||
| notificationManagerCompat.notifyCompat(tag, 1, notification) | ||
| } |
Co-authored-by: sirambd <abdourahamane.boinaidi@infomaniak.com>
Co-authored-by: sirambd <abdourahamane.boinaidi@infomaniak.com>
38d672d to
cb7ee5f
Compare
0f2ef1a to
83d331a
Compare
Agent-Logs-Url: https://github.com/Infomaniak/android-SwissTransfer/sessions/05dec554-32c4-47cd-b6fd-5808dcc71c9a Co-authored-by: sirambd <28200274+sirambd@users.noreply.github.com>
…n download notification Agent-Logs-Url: https://github.com/Infomaniak/android-SwissTransfer/sessions/51089ddf-2222-42f6-8c5c-2e26cd276222 Co-authored-by: sirambd <28200274+sirambd@users.noreply.github.com>
|



No description provided.