Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
15e5790
fix(upload): resume chunked uploads from the parts the server already…
ToteMeiSter Oct 5, 2026
bfe8366
fix(upload): prepare the file of an upload once and keep it between runs
ToteMeiSter Oct 5, 2026
a40ecad
fix(upload): keep the restart mark and the upload files safe
ToteMeiSter Oct 5, 2026
cce7ce7
fix(upload): do not start a chunked upload after a stop
ToteMeiSter Oct 5, 2026
e6fb95a
fix(upload): remember a cancel after doWork() removed its marker
ToteMeiSter Oct 5, 2026
b9d5245
fix(upload): address review findings on resume and cancel
ToteMeiSter Oct 13, 2026
4107b52
fix(upload): copy a content uri aside and rename it after success
ToteMeiSter Oct 13, 2026
26fc4ab
fix(upload): prepare the file in a subdirectory of the workspace
ToteMeiSter Oct 16, 2026
0f1e298
fix(upload): copy an empty file instead of failing it
ToteMeiSter Oct 16, 2026
6a17889
fix(upload): try an upload again after a short server outage
ToteMeiSter Oct 16, 2026
f9f5928
fix(upload): give the chunked uploader its own dispatcher
ToteMeiSter Oct 5, 2026
41da4cb
fix(upload): keep an upload running in a foreground service and retry…
ToteMeiSter Oct 5, 2026
c56aceb
fix(upload): send the cancel request from the application scope
ToteMeiSter Oct 5, 2026
18e7498
fix(upload): do not start a chunked upload after a stop
ToteMeiSter Oct 5, 2026
e019099
test(upload): cancelling one upload must not cancel the uploads behin…
ToteMeiSter Oct 14, 2026
38e91bb
fix(upload): a failed write in the cancel path must not fail the uplo…
ToteMeiSter Oct 14, 2026
48ab95e
fix(upload): keep the cancel flag when WorkManager stopped the cancel…
ToteMeiSter Oct 14, 2026
66f28e6
fix(upload): give up an upload the server keeps refusing for 24 hours
ToteMeiSter Oct 15, 2026
fe91dd9
fix(upload): fail at once when the prepared file cannot be read
ToteMeiSter Oct 15, 2026
20a1a5b
fix(upload): report a failed upload in the output data of the work
ToteMeiSter Oct 15, 2026
7177c30
fix(upload): show the upload notification only for files that are upl…
ToteMeiSter Oct 15, 2026
95f6a68
fix(upload): remove the draft file of an upload that failed
ToteMeiSter Oct 15, 2026
25cbdd4
fix(upload): take the size for the foreground decision from the prepa…
ToteMeiSter Oct 15, 2026
9f3808b
fix(upload): keep the 24 hours from failing an upload that took data
ToteMeiSter Oct 15, 2026
e063f78
fix(upload): forget the old progress when an upload starts from the b…
ToteMeiSter Oct 15, 2026
52b9b50
test(upload): keep the retry tests of FileUploader in their own file
ToteMeiSter Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.READ_PROFILE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
Expand Down Expand Up @@ -327,6 +328,14 @@
<receiver
android:name=".receivers.EndCallReceiver"
android:exported="false" />
<receiver
android:name=".receivers.UploadCancelReceiver"
android:exported="false" />

<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />

<service
android:name=".utils.SyncService"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -431,15 +431,15 @@ class ChatViewModel @AssistedInject constructor(
}

