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

@@ -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 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 marked *Duplicate · already on server* and nothing is re-uploaded or
overwritten. Different content is a different hash, i.e. a different file — 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 - **Entry points.** Share sheet (`ACTION_SEND`), "Open with" on a downloaded
file (`ACTION_VIEW`), and an in-app file picker. "Clear history" (app-bar file (`ACTION_VIEW`), and an in-app file picker. "Clear history" (app-bar
button) drops finished, duplicate and failed rows; in-flight uploads keep button) drops finished, duplicate, failed and cancelled rows; in-flight
running. uploads keep running.
## Parts ## Parts

View File

@@ -22,8 +22,8 @@ android {
applicationId = "eu.itaz.internaluploader" applicationId = "eu.itaz.internaluploader"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 3 versionCode = 4
versionName = "1.1.1" versionName = "1.2.0"
// The one place the server address exists. Never rendered in the UI; // The one place the server address exists. Never rendered in the UI;
// a pairing QR can carry a different origin and overrides this. // 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.core.view.updatePadding
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions import com.journeyapps.barcodescanner.ScanOptions
import eu.itaz.internaluploader.data.AppDb import eu.itaz.internaluploader.data.AppDb
import eu.itaz.internaluploader.data.Status import eu.itaz.internaluploader.data.Status
import eu.itaz.internaluploader.data.UploadItem
import eu.itaz.internaluploader.databinding.ActivityMainBinding import eu.itaz.internaluploader.databinding.ActivityMainBinding
import eu.itaz.internaluploader.pairing.PairApi import eu.itaz.internaluploader.pairing.PairApi
import eu.itaz.internaluploader.pairing.PairPayload import eu.itaz.internaluploader.pairing.PairPayload
@@ -35,7 +37,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
private lateinit var store: PairingStore private lateinit var store: PairingStore
private val adapter = QueueAdapter() private val adapter = QueueAdapter(onCancel = ::confirmCancel)
private val scanQr = registerForActivityResult(ScanContract()) { result -> private val scanQr = registerForActivityResult(ScanContract()) { result ->
val payload = result.contents?.let(PairPayload.Companion::fromQr) 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 // A share intent is consumed once; re-delivery on rotation would
// double-queue (dedupe by hash makes that harmless server-side, but // double-queue (the hash dedupe in Enqueuer refuses the copy, but the
// the queue UI would still show it twice). // user would still see a spurious "already in the queue" toast).
intent.action = null intent.action = null
} }
@@ -205,7 +207,14 @@ class MainActivity : AppCompatActivity() {
} }
lifecycleScope.launch { lifecycleScope.launch {
try { 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) { } catch (e: Exception) {
Toast.makeText( Toast.makeText(
this@MainActivity, 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() { private fun startScan() {
scanQr.launch( scanQr.launch(
ScanOptions() ScanOptions()

View File

@@ -18,6 +18,8 @@ object Status {
/** Same content was already on the server; nothing was re-sent or rewritten. */ /** Same content was already on the server; nothing was re-sent or rewritten. */
const val DUPLICATE = "duplicate" const val DUPLICATE = "duplicate"
const val FAILED = "failed" const val FAILED = "failed"
/** User cancelled before completion; the source file is always kept. */
const val CANCELLED = "cancelled"
} }
@Entity(tableName = "uploads") @Entity(tableName = "uploads")
@@ -61,14 +63,56 @@ interface UploadDao {
@Query("SELECT * FROM uploads WHERE status = :status") @Query("SELECT * FROM uploads WHERE status = :status")
suspend fun byStatus(status: String): List<UploadItem> 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") @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) 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") @Query("UPDATE uploads SET sourceDeleted = :deleted WHERE id = :id")
suspend fun setSourceDeleted(id: Long, deleted: Boolean) suspend fun setSourceDeleted(id: Long, deleted: Boolean)
/** Clear upload history: everything that has finished, one way or another. */ /** 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() 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.data.UploadItem
import eu.itaz.internaluploader.databinding.ItemUploadBinding 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) class Holder(val binding: ItemUploadBinding) : RecyclerView.ViewHolder(binding.root)
@@ -43,10 +45,15 @@ class QueueAdapter : ListAdapter<UploadItem, QueueAdapter.Holder>(Diff) {
R.string.status_failed, R.string.status_failed,
item.error ?: context.getString(R.string.status_failed_unknown), item.error ?: context.getString(R.string.status_failed_unknown),
) )
Status.CANCELLED -> context.getString(R.string.status_cancelled)
else -> item.status else -> item.status
} }
holder.binding.check.visibility = holder.binding.check.visibility =
if (item.status == Status.DONE || item.status == Status.DUPLICATE) View.VISIBLE else View.GONE 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>() { private object Diff : DiffUtil.ItemCallback<UploadItem>() {

View File

@@ -10,10 +10,13 @@ import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.workDataOf import androidx.work.workDataOf
import android.content.Intent
import eu.itaz.internaluploader.data.AppDb import eu.itaz.internaluploader.data.AppDb
import eu.itaz.internaluploader.data.Status import eu.itaz.internaluploader.data.Status
import eu.itaz.internaluploader.data.UploadItem import eu.itaz.internaluploader.data.UploadItem
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
@@ -22,12 +25,28 @@ import java.util.concurrent.TimeUnit
object Enqueuer { 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 * 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 * retry never depends on the source URI still being readable, then hand
* the file to WorkManager. * 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 originalName = displayNameOf(context, uri) ?: "unnamed"
val queueDir = File(context.filesDir, "queue").apply { mkdirs() } val queueDir = File(context.filesDir, "queue").apply { mkdirs() }
@@ -55,6 +74,12 @@ object Enqueuer {
val remoteName = if (ext != null) "$hash.$ext" else hash val remoteName = if (ext != null) "$hash.$ext" else hash
val db = AppDb.get(context) val db = AppDb.get(context)
insertLock.withLock {
val existing = db.uploads().activeByRemoteName(remoteName)
if (existing != null) {
cacheFile.delete()
return@withContext EnqueueResult.AlreadyQueued(existing)
}
val item = UploadItem( val item = UploadItem(
originalName = originalName, originalName = originalName,
remoteName = remoteName, remoteName = remoteName,
@@ -64,7 +89,36 @@ object Enqueuer {
) )
val id = db.uploads().insert(item) val id = db.uploads().insert(item)
schedule(context, id) schedule(context, id)
item.copy(id = 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. */ /** (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.Status
import eu.itaz.internaluploader.data.UploadItem import eu.itaz.internaluploader.data.UploadItem
import eu.itaz.internaluploader.pairing.PairingStore import eu.itaz.internaluploader.pairing.PairingStore
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.coroutines.resumeWithException
class UploadWorker(context: Context, params: WorkerParameters) : class UploadWorker(context: Context, params: WorkerParameters) :
CoroutineWorker(context, params) { CoroutineWorker(context, params) {
@@ -32,7 +37,12 @@ class UploadWorker(context: Context, params: WorkerParameters) :
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val itemId = inputData.getLong(KEY_ITEM_ID, -1) val itemId = inputData.getLong(KEY_ITEM_ID, -1)
val item = db.uploads().byId(itemId) ?: return Result.failure() 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() val paired = PairingStore(applicationContext).current()
?: return permanentFailure(item, applicationContext.getString(R.string.error_not_paired)) ?: 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. // itself still proceeds, just without a notification.
runCatching { setForeground(foregroundInfo(item, 0)) } 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 { return try {
when (val outcome = upload(paired.serverUrl, paired.token, item, file)) { 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 // content was already there and was NOT rewritten. Either
// way it is safe to remove our working copy and the source. // way it is safe to remove our working copy and the source.
val status = if (outcome == UploadOutcome.DUPLICATE) Status.DUPLICATE else Status.DONE 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() 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() Result.success()
} }
UploadOutcome.UNAUTHORIZED -> UploadOutcome.UNAUTHORIZED ->
@@ -69,12 +82,12 @@ class UploadWorker(context: Context, params: WorkerParameters) :
UploadOutcome.REJECTED -> UploadOutcome.REJECTED ->
permanentFailure(item, applicationContext.getString(R.string.error_rejected)) permanentFailure(item, applicationContext.getString(R.string.error_rejected))
is UploadOutcome.TRANSIENT -> { is UploadOutcome.TRANSIENT -> {
db.uploads().setStatus(item.id, Status.PENDING, error = outcome.message) db.uploads().markRetry(item.id, outcome.message)
Result.retry() Result.retry()
} }
} }
} catch (e: IOException) { } catch (e: IOException) {
db.uploads().setStatus(item.id, Status.PENDING, error = e.message) db.uploads().markRetry(item.id, e.message)
Result.retry() Result.retry()
} }
} }
@@ -100,7 +113,7 @@ class UploadWorker(context: Context, params: WorkerParameters) :
.head() .head()
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.build() .build()
client.newCall(head).execute().use { response -> client.newCall(head).await().use { response ->
when (response.code) { when (response.code) {
204 -> return UploadOutcome.DUPLICATE // already present; skip the bytes 204 -> return UploadOutcome.DUPLICATE // already present; skip the bytes
401 -> return UploadOutcome.UNAUTHORIZED 401 -> return UploadOutcome.UNAUTHORIZED
@@ -118,7 +131,7 @@ class UploadWorker(context: Context, params: WorkerParameters) :
// Room write per callback would thrash; CountingFileRequestBody // Room write per callback would thrash; CountingFileRequestBody
// already throttles callbacks to ~1/s. // already throttles callbacks to ~1/s.
kotlinx.coroutines.runBlocking { kotlinx.coroutines.runBlocking {
db.uploads().setStatus(item.id, Status.UPLOADING, progress = percent) db.uploads().setProgress(item.id, percent)
} }
} }
val put = Request.Builder() val put = Request.Builder()
@@ -126,7 +139,7 @@ class UploadWorker(context: Context, params: WorkerParameters) :
.put(body) .put(body)
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.build() .build()
client.newCall(put).execute().use { response -> client.newCall(put).await().use { response ->
return when (response.code) { return when (response.code) {
201 -> UploadOutcome.DONE // created 201 -> UploadOutcome.DONE // created
200 -> UploadOutcome.DUPLICATE // server already had identical content 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 { 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() 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:ellipsize="middle"
android:singleLine="true" android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodyLarge" 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_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="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 <ImageView
android:id="@+id/check" android:id="@+id/check"
android:layout_width="24dp" android:layout_width="24dp"
@@ -30,6 +33,20 @@
app:layout_constraintTop_toTopOf="@id/name" app:layout_constraintTop_toTopOf="@id/name"
app:tint="?attr/colorPrimary" /> 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 <TextView
android:id="@+id/status" android:id="@+id/status"
android:layout_width="0dp" android:layout_width="0dp"

View File

@@ -25,6 +25,15 @@
<string name="status_duplicate_removed">Duplicate · removed from phone</string> <string name="status_duplicate_removed">Duplicate · removed from phone</string>
<string name="status_failed">Failed: %1$s</string> <string name="status_failed">Failed: %1$s</string>
<string name="status_failed_unknown">unknown error</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> <string name="notification_uploading">Uploading</string>