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

@@ -22,8 +22,8 @@ android {
applicationId = "eu.itaz.internaluploader"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
versionCode = 2
versionName = "1.1.0"
// The one place the server address exists. Never rendered in the UI;
// a pairing QR can carry a different origin and overrides this.

View File

@@ -43,6 +43,15 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="internaluploader" android:host="pair" />
</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>
<!-- 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 =
registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
uris.forEach(::enqueueUri)
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != RESULT_OK) return@registerForActivityResult
val data = result.data ?: return@registerForActivityResult
pickedUris(data).forEach { uri ->
persistGrant(uri)
enqueueUri(uri)
}
}
private val askNotifications =
@@ -63,7 +72,20 @@ class MainActivity : AppCompatActivity() {
binding.scanButton.setOnClickListener { startScan() }
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 {
lifecycleScope.launch { Enqueuer.retryFailed(this@MainActivity) }
}
@@ -94,8 +116,17 @@ class MainActivity : AppCompatActivity() {
private fun handleIntent(intent: Intent) {
when (intent.action) {
Intent.ACTION_VIEW -> {
val code = intent.data?.getQueryParameter("c")
if (code != null) completePairing(PairPayload.fromDeepLinkCode(code))
val data = intent.data
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 -> {
sharedUri(intent)?.let(::enqueueUri)
@@ -126,6 +157,34 @@ class MainActivity : AppCompatActivity() {
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) {
if (store.current() == null) {
toast(R.string.pair_first)
@@ -188,7 +247,7 @@ class MainActivity : AppCompatActivity() {
menuInflater.inflate(R.menu.main_menu, menu)
val paired = store.current() != null
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
}
@@ -199,9 +258,11 @@ class MainActivity : AppCompatActivity() {
render()
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 {
AppDb.get(this@MainActivity).uploads().deleteByStatus(Status.DONE)
AppDb.get(this@MainActivity).uploads().deleteFinished()
}
true
}
@@ -211,4 +272,9 @@ class MainActivity : AppCompatActivity() {
private fun toast(resId: Int) {
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 UPLOADING = "uploading"
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"
}
@@ -28,6 +30,17 @@ data class UploadItem(
/** Private copy the worker uploads from; survives source URI revocation. */
val cachePath: String,
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 progress: Int = 0,
val error: String? = null,
@@ -51,11 +64,15 @@ interface UploadDao {
@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)
@Query("DELETE FROM uploads WHERE status = :status")
suspend fun deleteByStatus(status: 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')")
suspend fun deleteFinished()
}
@Database(entities = [UploadItem::class], version = 1, exportSchema = false)
@Database(entities = [UploadItem::class], version = 2, exportSchema = false)
abstract class AppDb : RoomDatabase() {
abstract fun uploads(): UploadDao
@@ -69,7 +86,11 @@ abstract class AppDb : RoomDatabase() {
context.applicationContext,
AppDb::class.java,
"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) {
Status.PENDING -> context.getString(R.string.status_pending)
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(
R.string.status_failed,
item.error ?: context.getString(R.string.status_failed_unknown),
@@ -41,7 +46,7 @@ class QueueAdapter : ListAdapter<UploadItem, QueueAdapter.Holder>(Diff) {
else -> item.status
}
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>() {

View File

@@ -60,6 +60,7 @@ object Enqueuer {
remoteName = remoteName,
cachePath = cacheFile.absolutePath,
size = size,
sourceUri = uri.toString(),
)
val id = db.uploads().insert(item)
schedule(context, id)

View File

@@ -2,9 +2,12 @@ package eu.itaz.internaluploader.upload
import android.app.Notification
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.provider.DocumentsContract
import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
@@ -49,9 +52,16 @@ class UploadWorker(context: Context, params: WorkerParameters) :
return try {
when (val outcome = upload(paired.serverUrl, paired.token, item, file)) {
UploadOutcome.DONE -> {
db.uploads().setStatus(item.id, Status.DONE, progress = 100)
UploadOutcome.DONE, UploadOutcome.DUPLICATE -> {
// 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()
deleteSource(item)
Result.success()
}
UploadOutcome.UNAUTHORIZED ->
@@ -71,6 +81,8 @@ class UploadWorker(context: Context, params: WorkerParameters) :
private sealed interface 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 REJECTED : UploadOutcome
data class TRANSIENT(val message: String) : UploadOutcome
@@ -90,7 +102,7 @@ class UploadWorker(context: Context, params: WorkerParameters) :
.build()
client.newCall(head).execute().use { response ->
when (response.code) {
204 -> return UploadOutcome.DONE
204 -> return UploadOutcome.DUPLICATE // already present; skip the bytes
401 -> return UploadOutcome.UNAUTHORIZED
404 -> Unit // expected: not there yet
else -> return UploadOutcome.TRANSIENT("HEAD ${response.code}")
@@ -116,7 +128,8 @@ class UploadWorker(context: Context, params: WorkerParameters) :
.build()
client.newCall(put).execute().use { response ->
return when (response.code) {
200, 201 -> UploadOutcome.DONE
201 -> UploadOutcome.DONE // created
200 -> UploadOutcome.DUPLICATE // server already had identical content
401 -> UploadOutcome.UNAUTHORIZED
409, 413, 400 -> UploadOutcome.REJECTED
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 {
db.uploads().setStatus(item.id, Status.FAILED, error = message)
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"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_clear_finished"
android:title="@string/action_clear_finished"
app:showAsAction="never" />
android:id="@+id/action_clear_history"
android:icon="@drawable/ic_clear_history"
android:title="@string/action_clear_history"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_unpair"
android:title="@string/action_unpair"

View File

@@ -20,6 +20,9 @@
<string name="status_pending">Waiting…</string>
<string name="status_uploading">Uploading… %1$d%%</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_unknown">unknown error</string>
@@ -31,5 +34,5 @@
<string name="error_rejected">rejected by server</string>
<string name="action_unpair">Unpair</string>
<string name="action_clear_finished">Clear finished</string>
<string name="action_clear_history">Clear history</string>
</resources>