app v1.2.0: cancel with confirmation, queue dedupe by content hash

- Pending/uploading rows get a cancel button; every cancel is confirmed
  in a dialog and the source file is always kept — only a confirmed
  upload deletes originals. The in-flight HTTP transfer aborts
  immediately (async OkHttp call instead of blocking execute()).
- Enqueue refuses content whose SHA-256 is already pending/uploading,
  so sharing or picking the same file twice no longer uploads it twice
  in parallel (content URIs are opaque; the hash is the identity).
- Cancel/finish races resolved with conditional DB updates: a cancel
  landing mid-flight keeps the source; a cancel after completion is
  refused with a toast instead of flipping a finished row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-10-02 00:29:42 +01:00
parent 81d5f33acc
commit ec2a332c88
10 changed files with 243 additions and 33 deletions

View File

@@ -22,8 +22,8 @@ android {
applicationId = "eu.itaz.internaluploader"
minSdk = 26
targetSdk = 35
versionCode = 3
versionName = "1.1.1"
versionCode = 4
versionName = "1.2.0"
// The one place the server address exists. Never rendered in the UI;
// a pairing QR can carry a different origin and overrides this.

View File

@@ -18,10 +18,12 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import eu.itaz.internaluploader.data.AppDb
import eu.itaz.internaluploader.data.Status
import eu.itaz.internaluploader.data.UploadItem
import eu.itaz.internaluploader.databinding.ActivityMainBinding
import eu.itaz.internaluploader.pairing.PairApi
import eu.itaz.internaluploader.pairing.PairPayload
@@ -35,7 +37,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var store: PairingStore
private val adapter = QueueAdapter()
private val adapter = QueueAdapter(onCancel = ::confirmCancel)
private val scanQr = registerForActivityResult(ScanContract()) { result ->
val payload = result.contents?.let(PairPayload.Companion::fromQr)
@@ -149,8 +151,8 @@ class MainActivity : AppCompatActivity() {
}
}
// A share intent is consumed once; re-delivery on rotation would
// double-queue (dedupe by hash makes that harmless server-side, but
// the queue UI would still show it twice).
// double-queue (the hash dedupe in Enqueuer refuses the copy, but the
// user would still see a spurious "already in the queue" toast).
intent.action = null
}
@@ -205,7 +207,14 @@ class MainActivity : AppCompatActivity() {
}
lifecycleScope.launch {
try {
Enqueuer.enqueue(this@MainActivity, uri)
val result = Enqueuer.enqueue(this@MainActivity, uri)
if (result is Enqueuer.EnqueueResult.AlreadyQueued) {
Toast.makeText(
this@MainActivity,
getString(R.string.already_queued, result.existing.originalName),
Toast.LENGTH_LONG,
).show()
}
} catch (e: Exception) {
Toast.makeText(
this@MainActivity,
@@ -216,6 +225,26 @@ class MainActivity : AppCompatActivity() {
}
}
/**
* Cancelling is confirmed every time — there is no undo for an aborted
* transfer. The original file is never deleted on cancel; only a
* confirmed upload removes sources.
*/
private fun confirmCancel(item: UploadItem) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.cancel_confirm_title)
.setMessage(getString(R.string.cancel_confirm_message, item.originalName))
.setPositiveButton(R.string.cancel_confirm_yes) { _, _ ->
lifecycleScope.launch {
if (!Enqueuer.cancel(this@MainActivity, item.id)) {
toast(R.string.cancel_too_late)
}
}
}
.setNegativeButton(R.string.cancel_confirm_no, null)
.show()
}
private fun startScan() {
scanQr.launch(
ScanOptions()

View File

@@ -18,6 +18,8 @@ object Status {
/** Same content was already on the server; nothing was re-sent or rewritten. */
const val DUPLICATE = "duplicate"
const val FAILED = "failed"
/** User cancelled before completion; the source file is always kept. */
const val CANCELLED = "cancelled"
}
@Entity(tableName = "uploads")
@@ -61,14 +63,56 @@ interface UploadDao {
@Query("SELECT * FROM uploads WHERE status = :status")
suspend fun byStatus(status: String): List<UploadItem>
/**
* An item with the same content hash that is still in flight. Content
* identity is the SHA-256 baked into remoteName, so this is how "the same
* file was already added" is detected regardless of which URI it came by.
*/
@Query("SELECT * FROM uploads WHERE remoteName = :remoteName AND status IN ('pending', 'uploading') LIMIT 1")
suspend fun activeByRemoteName(remoteName: String): UploadItem?
@Query("UPDATE uploads SET status = :status, progress = :progress, error = :error WHERE id = :id")
suspend fun setStatus(id: Long, status: String, progress: Int = 0, error: String? = null)
/** Progress tick that cannot resurrect an item cancelled mid-flight. */
@Query("UPDATE uploads SET progress = :progress WHERE id = :id AND status = 'uploading'")
suspend fun setProgress(id: Long, progress: Int)
/**
* Cancel, but only while there is still something to cancel. Returns 0
* when the upload already reached a terminal state — the caller must then
* treat the cancel as too late rather than flip a finished row.
*/
@Query("UPDATE uploads SET status = 'cancelled', progress = 0, error = NULL WHERE id = :id AND status IN ('pending', 'uploading')")
suspend fun markCancelled(id: Long): Int
/**
* Record a confirmed upload unless the user cancelled while the bytes were
* in flight. Returns 0 on that race; the worker must then keep the source.
*/
@Query("UPDATE uploads SET status = :status, progress = 100, error = NULL WHERE id = :id AND status != 'cancelled'")
suspend fun markUploaded(id: Long, status: String): Int
/**
* Pending → uploading, refused for a row cancelled in the meantime.
* Returns 0 when there was nothing eligible to start.
*/
@Query("UPDATE uploads SET status = 'uploading', error = NULL WHERE id = :id AND status IN ('pending', 'uploading')")
suspend fun markUploading(id: Long): Int
/** Put an in-flight item back to pending for retry — unless it was cancelled. */
@Query("UPDATE uploads SET status = 'pending', progress = 0, error = :error WHERE id = :id AND status = 'uploading'")
suspend fun markRetry(id: Long, error: String?)
/** Record a permanent failure — unless the user already cancelled. */
@Query("UPDATE uploads SET status = 'failed', progress = 0, error = :error WHERE id = :id AND status != 'cancelled'")
suspend fun markFailed(id: Long, error: String?)
@Query("UPDATE uploads SET sourceDeleted = :deleted WHERE id = :id")
suspend fun setSourceDeleted(id: Long, deleted: Boolean)
/** Clear upload history: everything that has finished, one way or another. */
@Query("DELETE FROM uploads WHERE status IN ('done', 'duplicate', 'failed')")
@Query("DELETE FROM uploads WHERE status IN ('done', 'duplicate', 'failed', 'cancelled')")
suspend fun deleteFinished()
}

View File

@@ -12,7 +12,9 @@ import eu.itaz.internaluploader.data.Status
import eu.itaz.internaluploader.data.UploadItem
import eu.itaz.internaluploader.databinding.ItemUploadBinding
class QueueAdapter : ListAdapter<UploadItem, QueueAdapter.Holder>(Diff) {
class QueueAdapter(
private val onCancel: (UploadItem) -> Unit,
) : ListAdapter<UploadItem, QueueAdapter.Holder>(Diff) {
class Holder(val binding: ItemUploadBinding) : RecyclerView.ViewHolder(binding.root)
@@ -43,10 +45,15 @@ class QueueAdapter : ListAdapter<UploadItem, QueueAdapter.Holder>(Diff) {
R.string.status_failed,
item.error ?: context.getString(R.string.status_failed_unknown),
)
Status.CANCELLED -> context.getString(R.string.status_cancelled)
else -> item.status
}
holder.binding.check.visibility =
if (item.status == Status.DONE || item.status == Status.DUPLICATE) View.VISIBLE else View.GONE
val cancellable = item.status == Status.PENDING || item.status == Status.UPLOADING
holder.binding.cancel.visibility = if (cancellable) View.VISIBLE else View.GONE
holder.binding.cancel.setOnClickListener { if (cancellable) onCancel(item) }
}
private object Diff : DiffUtil.ItemCallback<UploadItem>() {

View File

@@ -10,10 +10,13 @@ import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import android.content.Intent
import eu.itaz.internaluploader.data.AppDb
import eu.itaz.internaluploader.data.Status
import eu.itaz.internaluploader.data.UploadItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import java.security.MessageDigest
@@ -22,12 +25,28 @@ import java.util.concurrent.TimeUnit
object Enqueuer {
sealed interface EnqueueResult {
data class Queued(val item: UploadItem) : EnqueueResult
/**
* Content with the same SHA-256 is already pending or uploading, so a
* second queue entry would only send the same bytes twice. The share
* sheet hands us opaque content URIs (no stable path — the same file
* can arrive under different URIs), so the hash computed while copying
* is the only reliable identity.
*/
data class AlreadyQueued(val existing: UploadItem) : EnqueueResult
}
/** Serializes the dedupe-check + insert so two shares of the same file can't race past each other. */
private val insertLock = Mutex()
/**
* Copy the shared content into app-private storage while hashing it, so a
* retry never depends on the source URI still being readable, then hand
* the file to WorkManager.
*/
suspend fun enqueue(context: Context, uri: Uri): UploadItem = withContext(Dispatchers.IO) {
suspend fun enqueue(context: Context, uri: Uri): EnqueueResult = withContext(Dispatchers.IO) {
val originalName = displayNameOf(context, uri) ?: "unnamed"
val queueDir = File(context.filesDir, "queue").apply { mkdirs() }
@@ -55,16 +74,51 @@ object Enqueuer {
val remoteName = if (ext != null) "$hash.$ext" else hash
val db = AppDb.get(context)
val item = UploadItem(
originalName = originalName,
remoteName = remoteName,
cachePath = cacheFile.absolutePath,
size = size,
sourceUri = uri.toString(),
)
val id = db.uploads().insert(item)
schedule(context, id)
item.copy(id = id)
insertLock.withLock {
val existing = db.uploads().activeByRemoteName(remoteName)
if (existing != null) {
cacheFile.delete()
return@withContext EnqueueResult.AlreadyQueued(existing)
}
val item = UploadItem(
originalName = originalName,
remoteName = remoteName,
cachePath = cacheFile.absolutePath,
size = size,
sourceUri = uri.toString(),
)
val id = db.uploads().insert(item)
schedule(context, id)
EnqueueResult.Queued(item.copy(id = id))
}
}
/**
* Cancel one queued or in-flight upload. The source file is always kept —
* only a confirmed upload may ever delete the original. Returns false when
* the upload already reached a terminal state before the cancel landed.
*/
suspend fun cancel(context: Context, itemId: Long): Boolean = withContext(Dispatchers.IO) {
val db = AppDb.get(context)
// Flip the status first: a worker that finishes its PUT concurrently
// checks it before touching the source file.
if (db.uploads().markCancelled(itemId) == 0) return@withContext false
WorkManager.getInstance(context).cancelUniqueWork("upload-$itemId")
val item = db.uploads().byId(itemId)
if (item != null) {
File(item.cachePath).delete()
// The original stays on the device, so the persisted write grant
// taken for post-upload deletion is no longer needed.
item.sourceUri?.let { uriString ->
runCatching {
context.contentResolver.releasePersistableUriPermission(
Uri.parse(uriString),
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
}
}
true
}
/** (Re-)schedule the worker for one queue item. */

View File

@@ -18,11 +18,16 @@ import eu.itaz.internaluploader.data.AppDb
import eu.itaz.internaluploader.data.Status
import eu.itaz.internaluploader.data.UploadItem
import eu.itaz.internaluploader.pairing.PairingStore
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resumeWithException
class UploadWorker(context: Context, params: WorkerParameters) :
CoroutineWorker(context, params) {
@@ -32,7 +37,12 @@ class UploadWorker(context: Context, params: WorkerParameters) :
override suspend fun doWork(): Result {
val itemId = inputData.getLong(KEY_ITEM_ID, -1)
val item = db.uploads().byId(itemId) ?: return Result.failure()
if (item.status == Status.DONE) return Result.success()
if (item.status == Status.DONE ||
item.status == Status.DUPLICATE ||
item.status == Status.CANCELLED
) {
return Result.success()
}
val paired = PairingStore(applicationContext).current()
?: return permanentFailure(item, applicationContext.getString(R.string.error_not_paired))
@@ -48,7 +58,8 @@ class UploadWorker(context: Context, params: WorkerParameters) :
// itself still proceeds, just without a notification.
runCatching { setForeground(foregroundInfo(item, 0)) }
db.uploads().setStatus(item.id, Status.UPLOADING)
// Refused when a cancel slipped in since the byId read above.
if (db.uploads().markUploading(item.id) == 0) return Result.success()
return try {
when (val outcome = upload(paired.serverUrl, paired.token, item, file)) {
@@ -59,9 +70,11 @@ class UploadWorker(context: Context, params: WorkerParameters) :
// content was already there and was NOT rewritten. Either
// way it is safe to remove our working copy and the source.
val status = if (outcome == UploadOutcome.DUPLICATE) Status.DUPLICATE else Status.DONE
db.uploads().setStatus(item.id, status, progress = 100)
val recorded = db.uploads().markUploaded(item.id, status)
file.delete()
deleteSource(item)
// A cancel that landed while the bytes were in flight wins:
// the user was promised the original stays on the device.
if (recorded > 0) deleteSource(item)
Result.success()
}
UploadOutcome.UNAUTHORIZED ->
@@ -69,12 +82,12 @@ class UploadWorker(context: Context, params: WorkerParameters) :
UploadOutcome.REJECTED ->
permanentFailure(item, applicationContext.getString(R.string.error_rejected))
is UploadOutcome.TRANSIENT -> {
db.uploads().setStatus(item.id, Status.PENDING, error = outcome.message)
db.uploads().markRetry(item.id, outcome.message)
Result.retry()
}
}
} catch (e: IOException) {
db.uploads().setStatus(item.id, Status.PENDING, error = e.message)
db.uploads().markRetry(item.id, e.message)
Result.retry()
}
}
@@ -100,7 +113,7 @@ class UploadWorker(context: Context, params: WorkerParameters) :
.head()
.header("Authorization", "Bearer $token")
.build()
client.newCall(head).execute().use { response ->
client.newCall(head).await().use { response ->
when (response.code) {
204 -> return UploadOutcome.DUPLICATE // already present; skip the bytes
401 -> return UploadOutcome.UNAUTHORIZED
@@ -118,7 +131,7 @@ class UploadWorker(context: Context, params: WorkerParameters) :
// Room write per callback would thrash; CountingFileRequestBody
// already throttles callbacks to ~1/s.
kotlinx.coroutines.runBlocking {
db.uploads().setStatus(item.id, Status.UPLOADING, progress = percent)
db.uploads().setProgress(item.id, percent)
}
}
val put = Request.Builder()
@@ -126,7 +139,7 @@ class UploadWorker(context: Context, params: WorkerParameters) :
.put(body)
.header("Authorization", "Bearer $token")
.build()
client.newCall(put).execute().use { response ->
client.newCall(put).await().use { response ->
return when (response.code) {
201 -> UploadOutcome.DONE // created
200 -> UploadOutcome.DUPLICATE // server already had identical content
@@ -165,8 +178,26 @@ class UploadWorker(context: Context, params: WorkerParameters) :
}
}
/**
* Suspend on an OkHttp call so a WorkManager cancel (user pressed cancel)
* aborts the transfer immediately; a blocking execute() would keep sending
* bytes until the socket drained.
*/
private suspend fun Call.await(): Response = suspendCancellableCoroutine { cont ->
enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
cont.resume(response) { _, resp, _ -> resp.close() }
}
override fun onFailure(call: Call, e: IOException) {
if (!cont.isCancelled) cont.resumeWithException(e)
}
})
cont.invokeOnCancellation { cancel() }
}
private suspend fun permanentFailure(item: UploadItem, message: String): Result {
db.uploads().setStatus(item.id, Status.FAILED, error = message)
db.uploads().markFailed(item.id, message)
return Result.failure()
}

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
</vector>

View File

@@ -14,10 +14,13 @@
android:ellipsize="middle"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodyLarge"
app:layout_constraintEnd_toStartOf="@id/check"
app:layout_constraintEnd_toStartOf="@id/cancel"
app:layout_goneMarginEnd="32dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- Occupies the same end slot as @id/cancel; the two are never visible
together (check = finished, cancel = still cancellable). -->
<ImageView
android:id="@+id/check"
android:layout_width="24dp"
@@ -30,6 +33,20 @@
app:layout_constraintTop_toTopOf="@id/name"
app:tint="?attr/colorPrimary" />
<ImageButton
android:id="@+id/cancel"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/cancel_upload"
android:padding="8dp"
android:src="@drawable/ic_close"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/name"
app:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/status"
android:layout_width="0dp"

View File

@@ -25,6 +25,15 @@
<string name="status_duplicate_removed">Duplicate · removed from phone</string>
<string name="status_failed">Failed: %1$s</string>
<string name="status_failed_unknown">unknown error</string>
<string name="status_cancelled">Cancelled · file kept on phone</string>
<string name="cancel_upload">Cancel upload</string>
<string name="cancel_confirm_title">Cancel this upload?</string>
<string name="cancel_confirm_message">%1$s will not be uploaded. The file stays on your phone.</string>
<string name="cancel_confirm_yes">Cancel upload</string>
<string name="cancel_confirm_no">Keep uploading</string>
<string name="cancel_too_late">Already finished — nothing to cancel</string>
<string name="already_queued">%1$s is already in the queue (same content)</string>
<string name="notification_uploading">Uploading</string>