app v1.1.0: delete-after-upload, duplicate status, clear history, open-with
- Delete the source file once an upload is confirmed (best-effort: SAF picks get a persisted read+write grant and are removed; read-only shared streams are left in place). Cache copy always removed. A removed source shows as 'removed from phone'. - Distinguish duplicates: a HEAD (or the server's 200 existed) marks the item 'Duplicate · already on server' — content is never re-sent or overwritten, since the name IS the sha256. Different content = different hash = a new file, not a duplicate. - 'Clear history' app-bar button clears finished/duplicate/failed rows. - Register as an 'Open with' target (ACTION_VIEW, content/file) in addition to the existing share-sheet target. - Room schema v2 (destructive fallback; queue is ephemeral). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
22
README.md
22
README.md
@@ -8,6 +8,28 @@ Every file is stored as `<sha256-of-content>.<original extension>`, so names
|
|||||||
carry nothing, re-uploads are idempotent, and a retried upload can be verified
|
carry nothing, re-uploads are idempotent, and a retried upload can be verified
|
||||||
server-side before it is accepted.
|
server-side before it is accepted.
|
||||||
|
|
||||||
|
## Behaviour
|
||||||
|
|
||||||
|
- **Integrity is structural.** The remote name *is* the SHA-256 of the content.
|
||||||
|
The server recomputes the hash of the bytes it receives and stores the file
|
||||||
|
only if it matches (mismatch → 409, nothing written). So a success response
|
||||||
|
is proof the server holds byte-for-byte-intact content — which is what makes
|
||||||
|
deleting the local copy safe.
|
||||||
|
- **Delete after confirmed upload.** Once an upload is confirmed, the app
|
||||||
|
removes its working copy and, best-effort, the original file. Files picked
|
||||||
|
in-app (SAF) are removed via the persisted read+write grant; a file that
|
||||||
|
arrives as a read-only share/`open with` stream cannot be deleted and is left
|
||||||
|
in place (not a failure). A removed source shows as "removed from phone".
|
||||||
|
- **Duplicates are never rewritten.** Identical content (same hash) already on
|
||||||
|
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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
## Parts
|
## Parts
|
||||||
|
|
||||||
- `server/` — Node 22 / TypeScript. Runs as a docker container **on nas001**
|
- `server/` — Node 22 / TypeScript. Runs as a docker container **on nas001**
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ android {
|
|||||||
applicationId = "eu.itaz.internaluploader"
|
applicationId = "eu.itaz.internaluploader"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 2
|
||||||
versionName = "1.0.0"
|
versionName = "1.1.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.
|
||||||
|
|||||||
@@ -43,6 +43,15 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="internaluploader" android:host="pair" />
|
<data android:scheme="internaluploader" android:host="pair" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- "Open with" target: tapping a downloaded file offers this app,
|
||||||
|
which queues it for upload. Any file type, content or file uri. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="content" android:mimeType="*/*" />
|
||||||
|
<data android:scheme="file" android:mimeType="*/*" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- WorkManager's foreground service must declare the same type the
|
<!-- WorkManager's foreground service must declare the same type the
|
||||||
|
|||||||
@@ -43,9 +43,18 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ACTION_OPEN_DOCUMENT rather than the OpenMultipleDocuments contract: we
|
||||||
|
// ask for a persistable read+WRITE grant so a confirmed upload can delete
|
||||||
|
// the original. The contract only requests read, which leaves nothing to
|
||||||
|
// delete with later.
|
||||||
private val pickFiles =
|
private val pickFiles =
|
||||||
registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
uris.forEach(::enqueueUri)
|
if (result.resultCode != RESULT_OK) return@registerForActivityResult
|
||||||
|
val data = result.data ?: return@registerForActivityResult
|
||||||
|
pickedUris(data).forEach { uri ->
|
||||||
|
persistGrant(uri)
|
||||||
|
enqueueUri(uri)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val askNotifications =
|
private val askNotifications =
|
||||||
@@ -63,7 +72,20 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
binding.scanButton.setOnClickListener { startScan() }
|
binding.scanButton.setOnClickListener { startScan() }
|
||||||
binding.ssoButton.setOnClickListener { startAppSso() }
|
binding.ssoButton.setOnClickListener { startAppSso() }
|
||||||
binding.addButton.setOnClickListener { pickFiles.launch(arrayOf("*/*")) }
|
binding.addButton.setOnClickListener {
|
||||||
|
pickFiles.launch(
|
||||||
|
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||||
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
|
type = "*/*"
|
||||||
|
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||||
|
addFlags(
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||||
|
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||||
|
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
binding.retryButton.setOnClickListener {
|
binding.retryButton.setOnClickListener {
|
||||||
lifecycleScope.launch { Enqueuer.retryFailed(this@MainActivity) }
|
lifecycleScope.launch { Enqueuer.retryFailed(this@MainActivity) }
|
||||||
}
|
}
|
||||||
@@ -94,8 +116,17 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private fun handleIntent(intent: Intent) {
|
private fun handleIntent(intent: Intent) {
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
Intent.ACTION_VIEW -> {
|
Intent.ACTION_VIEW -> {
|
||||||
val code = intent.data?.getQueryParameter("c")
|
val data = intent.data
|
||||||
if (code != null) completePairing(PairPayload.fromDeepLinkCode(code))
|
when {
|
||||||
|
// Our own pairing deep link: internaluploader://pair?c=…
|
||||||
|
data?.scheme == DEEP_LINK_SCHEME -> {
|
||||||
|
data.getQueryParameter("c")?.let {
|
||||||
|
completePairing(PairPayload.fromDeepLinkCode(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "Open with InternalUploader" on a downloaded file.
|
||||||
|
data != null -> enqueueUri(data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Intent.ACTION_SEND -> {
|
Intent.ACTION_SEND -> {
|
||||||
sharedUri(intent)?.let(::enqueueUri)
|
sharedUri(intent)?.let(::enqueueUri)
|
||||||
@@ -126,6 +157,34 @@ class MainActivity : AppCompatActivity() {
|
|||||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) ?: emptyList()
|
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun pickedUris(data: Intent): List<Uri> {
|
||||||
|
val clip = data.clipData
|
||||||
|
return when {
|
||||||
|
clip != null -> (0 until clip.itemCount).map { clip.getItemAt(it).uri }
|
||||||
|
data.data != null -> listOf(data.data!!)
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a read+write grant so the upload worker can delete the original
|
||||||
|
* after a confirmed upload. Falls back to read-only if the provider won't
|
||||||
|
* grant write — the file then simply stays put.
|
||||||
|
*/
|
||||||
|
private fun persistGrant(uri: Uri) {
|
||||||
|
val rw = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
try {
|
||||||
|
contentResolver.takePersistableUriPermission(uri, rw)
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
runCatching {
|
||||||
|
contentResolver.takePersistableUriPermission(
|
||||||
|
uri,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun enqueueUri(uri: Uri) {
|
private fun enqueueUri(uri: Uri) {
|
||||||
if (store.current() == null) {
|
if (store.current() == null) {
|
||||||
toast(R.string.pair_first)
|
toast(R.string.pair_first)
|
||||||
@@ -188,7 +247,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
menuInflater.inflate(R.menu.main_menu, menu)
|
menuInflater.inflate(R.menu.main_menu, menu)
|
||||||
val paired = store.current() != null
|
val paired = store.current() != null
|
||||||
menu.findItem(R.id.action_unpair).isVisible = paired
|
menu.findItem(R.id.action_unpair).isVisible = paired
|
||||||
menu.findItem(R.id.action_clear_finished).isVisible = paired
|
menu.findItem(R.id.action_clear_history).isVisible = paired
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,9 +258,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
render()
|
render()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.action_clear_finished -> {
|
R.id.action_clear_history -> {
|
||||||
|
// Clears finished + failed entries; anything still in flight
|
||||||
|
// keeps running and stays in the list.
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
AppDb.get(this@MainActivity).uploads().deleteByStatus(Status.DONE)
|
AppDb.get(this@MainActivity).uploads().deleteFinished()
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -211,4 +272,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private fun toast(resId: Int) {
|
private fun toast(resId: Int) {
|
||||||
Toast.makeText(this, resId, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, resId, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// Matches the manifest deep-link filter and the server's PU_APP_SCHEME.
|
||||||
|
const val DEEP_LINK_SCHEME = "internaluploader"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ object Status {
|
|||||||
const val PENDING = "pending"
|
const val PENDING = "pending"
|
||||||
const val UPLOADING = "uploading"
|
const val UPLOADING = "uploading"
|
||||||
const val DONE = "done"
|
const val DONE = "done"
|
||||||
|
/** Same content was already on the server; nothing was re-sent or rewritten. */
|
||||||
|
const val DUPLICATE = "duplicate"
|
||||||
const val FAILED = "failed"
|
const val FAILED = "failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +30,17 @@ data class UploadItem(
|
|||||||
/** Private copy the worker uploads from; survives source URI revocation. */
|
/** Private copy the worker uploads from; survives source URI revocation. */
|
||||||
val cachePath: String,
|
val cachePath: String,
|
||||||
val size: Long,
|
val size: Long,
|
||||||
|
/**
|
||||||
|
* The original file's content URI, kept so we can delete it once the
|
||||||
|
* upload is confirmed. Null when the source cannot be identified.
|
||||||
|
*/
|
||||||
|
val sourceUri: String? = null,
|
||||||
|
/**
|
||||||
|
* Whether the original source file was removed from the device after a
|
||||||
|
* confirmed upload. Best-effort: only files whose provider grants delete
|
||||||
|
* (a picked SAF document) can be removed; a shared read-only stream cannot.
|
||||||
|
*/
|
||||||
|
val sourceDeleted: Boolean = false,
|
||||||
val status: String = Status.PENDING,
|
val status: String = Status.PENDING,
|
||||||
val progress: Int = 0,
|
val progress: Int = 0,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
@@ -51,11 +64,15 @@ interface UploadDao {
|
|||||||
@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)
|
||||||
|
|
||||||
@Query("DELETE FROM uploads WHERE status = :status")
|
@Query("UPDATE uploads SET sourceDeleted = :deleted WHERE id = :id")
|
||||||
suspend fun deleteByStatus(status: String)
|
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')")
|
||||||
|
suspend fun deleteFinished()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Database(entities = [UploadItem::class], version = 1, exportSchema = false)
|
@Database(entities = [UploadItem::class], version = 2, exportSchema = false)
|
||||||
abstract class AppDb : RoomDatabase() {
|
abstract class AppDb : RoomDatabase() {
|
||||||
abstract fun uploads(): UploadDao
|
abstract fun uploads(): UploadDao
|
||||||
|
|
||||||
@@ -69,7 +86,11 @@ abstract class AppDb : RoomDatabase() {
|
|||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
AppDb::class.java,
|
AppDb::class.java,
|
||||||
"internaluploader.db",
|
"internaluploader.db",
|
||||||
).build().also { instance = it }
|
)
|
||||||
|
// The queue is ephemeral working state, not data worth a
|
||||||
|
// migration — drop and recreate on a schema bump.
|
||||||
|
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||||
|
.build().also { instance = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ class QueueAdapter : ListAdapter<UploadItem, QueueAdapter.Holder>(Diff) {
|
|||||||
holder.binding.status.text = when (item.status) {
|
holder.binding.status.text = when (item.status) {
|
||||||
Status.PENDING -> context.getString(R.string.status_pending)
|
Status.PENDING -> context.getString(R.string.status_pending)
|
||||||
Status.UPLOADING -> context.getString(R.string.status_uploading, item.progress)
|
Status.UPLOADING -> context.getString(R.string.status_uploading, item.progress)
|
||||||
Status.DONE -> context.getString(R.string.status_done)
|
Status.DONE ->
|
||||||
|
if (item.sourceDeleted) context.getString(R.string.status_done_removed)
|
||||||
|
else context.getString(R.string.status_done)
|
||||||
|
Status.DUPLICATE ->
|
||||||
|
if (item.sourceDeleted) context.getString(R.string.status_duplicate_removed)
|
||||||
|
else context.getString(R.string.status_duplicate)
|
||||||
Status.FAILED -> context.getString(
|
Status.FAILED -> context.getString(
|
||||||
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),
|
||||||
@@ -41,7 +46,7 @@ class QueueAdapter : ListAdapter<UploadItem, QueueAdapter.Holder>(Diff) {
|
|||||||
else -> item.status
|
else -> item.status
|
||||||
}
|
}
|
||||||
holder.binding.check.visibility =
|
holder.binding.check.visibility =
|
||||||
if (item.status == Status.DONE) View.VISIBLE else View.GONE
|
if (item.status == Status.DONE || item.status == Status.DUPLICATE) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
private object Diff : DiffUtil.ItemCallback<UploadItem>() {
|
private object Diff : DiffUtil.ItemCallback<UploadItem>() {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ object Enqueuer {
|
|||||||
remoteName = remoteName,
|
remoteName = remoteName,
|
||||||
cachePath = cacheFile.absolutePath,
|
cachePath = cacheFile.absolutePath,
|
||||||
size = size,
|
size = size,
|
||||||
|
sourceUri = uri.toString(),
|
||||||
)
|
)
|
||||||
val id = db.uploads().insert(item)
|
val id = db.uploads().insert(item)
|
||||||
schedule(context, id)
|
schedule(context, id)
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package eu.itaz.internaluploader.upload
|
|||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.provider.DocumentsContract
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.ForegroundInfo
|
import androidx.work.ForegroundInfo
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
@@ -49,9 +52,16 @@ class UploadWorker(context: Context, params: WorkerParameters) :
|
|||||||
|
|
||||||
return try {
|
return try {
|
||||||
when (val outcome = upload(paired.serverUrl, paired.token, item, file)) {
|
when (val outcome = upload(paired.serverUrl, paired.token, item, file)) {
|
||||||
UploadOutcome.DONE -> {
|
UploadOutcome.DONE, UploadOutcome.DUPLICATE -> {
|
||||||
db.uploads().setStatus(item.id, Status.DONE, progress = 100)
|
// Both are confirmed-present-and-intact: the server stores a
|
||||||
|
// file only if the bytes it holds hash to the name we sent
|
||||||
|
// (the SHA-256). DONE = we created it; DUPLICATE = identical
|
||||||
|
// 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)
|
||||||
file.delete()
|
file.delete()
|
||||||
|
deleteSource(item)
|
||||||
Result.success()
|
Result.success()
|
||||||
}
|
}
|
||||||
UploadOutcome.UNAUTHORIZED ->
|
UploadOutcome.UNAUTHORIZED ->
|
||||||
@@ -71,6 +81,8 @@ class UploadWorker(context: Context, params: WorkerParameters) :
|
|||||||
|
|
||||||
private sealed interface UploadOutcome {
|
private sealed interface UploadOutcome {
|
||||||
data object DONE : UploadOutcome
|
data object DONE : UploadOutcome
|
||||||
|
/** Identical content already on the server — not re-sent, not rewritten. */
|
||||||
|
data object DUPLICATE : UploadOutcome
|
||||||
data object UNAUTHORIZED : UploadOutcome
|
data object UNAUTHORIZED : UploadOutcome
|
||||||
data object REJECTED : UploadOutcome
|
data object REJECTED : UploadOutcome
|
||||||
data class TRANSIENT(val message: String) : UploadOutcome
|
data class TRANSIENT(val message: String) : UploadOutcome
|
||||||
@@ -90,7 +102,7 @@ class UploadWorker(context: Context, params: WorkerParameters) :
|
|||||||
.build()
|
.build()
|
||||||
client.newCall(head).execute().use { response ->
|
client.newCall(head).execute().use { response ->
|
||||||
when (response.code) {
|
when (response.code) {
|
||||||
204 -> return UploadOutcome.DONE
|
204 -> return UploadOutcome.DUPLICATE // already present; skip the bytes
|
||||||
401 -> return UploadOutcome.UNAUTHORIZED
|
401 -> return UploadOutcome.UNAUTHORIZED
|
||||||
404 -> Unit // expected: not there yet
|
404 -> Unit // expected: not there yet
|
||||||
else -> return UploadOutcome.TRANSIENT("HEAD ${response.code}")
|
else -> return UploadOutcome.TRANSIENT("HEAD ${response.code}")
|
||||||
@@ -116,7 +128,8 @@ class UploadWorker(context: Context, params: WorkerParameters) :
|
|||||||
.build()
|
.build()
|
||||||
client.newCall(put).execute().use { response ->
|
client.newCall(put).execute().use { response ->
|
||||||
return when (response.code) {
|
return when (response.code) {
|
||||||
200, 201 -> UploadOutcome.DONE
|
201 -> UploadOutcome.DONE // created
|
||||||
|
200 -> UploadOutcome.DUPLICATE // server already had identical content
|
||||||
401 -> UploadOutcome.UNAUTHORIZED
|
401 -> UploadOutcome.UNAUTHORIZED
|
||||||
409, 413, 400 -> UploadOutcome.REJECTED
|
409, 413, 400 -> UploadOutcome.REJECTED
|
||||||
else -> UploadOutcome.TRANSIENT("PUT ${response.code}")
|
else -> UploadOutcome.TRANSIENT("PUT ${response.code}")
|
||||||
@@ -124,6 +137,34 @@ class UploadWorker(context: Context, params: WorkerParameters) :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the original file from the device once its upload is confirmed.
|
||||||
|
*
|
||||||
|
* Best-effort: only a source whose provider grants deletion (a document
|
||||||
|
* picked through SAF, for which we persisted a read+write grant) can be
|
||||||
|
* removed. A file arriving via the share sheet or an "open with" is a
|
||||||
|
* read-only stream the provider will not let us delete — that just leaves
|
||||||
|
* the original in place, which is not an upload failure.
|
||||||
|
*/
|
||||||
|
private suspend fun deleteSource(item: UploadItem) {
|
||||||
|
val uriString = item.sourceUri ?: return
|
||||||
|
val uri = uriString.toUri()
|
||||||
|
val resolver = applicationContext.contentResolver
|
||||||
|
val deleted = runCatching {
|
||||||
|
DocumentsContract.deleteDocument(resolver, uri)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
if (deleted) {
|
||||||
|
db.uploads().setSourceDeleted(item.id, true)
|
||||||
|
// Drop the persisted grant we no longer need.
|
||||||
|
runCatching {
|
||||||
|
resolver.releasePersistableUriPermission(
|
||||||
|
uri,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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().setStatus(item.id, Status.FAILED, error = message)
|
||||||
return Result.failure()
|
return Result.failure()
|
||||||
|
|||||||
10
android/app/src/main/res/drawable/ic_clear_history.xml
Normal file
10
android/app/src/main/res/drawable/ic_clear_history.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z" />
|
||||||
|
</vector>
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
<item
|
<item
|
||||||
android:id="@+id/action_clear_finished"
|
android:id="@+id/action_clear_history"
|
||||||
android:title="@string/action_clear_finished"
|
android:icon="@drawable/ic_clear_history"
|
||||||
app:showAsAction="never" />
|
android:title="@string/action_clear_history"
|
||||||
|
app:showAsAction="ifRoom" />
|
||||||
<item
|
<item
|
||||||
android:id="@+id/action_unpair"
|
android:id="@+id/action_unpair"
|
||||||
android:title="@string/action_unpair"
|
android:title="@string/action_unpair"
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
<string name="status_pending">Waiting…</string>
|
<string name="status_pending">Waiting…</string>
|
||||||
<string name="status_uploading">Uploading… %1$d%%</string>
|
<string name="status_uploading">Uploading… %1$d%%</string>
|
||||||
<string name="status_done">Uploaded</string>
|
<string name="status_done">Uploaded</string>
|
||||||
|
<string name="status_done_removed">Uploaded · removed from phone</string>
|
||||||
|
<string name="status_duplicate">Duplicate · already on server</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>
|
||||||
|
|
||||||
@@ -31,5 +34,5 @@
|
|||||||
<string name="error_rejected">rejected by server</string>
|
<string name="error_rejected">rejected by server</string>
|
||||||
|
|
||||||
<string name="action_unpair">Unpair</string>
|
<string name="action_unpair">Unpair</string>
|
||||||
<string name="action_clear_finished">Clear finished</string>
|
<string name="action_clear_history">Clear history</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user