Skip to content

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

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

feat: Download a transfer or folder with api v2#618
sirambd wants to merge 39 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 +134 to +143
}.getOrElse { exception ->
return when (exception) {
is CancellationException, is NetworkException -> Result.retry()
is AppDownloadManager.FailureException -> Result.failure()
else -> {
SentryLog.e(TAG, "failure", exception)
Result.failure()
}
}
}
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: Do not retry when a CancellationException is caught, as this indicates the work was cancelled by the user or system. Retrying cancelled work ignores the cancellation request and causes the download to restart unexpectedly. Remove CancellationException from the retry condition and treat it as a failure. [possible issue, importance: 8]

Suggested change
}.getOrElse { exception ->
return when (exception) {
is CancellationException, is NetworkException -> Result.retry()
is AppDownloadManager.FailureException -> Result.failure()
else -> {
SentryLog.e(TAG, "failure", exception)
Result.failure()
}
}
}
}.getOrElse { exception ->
return when (exception) {
is CancellationException -> Result.failure()
is NetworkException -> Result.retry()
is AppDownloadManager.FailureException -> Result.failure()
else -> {
SentryLog.e(TAG, "failure", exception)
Result.failure()
}
}
}

Comment on lines +185 to +203
private suspend fun downloadFile(
url: String,
output: OutputStream,
onDownload: suspend (bytesSentTotal: Long, contentLength: Long?) -> Unit,
) {
createHttpClient(HttpClient.okHttpClient).prepareGet(url) {
accept(ContentType.Any)
onDownload { bytesSentTotal, contentLength ->
onDownload(bytesSentTotal, contentLength)
}
}.execute { response ->
val channel = response.bodyAsChannel()
while (!channel.isClosedForRead) {
val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE)
while (!packet.exhausted()) output.write(packet.readByteArray())
}
output.flush()
}
}
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: The HttpClient created by createHttpClient() is never closed, causing resource leaks (socket exhaustion, memory growth) when downloading multiple files. Use a single shared HttpClient instance injected into the class, or ensure the client is properly closed after use by wrapping in try-finally or using use {}. [possible issue, importance: 8]

Suggested change
private suspend fun downloadFile(
url: String,
output: OutputStream,
onDownload: suspend (bytesSentTotal: Long, contentLength: Long?) -> Unit,
) {
createHttpClient(HttpClient.okHttpClient).prepareGet(url) {
accept(ContentType.Any)
onDownload { bytesSentTotal, contentLength ->
onDownload(bytesSentTotal, contentLength)
}
}.execute { response ->
val channel = response.bodyAsChannel()
while (!channel.isClosedForRead) {
val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE)
while (!packet.exhausted()) output.write(packet.readByteArray())
}
output.flush()
}
}
private suspend fun downloadFile(
url: String,
output: OutputStream,
onDownload: suspend (bytesSentTotal: Long, contentLength: Long?) -> Unit,
) {
val client = createHttpClient(HttpClient.okHttpClient)
try {
client.prepareGet(url) {
accept(ContentType.Any)
onDownload { bytesSentTotal, contentLength ->
onDownload(bytesSentTotal, contentLength)
}
}.execute { response ->
val channel = response.bodyAsChannel()
while (!channel.isClosedForRead) {
val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE)
while (!packet.exhausted()) output.write(packet.readByteArray())
}
output.flush()
}
} finally {
client.close()
}
}

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 on lines +197 to +198
workManager.enqueueUniqueWork(uniqueWorkName, ExistingWorkPolicy.REPLACE, workRequest)
return UniqueDownloadId(999)
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: Return a unique identifier instead of the hardcoded value 999. Using a constant ID causes collisions when multiple downloads are active, leading to incorrect status tracking and potential conflicts in the database. Generate a unique ID based on the work request or use a counter. [possible issue, importance: 8]

Suggested change
workManager.enqueueUniqueWork(uniqueWorkName, ExistingWorkPolicy.REPLACE, workRequest)
return UniqueDownloadId(999)
workManager.enqueueUniqueWork(uniqueWorkName, ExistingWorkPolicy.REPLACE, workRequest)
return UniqueDownloadId(workRequest.id.hashCode())

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 on lines +210 to +215
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("/") ?: ""}"
}
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: Sanitize displayTitle and file path components to prevent directory traversal attacks. User-controlled transfer titles containing ".." or path separators can escape the intended download directory on API < 29, potentially overwriting files in arbitrary locations. Remove or escape path separator characters from displayTitle. [security, importance: 9]

Suggested change
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("/") ?: ""}"
}
fun TransferUi.computeFolderDownloadPathWith(fileUi: FileUi): String {
val filePath = fileUi.path?.takeIf { it.contains("/") }?.let {
if (it.startsWith("/")) it.substringAfter("/") else it
}?.substringBeforeLast("/")?.replace("..", "_")
val safeTitle = this.displayTitle.replace("[/\\\\]".toRegex(), "_").replace("..", "_")
return "$ROOT_FOLDER_NAME/$safeTitle/${filePath ?: ""}"
}

Comment on lines 34 to +35
<string name="buttonEditTransfer">Edit my transfer</string>
<string name="buttonFeedback">Feedback</string>
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 you need this buttonFeedback?


fun downloadSucceeded(tag: String, title: String) {
val notification = buildDownloadNotification(title)
notificationManagerCompat.notifyCompat(tag, 1, notification)
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.

This method looks deprecated


private fun buildDownloadNotification(title: String): Notification {
return NotificationCompat.Builder(appContext, ChannelIds.downloadChannelId)
.setTicker(title)
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.

Shouldn't the ticker be "download completed" rather than the title of the file? Or if you want to go further maybe a mix of the two like "Download completed: $title" but this requires a new string

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?

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?

}
}.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?

}
val availableBytes = StatFs(path).availableBytes
// Add a safety margin of 10% extra space
val requiredWithMargin = (requiredBytes * 110) / 100
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.

Suggested change
val requiredWithMargin = (requiredBytes * 110) / 100
val requiredWithMargin = requiredBytes * 1.1


private fun hasAvailableSpace(requiredBytes: Long): Boolean {
val path = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> applicationContext.getExternalFilesDir(null)?.path ?: return true
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.

Same writing standard

Suggested change
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> applicationContext.getExternalFilesDir(null)?.path ?: return true
SDK_INT >= 29 -> applicationContext.getExternalFilesDir(null)?.path ?: return true

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?

onClick: () -> Unit,
) {

val createdDate = transfer.createdDateTimestamp.toDateFromSeconds().format(FORMAT_DATE_TITLE)
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.

As sonar pointed out, createdDate is unused

@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.

2 participants