diff --git a/README.md b/README.md index be3ffaa..ce5c9b2 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,21 @@ server-side before it is accepted. the server is detected by a `HEAD` before any bytes are sent; the item is marked *Duplicate · already on server* and nothing is re-uploaded or overwritten. Different content is a different hash, i.e. a different file — - not a duplicate. + not a duplicate. The queue dedupes the same way: content whose hash is + already pending or uploading is refused at enqueue time ("already in the + queue"), so adding the same file twice — even via two different content + URIs — never uploads it twice in parallel. +- **Cancel keeps the original.** A pending or in-flight upload can be + cancelled from its queue row; every cancel asks for confirmation so it can't + happen by accident. Cancelling aborts the transfer and discards the working + copy, but never deletes the source file — only a confirmed upload does that. + A cancel that races a finishing upload loses gracefully: if it lands first, + the source is kept even though the bytes reached the server; if the upload + finished first, the app says so and nothing is flipped. - **Entry points.** Share sheet (`ACTION_SEND`), "Open with" on a downloaded file (`ACTION_VIEW`), and an in-app file picker. "Clear history" (app-bar - button) drops finished, duplicate and failed rows; in-flight uploads keep - running. + button) drops finished, duplicate, failed and cancelled rows; in-flight + uploads keep running. ## Parts diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ce55c21..23ea102 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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. diff --git a/android/app/src/main/java/eu/itaz/internaluploader/MainActivity.kt b/android/app/src/main/java/eu/itaz/internaluploader/MainActivity.kt index 5cfb49d..6ea53b8 100644 --- a/android/app/src/main/java/eu/itaz/internaluploader/MainActivity.kt +++ b/android/app/src/main/java/eu/itaz/internaluploader/MainActivity.kt @@ -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() diff --git a/android/app/src/main/java/eu/itaz/internaluploader/data/Db.kt b/android/app/src/main/java/eu/itaz/internaluploader/data/Db.kt index 2d58c8c..5665ec9 100644 --- a/android/app/src/main/java/eu/itaz/internaluploader/data/Db.kt +++ b/android/app/src/main/java/eu/itaz/internaluploader/data/Db.kt @@ -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 + /** + * 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() } diff --git a/android/app/src/main/java/eu/itaz/internaluploader/ui/QueueAdapter.kt b/android/app/src/main/java/eu/itaz/internaluploader/ui/QueueAdapter.kt index 1f0167f..fd47c91 100644 --- a/android/app/src/main/java/eu/itaz/internaluploader/ui/QueueAdapter.kt +++ b/android/app/src/main/java/eu/itaz/internaluploader/ui/QueueAdapter.kt @@ -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(Diff) { +class QueueAdapter( + private val onCancel: (UploadItem) -> Unit, +) : ListAdapter(Diff) { class Holder(val binding: ItemUploadBinding) : RecyclerView.ViewHolder(binding.root) @@ -43,10 +45,15 @@ class QueueAdapter : ListAdapter(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() { diff --git a/android/app/src/main/java/eu/itaz/internaluploader/upload/Enqueuer.kt b/android/app/src/main/java/eu/itaz/internaluploader/upload/Enqueuer.kt index 9cdf912..101c9cc 100644 --- a/android/app/src/main/java/eu/itaz/internaluploader/upload/Enqueuer.kt +++ b/android/app/src/main/java/eu/itaz/internaluploader/upload/Enqueuer.kt @@ -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. */ diff --git a/android/app/src/main/java/eu/itaz/internaluploader/upload/UploadWorker.kt b/android/app/src/main/java/eu/itaz/internaluploader/upload/UploadWorker.kt index c60afad..c54f621 100644 --- a/android/app/src/main/java/eu/itaz/internaluploader/upload/UploadWorker.kt +++ b/android/app/src/main/java/eu/itaz/internaluploader/upload/UploadWorker.kt @@ -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() } diff --git a/android/app/src/main/res/drawable/ic_close.xml b/android/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..b018863 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/layout/item_upload.xml b/android/app/src/main/res/layout/item_upload.xml index 4282ffb..a86b059 100644 --- a/android/app/src/main/res/layout/item_upload.xml +++ b/android/app/src/main/res/layout/item_upload.xml @@ -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" /> + + + Duplicate · removed from phone Failed: %1$s unknown error + Cancelled · file kept on phone + + Cancel upload + Cancel this upload? + %1$s will not be uploaded. The file stays on your phone. + Cancel upload + Keep uploading + Already finished — nothing to cancel + %1$s is already in the queue (same content) Uploading