Skip to content

Commit 6144129

Browse files
committed
fix: cancelling an upload could still share the file
Cancel relied solely on WorkManager's isStopped, but cancelUniqueWork() dispatches asynchronously through WorkManager's own executor and Room DB - a small/compressed image could finish uploading and get shared to the room before that cancellation ever became visible to doWork(). Track cancelled referenceIds in an in-memory set instead, set synchronously by the UI before cancelUniqueWork() is called, so the worker can see it immediately. Also close a second gap: once the upload succeeded, the local placeholder stayed flagged as temporary indefinitely, so a "too late" cancel could still delete it locally - making an already-sent message disappear from the UI until the next sync brought it back. Guard the placeholder deletion to only fire while the message is still PENDING. Assisted-by: Claude:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
1 parent 1dba22a commit 6144129

6 files changed

Lines changed: 110 additions & 11 deletions

File tree

app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,12 @@ interface ChatMessageRepository : LifecycleAwareManager {
160160
referenceId: String
161161
): Flow<Result<ChatMessage?>>
162162

163-
suspend fun deleteTempMessageByReferenceId(referenceId: String)
163+
/**
164+
* Deletes the local upload placeholder for [referenceId], but only while it's still PENDING.
165+
* Returns false without deleting anything if the upload already finished (and was shared) by
166+
* the time this is called, so a late cancel can't hide an already-sent message.
167+
*/
168+
suspend fun deleteTempMessageByReferenceId(referenceId: String): Boolean
164169

165170
suspend fun editChatMessage(credentials: String, url: String, text: String): Flow<Result<ChatOverallSingleMessage>>
166171

app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -755,9 +755,8 @@ class OfflineFirstChatRepository @Inject constructor(
755755
}
756756
}
757757

758-
override suspend fun deleteTempMessageByReferenceId(referenceId: String) {
759-
chatDao.deleteTempChatMessages(internalConversationId, listOf(referenceId))
760-
}
758+
override suspend fun deleteTempMessageByReferenceId(referenceId: String): Boolean =
759+
chatDao.deleteTempChatMessageIfPending(internalConversationId, referenceId) > 0
761760

