Skip to content

feat: Download a transfer or folder with api v2#618

Open
sirambd wants to merge 53 commits intomainfrom
download-folder
Open

feat: Download a transfer or folder with api v2#618
sirambd wants to merge 53 commits intomainfrom
download-folder

Conversation

@sirambd
Copy link
Copy Markdown
Member

@sirambd sirambd commented Apr 1, 2026

No description provided.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 1, 2026

PR Reviewer Guide 🔍

(Review updated until commit 23554ac)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 No relevant tests
🔒 Security concerns

Path Traversal:
The download path construction in AppDownloadManager uses unsanitized user-controlled input (transfer.displayTitle and fileUi.fileName/fileUi.path) when building file system paths. An attacker controlling the transfer metadata could potentially inject path traversal sequences (e.g., "../../../") to write files to arbitrary locations on the device's storage. This should be mitigated by validating or sanitizing the title and filename inputs to remove or escape path separators and parent directory references.

⚡ Recommended focus areas for review

Hardcoded UniqueDownloadId

The scheduleWork method returns UniqueDownloadId(999) as a hardcoded constant. If multiple downloads are scheduled concurrently, they will all share the same ID, which could cause confusion in logging or tracking. While the current implementation appears to use the transfer UUID and folder ID for actual work identification, returning a constant value defeats the purpose of having a unique ID type. Consider returning a unique value derived from the work request or transfer identifier.

return UniqueDownloadId(999)
Incorrect TAG constant

The TAG is defined as AppDownloadManager::javaClass.name which evaluates to "java.lang.Class" rather than the actual class name. This will cause all Sentry logs to be tagged incorrectly. It should be changed to AppDownloadManager::class.java.name or a hardcoded string.

private val TAG = AppDownloadManager::javaClass.name
Path Traversal Risk

The computeFolderDownloadPathWith function constructs file paths using transfer.displayTitle and fileUi.path without sanitizing path traversal sequences (e.g., "../"). If the server returns malicious filenames or transfer titles containing such sequences, the app could write files outside the intended Downloads/SwissTransfer directory. This affects both the MediaStore implementation (API 29+) and the direct File access (API < 29). Validate or sanitize these inputs to prevent directory traversal attacks.

fun TransferUi.computeFolderDownloadPathWith(fileUi: FileUi): String {
    val filePath = fileUi.path?.takeIf { it.contains("/") }?.let {
        if (it.startsWith("/")) it.substringAfter("/") else it
    }
    return "$ROOT_FOLDER_NAME/${this.displayTitle}/${filePath?.substringBeforeLast("/") ?: ""}"

Comment on lines +169 to +178
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
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

Suggested change
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
}

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 2, 2026

Persistent review updated to latest commit 23554ac

Comment thread app/src/main/java/com/infomaniak/swisstransfer/services/DownloadWorker.kt Outdated
output: OutputStream,
onDownload: suspend (bytesSentTotal: Long, contentLength: Long?) -> Unit,
) {
createHttpClient(HttpClient.okHttpClient).prepareGet(url) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

Suggested change
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) {

Comment thread app/src/main/res/values/strings.xml Outdated
Comment thread app/src/main/java/com/infomaniak/swisstransfer/ui/utils/NotificationsUtils.kt Outdated
Comment thread app/src/main/java/com/infomaniak/swisstransfer/ui/utils/NotificationsUtils.kt Outdated
Comment on lines +183 to +192
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()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we not put an intent when the notification is clicked?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot There's no point in displaying the download view because they'll never end up there, instead, packageManager.getLaunchIntentForPackage would be better here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really a folderId or rather a fileId?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a folderId

}
}.getOrElse { exception ->
return when (exception) {
is CancellationException, is NetworkException -> Result.retry()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we want to retry when there's a CancellationException?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/src/main/java/com/infomaniak/swisstransfer/services/DownloadWorker.kt Outdated
Comment thread app/src/main/java/com/infomaniak/swisstransfer/services/DownloadWorker.kt Outdated
Comment on lines +222 to +231
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())
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just use a map instead of a transform?

Copy link
Copy Markdown
Member Author

@sirambd sirambd Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 swisstransfer dependency to 7.1.1 and add Ktor network dependency.
  • Introduce DownloadWorker + AppDownloadManager to 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.

Comment on lines +204 to +213
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
}
Comment on lines +60 to +76
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)
}
Comment on lines +222 to +231
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())
}
}
Comment on lines +161 to +163
val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI
val itemUri = resolver.insert(collection, contentValues) ?: return@withContext null

Comment on lines +91 to +93
val createdDate = createdDateTimestamp.toDateFromSeconds().format(FORMAT_DATE_TITLE)
val title = title ?: createdDate
return title
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Check it out—I've improved this code even further in this commit: 075b16f

Comment thread app/src/main/java/com/infomaniak/swisstransfer/services/AppDownloadManager.kt Outdated
Comment on lines +142 to +145
fun downloadSucceeded(tag: String, title: String) {
val notification = buildDownloadNotification(title)
notificationManagerCompat.notifyCompat(tag, 1, notification)
}
@sonarqubecloud
Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants