⭐
Log in to mark this repository.
android background worker
758983a · gmorell · 2026-05-09 03:25
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