762761
@Suppress("Detekt.TooGenericExceptionCaught")
763762
override suspend fun editChatMessage(

app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ class ChatViewModel @AssistedInject constructor(
235235

236236
fun cancelUpload(referenceId: String) {
237237
val fileUri = uploadReferenceToUri.remove(referenceId) ?: return
238-
WorkManager.getInstance(NextcloudTalkApplication.sharedApplication!!).cancelUniqueWork(fileUri)
238+
UploadAndShareFilesWorker.cancelUpload(referenceId, fileUri)
239239
viewModelScope.launch {
240240
chatRepository.deleteTempMessageByReferenceId(referenceId)
241241
}

app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,23 @@ interface ChatMessagesDao {
177177
)
178178
fun deleteTempChatMessages(internalConversationId: String, referenceIds: List<String>)
179179

180+
/*
181+
* Used when the user cancels an upload - unlike deleteTempChatMessages, this must NOT delete
182+
* the placeholder once sendStatus has already moved past PENDING (i.e. the upload already
183+
* finished and was shared), since by then the message has actually been sent and deleting the
184+
* local placeholder would only hide it until the next sync brings it back.
185+
*/
186+
@Query(
187+
value = """
188+
DELETE FROM ChatMessages
189+
WHERE internalConversationId = :internalConversationId
190+
AND referenceId = :referenceId
191+
AND isTemporary = 1
192+
AND sendStatus = 'PENDING'
193+
"""
194+
)
195+
fun deleteTempChatMessageIfPending(internalConversationId: String, referenceId: String): Int
196+
180197
@Update
181198
fun updateChatMessage(message: ChatMessageEntity)
182199

app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ import com.nextcloud.talk.utils.VideoCompressor
5353
import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld
5454
import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil
5555
import com.nextcloud.talk.utils.preferences.AppPreferences
56+
import io.reactivex.Observable
57+
import io.reactivex.disposables.Disposable
5658
import kotlinx.coroutines.ExperimentalCoroutinesApi
5759
import kotlinx.coroutines.flow.MutableSharedFlow
5860
import kotlinx.coroutines.flow.SharedFlow
@@ -62,7 +64,10 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
6264
import okhttp3.OkHttpClient
6365
import java.io.File
6466
import java.io.IOException
67+
import java.util.Collections
6568
import java.util.UUID
69+
import java.util.concurrent.ConcurrentHashMap
70+
import java.util.concurrent.CountDownLatch
6671
import java.util.concurrent.TimeUnit
6772
import javax.inject.Inject
6873

@@ -107,11 +112,35 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
107112
private var chunkedFileUploader: ChunkedFileUploader? = null
108113
private var referenceId: String? = null
109114
private var internalConversationId: String? = null
115+
private var uploadDisposable: Disposable? = null
116+
private var uploadLatch: CountDownLatch? = null
117+
118+
/**
119+
* WorkManager's own `isStopped`/`onStopped()` only becomes true/fires after
120+
* cancelUniqueWork() round-trips through WorkManager's internal executor and Room DB - for a
121+
* small/compressed image the whole upload+share can finish faster than that round-trip, so
122+
* isStopped alone arrives too late. [cancelledReferenceIds] is set synchronously by the UI
123+
* before cancelUniqueWork() is even called, so it's visible to doWork() immediately.
124+
*/
125+
private fun isCancelled(): Boolean = referenceId?.let { cancelledReferenceIds.contains(it) } == true
110126

111-
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
112127
override fun doWork(): Result {
113128
NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
114129

130+
try {
131+
return doUpload()
132+
} finally {
133+
referenceId?.let { cancelledReferenceIds.remove(it) }
134+
}
135+
}
136+
137+
@Suppress(
138+
"Detekt.TooGenericExceptionCaught",
139+
"Detekt.LongMethod",
140+
"Detekt.CyclomaticComplexMethod",
141+
"Detekt.ReturnCount"
142+
)
143+
private fun doUpload(): Result {
115144
return try {
116145
currentUser = currentUserProvider.currentUser.blockingGet()
117146
val sourceFile = inputData.getString(DEVICE_SOURCE_FILE)
@@ -149,6 +178,11 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
149178
useConversationSubfolders = useConversationSubfolders
150179
)
151180

181+
if (uploadSuccess && (isStopped || isCancelled())) {
182+
// Cancelled right as the upload finished - don't share a cancelled upload.
183+
return Result.failure()
184+
}
185+
152186
if (uploadSuccess) {
153187
// useConversationSubfolders already shares as part of uploadFile() via
154188
// postConversationAttachment, so only share explicitly for the plain upload path.
@@ -160,7 +194,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
160194
}
161195
Log.e(TAG, "Share operation failed after upload")
162196
return failUpload()
163-
} else if (isStopped) {
197+
} else if (isStopped || isCancelled()) {
164198
// since work is cancelled the result would be ignored anyways
165199
return Result.failure()
166200
}
@@ -176,7 +210,9 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
176210
"Network error while uploading file (attempt ${runAttemptCount + 1}/$MAX_UPLOAD_ATTEMPTS)",
177211
e
178212
)
179-
if (runAttemptCount < MAX_UPLOAD_ATTEMPTS - 1) {
213+
if (isStopped || isCancelled()) {
214+
Result.failure()
215+
} else if (runAttemptCount < MAX_UPLOAD_ATTEMPTS - 1) {
180216
Result.retry()
181217
} else {
182218
failUpload()
@@ -235,7 +271,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
235271
chunkedFileUploader!!.upload(file!!, mimeType, remotePath)
236272
} else {
237273
Log.d(TAG, "starting normal upload (not chunked) of $fileName")
238-
FileUploader(
274+
val observable = FileUploader(
239275
okHttpClient,
240276
context,
241277
currentUser,
@@ -245,9 +281,37 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
245281
ncApiCoroutines
246282
)
247283
.upload(sourceFileUri, fileName, remotePath, null)
248-
.blockingFirst()
284+
blockingUpload(observable)
249285
}
250286

287+
// unlike .blockingFirst(), keeps a Disposable so onStopped() can cancel the underlying OkHttp call
288+
private fun blockingUpload(observable: Observable<Boolean>): Boolean {
289+
val latch = CountDownLatch(1)
290+
uploadLatch = latch
291+
var result = false
292+
var error: Throwable? = null
293+
uploadDisposable = observable.subscribe(
294+
{ success ->
295+
result = success
296+
latch.countDown()
297+
},
298+
{ throwable ->
299+
error = throwable
300+
latch.countDown()
301+
},
302+
{ latch.countDown() }
303+
)
304+
latch.await()
305+
uploadDisposable = null
306+
uploadLatch = null
307+
308+
if (isStopped || isCancelled()) {
309+
return false
310+
}
311+
error?.let { throw it }
312+
return result
313+
}
314+
251315
private fun uploadUsingConversationSubfolders(sourceFileUri: Uri, metaData: String?): Boolean =
252316
runBlocking {
253317
val credentials = ApiUtils.getCredentials(
@@ -287,7 +351,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
287351
.uploadToConversationSubfolder(sourceFileUri, tempRemotePath)
288352
}
289353

290-
if (!uploadSuccess) {
354+
if (!uploadSuccess || isStopped || isCancelled()) {
291355
return@runBlocking false
292356
}
293357

@@ -352,6 +416,8 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
352416
if (file != null && isChunkedUploading) {
353417
chunkedFileUploader?.abortUpload {}
354418
}
419+
uploadDisposable?.dispose()
420+
uploadLatch?.countDown()
355421
super.onStopped()
356422
}
357423

@@ -399,6 +465,11 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
399465
private const val MAX_UPLOAD_ATTEMPTS = 4
400466
const val REQUEST_PERMISSION = 3123
401467

468+
// referenceIds the user cancelled - set synchronously here, before cancelUniqueWork() is
469+
// even called, so doWork() can see it immediately instead of waiting for isStopped, which
470+
// only becomes true once WorkManager's own async cancellation dispatch completes.
471+
private val cancelledReferenceIds: MutableSet<String> = Collections.newSetFromMap(ConcurrentHashMap())
472+
402473
private val _uploadCompletedFlow: MutableSharedFlow<String> = MutableSharedFlow(
403474
replay = 1,
404475
extraBufferCapacity = 1
@@ -479,5 +550,10 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
479550
WorkManager.getInstance().enqueueUniqueWork(fileUri, ExistingWorkPolicy.KEEP, uploadWorker)
480551
return uploadWorker.id
481552
}
553+
554+
fun cancelUpload(referenceId: String, fileUri: String) {
555+
cancelledReferenceIds.add(referenceId)
556+
WorkManager.getInstance().cancelUniqueWork(fileUri)
557+
}
482558
}
483559
}

app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ class DummyChatMessagesDaoImpl : ChatMessagesDao {
8484
/* */
8585
}
8686

87+
override fun deleteTempChatMessageIfPending(internalConversationId: String, referenceId: String): Int = 0
88+
8789
override fun updateChatMessage(message: ChatMessageEntity) {
8890
/* */
8991
}

0 commit comments

Comments
 (0)