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:
2026-09-29 00:07:30 +01:00
parent 2fd80cfd7d
commit 4c6c897b10
11 changed files with 203 additions and 24 deletions

View File

@@ -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**

View File

@@ -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.

View File

@@ -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

View File

@@ -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"
}
} }

View File

@@ -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 }
} }
} }
} }

View File

@@ -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>() {

View File

@@ -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)

View File

@@ -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()

View 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>

View File

@@ -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"

View File

@@ -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>