android background worker

758983a · gmorell · 2026-05-09 03:25

16 files +239 -56

Files changed

modified android/app/src/main/AndroidManifest.xml
+8 −0
@@ -3,6 +3,9 @@
3 3
4 4 <uses-permission android:name="android.permission.INTERNET"/>
5 5 <uses-permission android:name="android.permission.CAMERA"/>
6 + <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
7 + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
8 + <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
6 9 <uses-feature android:name="android.hardware.camera" android:required="false"/>
7 10
8 11 <application
@@ -43,6 +46,11 @@
43 46 android:resource="@xml/file_paths"/>
44 47 </provider>
45 48
49 + <service
50 + android:name=".upload.UploadService"
51 + android:exported="false"
52 + android:foregroundServiceType="dataSync"/>
53 +
46 54 <activity
47 55 android:name=".ui.ShareReceiverActivity"
48 56 android:exported="true"
modified android/app/src/main/java/org/neiam/dms/app/ui/ShareReceiverActivity.kt
+54 −54
@@ -17,15 +17,18 @@ import androidx.compose.ui.Modifier
17 17 import androidx.compose.ui.unit.dp
18 18 import androidx.lifecycle.lifecycleScope
19 19 import coil.compose.AsyncImage
20 +import kotlinx.coroutines.Dispatchers
20 21 import kotlinx.coroutines.launch
21 −import org.neiam.dms.app.data.DmsClient
22 +import kotlinx.coroutines.withContext
22 23 import org.neiam.dms.app.data.ThemeStore
23 24 import org.neiam.dms.app.data.TokenStore
24 −import org.neiam.dms.app.data.toFilePart
25 −import org.neiam.dms.app.data.toFormPart
25 +import org.neiam.dms.app.data.displayName
26 26 import org.neiam.dms.app.ui.theme.ALL_THEMES
27 27 import org.neiam.dms.app.ui.theme.DmsTheme
28 28 import org.neiam.dms.app.ui.theme.appThemeByKey
29 +import org.neiam.dms.app.upload.StagedUpload
30 +import org.neiam.dms.app.upload.UploadService
31 +import java.io.File
29 32
30 33 class ShareReceiverActivity : ComponentActivity() {
31 34 override fun onCreate(savedInstanceState: Bundle?) {
@@ -47,49 +50,61 @@ class ShareReceiverActivity : ComponentActivity() {
47 50 DmsTheme(theme = theme) {
48 51 ShareUploadDialog(
49 52 uris = items,
50 − onUpload = { tags -> performUpload(creds, items, tags) },
53 + onUpload = { tags -> handoffToService(creds, items, tags) },
51 54 onDismiss = { finish() },
52 55 )
53 56 }
54 57 }
55 58 }
56 59
57 − private fun performUpload(
60 + /**
61 + * Copy the shared URIs into our app cache (URI grants on the share Intent
62 + * are revoked when the activity finishes), then hand the files off to
63 + * UploadService and dismiss the dialog. The user is unblocked after a
64 + * short staging step rather than waiting for every HTTP request.
65 + */
66 + private fun handoffToService(
58 67 creds: TokenStore.Credentials,
59 68 uris: List<Uri>,
60 69 tags: String?,
61 70 ) {
62 − val tagsPart = tags?.takeIf { it.isNotBlank() }?.toFormPart()
63 − val api = DmsClient.build(creds)
71 + staging = true
64 72 lifecycleScope.launch {
65 − uploadProgress = UploadProgress(0, 0, uris.size)
66 − for ((idx, uri) in uris.withIndex()) {
67 − uploadProgress = uploadProgress.copy(currentIndex = idx)
68 − try {
69 − val part = uri.toFilePart(contentResolver)
70 − api.upload(file = part, tags = tagsPart)
71 − uploadProgress = uploadProgress.copy(succeeded = uploadProgress.succeeded + 1)
72 − } catch (_: Exception) {
73 − uploadProgress = uploadProgress.copy(failed = uploadProgress.failed + 1)
74 − }
75 − }
76 − val msg = with(uploadProgress) {
77 − when {
78 − failed == 0 -> "Uploaded $succeeded"
79 − succeeded == 0 -> "All $failed upload(s) failed"
80 − else -> "Uploaded $succeeded, $failed failed"
81 − }
73 + val staged = withContext(Dispatchers.IO) { stageUris(uris) }
74 + if (staged.isEmpty()) {
75 + Toast.makeText(this@ShareReceiverActivity, "Nothing could be read from the share", Toast.LENGTH_LONG).show()
76 + finish(); return@launch
82 77 }
83 − Toast.makeText(this@ShareReceiverActivity, msg, Toast.LENGTH_LONG).show()
78 + UploadService.start(this@ShareReceiverActivity, creds, staged, tags)
79 + val msg = "Uploading ${staged.size} in background"
80 + Toast.makeText(this@ShareReceiverActivity, msg, Toast.LENGTH_SHORT).show()
84 81 finish()
85 82 }
86 83 }
87 84
88 − /** Compose-observable progress state. Updated as each upload completes. */
89 − private var uploadProgressInternal by mutableStateOf(UploadProgress())
90 − private var uploadProgress: UploadProgress
91 − get() = uploadProgressInternal
92 − set(value) { uploadProgressInternal = value }
85 + private fun stageUris(uris: List<Uri>): List<StagedUpload> {
86 + val stamp = System.currentTimeMillis()
87 + val outDir = File(cacheDir, "share-staging").apply { mkdirs() }
88 + return uris.mapIndexedNotNull { idx, uri ->
89 + try {
90 + val mime = contentResolver.getType(uri) ?: "application/octet-stream"
91 + val name = uri.displayName(contentResolver) ?: "upload-$idx.bin"
92 + val safe = name.replace('/', '_').replace('\\', '_')
93 + val target = File(outDir, "$stamp-$idx-$safe")
94 + contentResolver.openInputStream(uri)?.use { input ->
95 + target.outputStream().use { out -> input.copyTo(out) }
96 + } ?: return@mapIndexedNotNull null
97 + StagedUpload(target.absolutePath, name, mime)
98 + } catch (_: Exception) {
99 + null
100 + }
101 + }
102 + }
103 +
104 + private var stagingInternal by mutableStateOf(false)
105 + private var staging: Boolean
106 + get() = stagingInternal
107 + set(value) { stagingInternal = value }
93 108
94 109 @Composable
95 110 private fun ShareUploadDialog(
@@ -98,11 +113,10 @@ class ShareReceiverActivity : ComponentActivity() {
98 113 onDismiss: () -> Unit,
99 114 ) {
100 115 var tags by remember { mutableStateOf("") }
101 − val progress = uploadProgressInternal
102 − val uploading = progress.total > 0 && (progress.succeeded + progress.failed) < progress.total
116 + val busy = stagingInternal
103 117
104 118 AlertDialog(
105 − onDismissRequest = { if (!uploading) onDismiss() },
119 + onDismissRequest = { if (!busy) onDismiss() },
106 120 title = { Text("Upload to DMS") },
107 121 text = {
108 122 Column(
@@ -132,36 +146,29 @@ class ShareReceiverActivity : ComponentActivity() {
132 146 label = { Text("Tags") },
133 147 placeholder = { Text("user:keep Series:Friends species:macro") },
134 148 singleLine = true,
135 − enabled = !uploading,
149 + enabled = !busy,
136 150 modifier = Modifier.fillMaxWidth(),
137 151 )
138 152 Text(
139 153 "Whitespace or comma separated. Bare values are unnamespaced; ns:value sets a namespace.",
140 154 style = MaterialTheme.typography.bodySmall,
141 155 )
142 − if (uploading) {
143 − val done = progress.succeeded + progress.failed
156 + if (busy) {
144 157 Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
145 − Text(
146 − "Uploading ${done + 1} of ${progress.total} — ${progress.succeeded} ok, ${progress.failed} failed",
147 − style = MaterialTheme.typography.bodySmall,
148 − )
149 − LinearProgressIndicator(
150 − progress = { (done + 1).toFloat() / progress.total.coerceAtLeast(1) },
151 − modifier = Modifier.fillMaxWidth(),
152 − )
158 + Text("Preparing files…", style = MaterialTheme.typography.bodySmall)
159 + LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
153 160 }
154 161 }
155 162 }
156 163 },
157 164 confirmButton = {
158 165 TextButton(
159 − enabled = !uploading,
166 + enabled = !busy,
160 167 onClick = { onUpload(tags.ifBlank { null }) },
161 − ) { Text(if (uploading) "Uploading…" else "Upload") }
168 + ) { Text(if (busy) "Preparing…" else "Upload") }
162 169 },
163 170 dismissButton = {
164 − TextButton(enabled = !uploading, onClick = onDismiss) { Text("Cancel") }
171 + TextButton(enabled = !busy, onClick = onDismiss) { Text("Cancel") }
165 172 },
166 173 )
167 174 }
@@ -177,11 +184,4 @@ class ShareReceiverActivity : ComponentActivity() {
177 184 ).orEmpty()
178 185 else -> emptyList()
179 186 }
180 −
181 − private data class UploadProgress(
182 − val currentIndex: Int = 0,
183 − val succeeded: Int = 0,
184 − val failed: Int = 0,
185 − val total: Int = 0,
186 − )
187 187 }
added android/app/src/main/java/org/neiam/dms/app/upload/UploadService.kt
+175 −0
@@ -0,0 +1,175 @@
1 +package org.neiam.dms.app.upload
2 +
3 +import android.app.Notification
4 +import android.app.NotificationChannel
5 +import android.app.NotificationManager
6 +import android.app.Service
7 +import android.content.Context
8 +import android.content.Intent
9 +import android.content.pm.ServiceInfo
10 +import android.os.Build
11 +import android.os.IBinder
12 +import androidx.core.app.NotificationCompat
13 +import kotlinx.coroutines.CoroutineScope
14 +import kotlinx.coroutines.Dispatchers
15 +import kotlinx.coroutines.SupervisorJob
16 +import kotlinx.coroutines.cancel
17 +import kotlinx.coroutines.launch
18 +import okhttp3.MediaType.Companion.toMediaTypeOrNull
19 +import okhttp3.MultipartBody
20 +import okhttp3.RequestBody.Companion.asRequestBody
21 +import org.neiam.dms.app.data.DmsClient
22 +import org.neiam.dms.app.data.TokenStore
23 +import org.neiam.dms.app.data.toFormPart
24 +import java.io.File
25 +
26 +/**
27 + * Foreground service that uploads files staged in app cache to the DMS API.
28 + * Decoupled from any UI lifecycle so the share sheet can dismiss instantly
29 + * after the user taps Upload — progress is conveyed via notifications.
30 + */
31 +class UploadService : Service() {
32 + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
33 + private val notif by lazy { getSystemService(NotificationManager::class.java) }
34 +
35 + override fun onBind(intent: Intent?): IBinder? = null
36 +
37 + override fun onCreate() {
38 + super.onCreate()
39 + ensureChannels()
40 + }
41 +
42 + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
43 + intent ?: run { stopSelf(startId); return START_NOT_STICKY }
44 + val baseUrl = intent.getStringExtra(EXTRA_BASE_URL)
45 + val token = intent.getStringExtra(EXTRA_TOKEN)
46 + val paths = intent.getStringArrayExtra(EXTRA_PATHS)
47 + val names = intent.getStringArrayExtra(EXTRA_NAMES)
48 + val mimes = intent.getStringArrayExtra(EXTRA_MIMES)
49 + val tags = intent.getStringExtra(EXTRA_TAGS)
50 + if (baseUrl == null || token == null || paths == null || names == null || mimes == null) {
51 + stopSelf(startId); return START_NOT_STICKY
52 + }
53 +
54 + val total = paths.size
55 + startInForeground(buildProgressNotification(0, total, 0, 0))
56 +
57 + scope.launch {
58 + val api = DmsClient.build(TokenStore.Credentials(baseUrl, token))
59 + val tagsPart = tags?.takeIf { it.isNotBlank() }?.toFormPart()
60 + var ok = 0
61 + var failed = 0
62 + for (i in 0 until total) {
63 + val file = File(paths[i])
64 + notif.notify(NOTIF_PROGRESS_ID, buildProgressNotification(i, total, ok, failed))
65 + try {
66 + val mime = mimes[i].toMediaTypeOrNull()
67 + val part = MultipartBody.Part.createFormData("file", names[i], file.asRequestBody(mime))
68 + api.upload(file = part, tags = tagsPart)
69 + ok++
70 + } catch (_: Exception) {
71 + failed++
72 + }
73 + runCatching { file.delete() }
74 + }
75 + notif.notify(NOTIF_DONE_ID, buildDoneNotification(ok, failed))
76 + stopForegroundCompat()
77 + stopSelf(startId)
78 + }
79 + return START_NOT_STICKY
80 + }
81 +
82 + override fun onDestroy() {
83 + scope.cancel()
84 + super.onDestroy()
85 + }
86 +
87 + private fun startInForeground(notification: Notification) {
88 + if (Build.VERSION.SDK_INT >= 34) {
89 + startForeground(
90 + NOTIF_PROGRESS_ID,
91 + notification,
92 + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
93 + )
94 + } else {
95 + startForeground(NOTIF_PROGRESS_ID, notification)
96 + }
97 + }
98 +
99 + @Suppress("DEPRECATION")
100 + private fun stopForegroundCompat() {
101 + if (Build.VERSION.SDK_INT >= 24) stopForeground(STOP_FOREGROUND_REMOVE)
102 + else stopForeground(true)
103 + }
104 +
105 + private fun ensureChannels() {
106 + if (Build.VERSION.SDK_INT < 26) return
107 + notif.createNotificationChannel(
108 + NotificationChannel(CHANNEL_PROGRESS, "Uploads in progress", NotificationManager.IMPORTANCE_LOW)
109 + )
110 + notif.createNotificationChannel(
111 + NotificationChannel(CHANNEL_DONE, "Upload results", NotificationManager.IMPORTANCE_DEFAULT)
112 + )
113 + }
114 +
115 + private fun buildProgressNotification(currentIdx: Int, total: Int, ok: Int, failed: Int): Notification {
116 + val current = (currentIdx + 1).coerceAtMost(total)
117 + return NotificationCompat.Builder(this, CHANNEL_PROGRESS)
118 + .setSmallIcon(android.R.drawable.stat_sys_upload)
119 + .setContentTitle("Uploading to DMS")
120 + .setContentText("$current of $total — $ok ok, $failed failed")
121 + .setProgress(total, currentIdx, false)
122 + .setOngoing(true)
123 + .setOnlyAlertOnce(true)
124 + .build()
125 + }
126 +
127 + private fun buildDoneNotification(ok: Int, failed: Int): Notification {
128 + val text = when {
129 + failed == 0 -> "Uploaded $ok"
130 + ok == 0 -> "All $failed upload(s) failed"
131 + else -> "Uploaded $ok, $failed failed"
132 + }
133 + return NotificationCompat.Builder(this, CHANNEL_DONE)
134 + .setSmallIcon(android.R.drawable.stat_sys_upload_done)
135 + .setContentTitle("DMS upload complete")
136 + .setContentText(text)
137 + .setAutoCancel(true)
138 + .build()
139 + }
140 +
141 + companion object {
142 + private const val CHANNEL_PROGRESS = "dms_upload_progress"
143 + private const val CHANNEL_DONE = "dms_upload_done"
144 + private const val NOTIF_PROGRESS_ID = 1001
145 + private const val NOTIF_DONE_ID = 1002
146 +
147 + private const val EXTRA_BASE_URL = "base_url"
148 + private const val EXTRA_TOKEN = "token"
149 + private const val EXTRA_PATHS = "paths"
150 + private const val EXTRA_NAMES = "names"
151 + private const val EXTRA_MIMES = "mimes"
152 + private const val EXTRA_TAGS = "tags"
153 +
154 + fun start(
155 + context: Context,
156 + creds: TokenStore.Credentials,
157 + staged: List<StagedUpload>,
158 + tags: String?,
159 + ) {
160 + if (staged.isEmpty()) return
161 + val intent = Intent(context, UploadService::class.java).apply {
162 + putExtra(EXTRA_BASE_URL, creds.baseUrl)
163 + putExtra(EXTRA_TOKEN, creds.token)
164 + putExtra(EXTRA_PATHS, staged.map { it.path }.toTypedArray())
165 + putExtra(EXTRA_NAMES, staged.map { it.displayName }.toTypedArray())
166 + putExtra(EXTRA_MIMES, staged.map { it.mime }.toTypedArray())
167 + putExtra(EXTRA_TAGS, tags)
168 + }
169 + if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent)
170 + else context.startService(intent)
171 + }
172 + }
173 +}
174 +
175 +data class StagedUpload(val path: String, val displayName: String, val mime: String)
modified templates/root.html
+2 −2
@@ -69,8 +69,8 @@
69 69 </li>
70 70 <li>
71 71 <a href="/failed" class="btn btn-ghost btn-sm">
72 − <i class="fas fa-triangle-exclamation fa-fw"></i>
73 − <span class="hidden sm:inline">Failed</span>
72 + <i class="fas fa-gear fa-fw fa-spin"></i>
73 + <span class="hidden sm:inline">Work</span>
74 74 </a>
75 75 </li>
76 76 <li><span class="divider divider-horizontal mx-0"></span></li>

Parents: 7cd6e39