fun cancelUpload(referenceId: String) {
// uploadReferenceToWorkId is session-local (never persisted) - after an app restart it's
// empty even for an upload that's still stuck showing as "uploading" from a previous
// session, so a placeholder must still be removable even when there's no known work id to
// also cancel. Without this, cancel silently did nothing for any such placeholder, leaving
// the user with no way to clear a permanently stuck upload.
uploadReferenceToWorkId.remove(referenceId)?.let { workId ->
// uploadReferenceToWorkId is session-local - after an app restart it's empty, then the work is
// found by its reference id. That lookup blocks, so it runs off the main thread.
// The placeholder below is removed whether or not a work is found.
// Both jobs run in appScope: leaving the chat right after the tap must not drop the cancellation.
val workId = uploadReferenceToWorkId.remove(referenceId)
appScope.launch(Dispatchers.IO) {
UploadAndShareFilesWorker.cancelUpload(referenceId, workId)
}
viewModelScope.launch {
appScope.launch {
chatRepository.deleteTempMessageByReferenceId(referenceId)
}
_uploadProgressMap.update { it - referenceId }
Expand Down
592 changes: 461 additions & 131 deletions app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.receivers

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import autodagger.AutoInjector
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.data.database.dao.ChatMessagesDao
import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
import java.util.UUID
import javax.inject.Inject
import kotlin.concurrent.thread

/**
* Cancels an upload from its notification the same way the chat does: the worker aborts and the placeholder
* message of the upload is removed.
*/
@AutoInjector(NextcloudTalkApplication::class)
class UploadCancelReceiver : BroadcastReceiver() {

@Inject
lateinit var chatDao: ChatMessagesDao

@Suppress("TooGenericExceptionCaught")
override fun onReceive(context: Context, intent: Intent) {
val workId = intent.getStringExtra(KEY_WORK_ID)?.let { runCatching { UUID.fromString(it) }.getOrNull() }
val referenceId = intent.getStringExtra(KEY_REFERENCE_ID).orEmpty()
val internalConversationId = intent.getStringExtra(KEY_INTERNAL_CONVERSATION_ID).orEmpty()
if (workId == null) {
Log.w(TAG, "Cancel request without a valid work id")
return
}

NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
val pendingResult = goAsync()
thread {
try {
UploadAndShareFilesWorker.cancelUpload(referenceId, workId)
if (referenceId.isNotEmpty() && internalConversationId.isNotEmpty()) {
chatDao.deleteTempChatMessageIfPending(internalConversationId, referenceId)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to cancel upload", e)
} finally {
pendingResult.finish()
}
}
}

companion object {
private val TAG = UploadCancelReceiver::class.simpleName
const val KEY_WORK_ID = "KEY_UPLOAD_WORK_ID"
const val KEY_REFERENCE_ID = "KEY_UPLOAD_REFERENCE_ID"
const val KEY_INTERNAL_CONVERSATION_ID = "KEY_UPLOAD_INTERNAL_CONVERSATION_ID"
}
}
60 changes: 60 additions & 0 deletions app/src/main/java/com/nextcloud/talk/upload/UploadNotification.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.upload

import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import com.nextcloud.talk.R
import com.nextcloud.talk.receivers.UploadCancelReceiver
import com.nextcloud.talk.utils.NotificationUtils

/** The ongoing notification of the foreground service that keeps an upload running. */
object UploadNotification {

private const val MAX_PERCENT = 100

@Suppress("LongParameterList")
fun build(
context: Context,
notificationId: Int,
fileName: String,
percent: Int,
workId: String,
referenceId: String?,
internalConversationId: String?
): Notification {
val cancelIntent = Intent(context, UploadCancelReceiver::class.java)
.putExtra(UploadCancelReceiver.KEY_WORK_ID, workId)
.putExtra(UploadCancelReceiver.KEY_REFERENCE_ID, referenceId)
.putExtra(UploadCancelReceiver.KEY_INTERNAL_CONVERSATION_ID, internalConversationId)
val cancelPendingIntent = PendingIntent.getBroadcast(
context,
notificationId,
cancelIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

return NotificationCompat.Builder(
context,
NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_UPLOADS.name
)
.setContentTitle(context.getString(R.string.nc_upload_in_progess))
.setContentText(fileName)
.setSmallIcon(R.drawable.upload_white)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setProgress(MAX_PERCENT, percent, percent <= 0)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSilent(true)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.addAction(R.drawable.ic_baseline_close_24, context.getString(R.string.nc_cancel), cancelPendingIntent)
.build()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.upload

/** Lets a progress update through only when the value changed and [minIntervalMs] passed since the last one. */
class UploadProgressThrottle(
private val minIntervalMs: Long = DEFAULT_INTERVAL_MS,
private val clock: () -> Long = System::currentTimeMillis
) {
private var lastPercent = -1
private var lastUpdateMs = 0L

@Synchronized
fun shouldUpdate(percent: Int): Boolean {
val now = clock()
val due = lastPercent < 0 || now - lastUpdateMs >= minIntervalMs
if (percent == lastPercent || !due) {
return false
}
lastPercent = percent
lastUpdateMs = now
return true
}

companion object {
private const val DEFAULT_INTERVAL_MS = 1000L
}
}
143 changes: 143 additions & 0 deletions app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.upload

import android.system.ErrnoException
import android.system.OsConstants
import at.bitfire.dav4jvm.exception.HttpException as DavHttpException
import retrofit2.HttpException as RetrofitHttpException
import java.io.FileNotFoundException
import java.io.IOException
import java.net.UnknownServiceException
import java.security.cert.CertPathValidatorException
import java.security.cert.CertificateException
import javax.net.ssl.SSLException
import javax.net.ssl.SSLHandshakeException
import javax.net.ssl.SSLPeerUnverifiedException

/**
* Decides what an upload does after an error or after WorkManager stopped it.
*
* Network problems and stops by the system never use up attempts: the upload resumes once the network is back.
* A server that answers "unavailable" is waited for up to [MAX_UNAVAILABLE_MILLIS]. Other errors answered by the
* server count against [MAX_SERVER_ERRORS].
*/
object UploadRetryPolicy {

/** Server errors one upload may hit before it is marked as failed. */
const val MAX_SERVER_ERRORS = 4

/** How long a server may keep answering "unavailable" without taking data before the upload fails: 24 hours. */
const val MAX_UNAVAILABLE_MILLIS = 24L * 60 * 60 * 1000

/**
* "Unavailable" answers the server must have given, besides the 24 hours, before the upload fails. Time without
* a network passes too; a few answers after a long wait do not show a server that keeps refusing.
*/
const val MIN_UNAVAILABLE_ANSWERS = 10

private const val MAX_CAUSE_DEPTH = 8
private const val HTTP_REQUEST_TIMEOUT = 408
private const val HTTP_TOO_MANY_REQUESTS = 429
private const val HTTP_SERVER_ERROR = 500
private const val HTTP_NOT_IMPLEMENTED = 501
private const val HTTP_VERSION_NOT_SUPPORTED = 505
private const val HTTP_INSUFFICIENT_STORAGE = 507

// A server that cannot do the request or has a full quota does not change within the few attempts.
private val PERMANENT_SERVER_ERRORS =
setOf(HTTP_NOT_IMPLEMENTED, HTTP_VERSION_NOT_SUPPORTED, HTTP_INSUFFICIENT_STORAGE)

/**
* NETWORK: no connection, retried without a limit.
* UNAVAILABLE: the server answered that it is overloaded or restarting (5xx except 501, 505 and 507, 408,
* 429), retried until [MAX_UNAVAILABLE_MILLIS] pass and [MIN_UNAVAILABLE_ANSWERS] answers came without
* progress of the upload.
* SERVER: an answer or a TLS problem that may not go away, retried up to [MAX_SERVER_ERRORS] times.
* OTHER: a local problem, fails at once.
*/
enum class FailureKind { NETWORK, UNAVAILABLE, SERVER, OTHER }

enum class Decision { RETRY, FAIL }

/**
* Looks through the whole chain of causes, because RxJava wraps a checked [IOException] in a RuntimeException.
* An HTTP error wins over everything else. Errors of an overloaded or restarting server (5xx except 501, 505
* and 507, 408, 429) are UNAVAILABLE: the upload waits and goes on, but not for ever. Other HTTP errors and
* certificate problems count against [MAX_SERVER_ERRORS]. Any other [SSLException] is a network error: on
* Android a connection that drops in the middle of an HTTPS transfer arrives as an SSLException. A
* [FileNotFoundException] anywhere in the chain is OTHER: the prepared file cannot be read.
*/
fun classify(error: Throwable): FailureKind {
val causes = causeChain(error)
val httpCode = causes.firstNotNullOfOrNull(::httpCodeOf)
return when {
httpCode != null -> if (isTransientHttpCode(httpCode)) FailureKind.UNAVAILABLE else FailureKind.SERVER
isCertificateProblem(causes) -> FailureKind.SERVER
// ChunkFromFileRequestBody wraps every error of reading a chunk in a FileNotFoundException, also an
// IOException of the disk. The file is in noBackupFilesDir: waiting does not repair it.
causes.any { it is FileNotFoundException } -> FailureKind.OTHER
causes.any { it is IOException } -> FailureKind.NETWORK
else -> FailureKind.OTHER
}
}

private fun causeChain(error: Throwable): List<Throwable> =
generateSequence(error) { it.cause }.take(MAX_CAUSE_DEPTH).toList()

private fun isCertificateProblem(causes: List<Throwable>): Boolean =
causes.any { it is SSLPeerUnverifiedException || it is UnknownServiceException } ||
(
causes.any { it is SSLHandshakeException } &&
causes.any { it is CertificateException || it is CertPathValidatorException }
)

/**
* Like [classify], for an error while the file is prepared (copy, compression). A full or read-only disk and
* a file that cannot be read do not go away by waiting, so they are OTHER; any other IOException, e.g. a lost
* network while a cloud provider streams the file, is waited for.
*/
fun classifyPreparation(error: Throwable): FailureKind =
if (causeChain(error).any(::isUnrecoverableLocal)) FailureKind.OTHER else classify(error)

private fun isUnrecoverableLocal(error: Throwable): Boolean {
val errno = (error as? ErrnoException)?.errno
return error is FileNotFoundException || errno == OsConstants.ENOSPC || errno == OsConstants.EROFS
}

private fun httpCodeOf(error: Throwable): Int? =
when (error) {
is DavHttpException -> error.code
is RetrofitHttpException -> error.code()
else -> null
}

private fun isTransientHttpCode(code: Int): Boolean =
(code >= HTTP_SERVER_ERROR && code !in PERMANENT_SERVER_ERRORS) ||
code == HTTP_REQUEST_TIMEOUT ||
code == HTTP_TOO_MANY_REQUESTS

/**
* @param serverErrorCount server errors of this upload so far, including the one being decided on
* @param unavailableForMillis how long the server has been answering "unavailable" without the upload moving on
* @param unavailableAnswers how many such answers came in that time, including the one being decided on
*/
fun decide(
kind: FailureKind,
serverErrorCount: Int,
unavailableForMillis: Long,
unavailableAnswers: Int
): Decision =
when {
kind == FailureKind.NETWORK -> Decision.RETRY
kind == FailureKind.UNAVAILABLE &&
(unavailableForMillis < MAX_UNAVAILABLE_MILLIS || unavailableAnswers < MIN_UNAVAILABLE_ANSWERS) ->
Decision.RETRY
kind == FailureKind.SERVER && serverErrorCount < MAX_SERVER_ERRORS -> Decision.RETRY
else -> Decision.FAIL
}
}
Loading
Loading