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:
16
README.md
16
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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>() {
|
||||
|
||||
@@ -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,6 +74,12 @@ object Enqueuer {
|
||||
val remoteName = if (ext != null) "$hash.$ext" else hash
|
||||
|
||||
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(
|
||||
originalName = originalName,
|
||||
remoteName = remoteName,
|
||||
@@ -64,7 +89,36 @@ object Enqueuer {
|
||||
)
|
||||
val id = db.uploads().insert(item)
|
||||
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. */
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
9
android/app/src/main/res/drawable/ic_close.xml
Normal file
9
android/app/src/main/res/drawable/ic_close.xml
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user