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,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
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"
|
||||
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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user