From 0c7119b2a65ae0f4ba3b7b936f61ec44cef8fc8a Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sun, 20 Sep 2026 12:55:44 +0200 Subject: [PATCH] feat(android): task notifications via periodic workflow poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TaskPollWorker queries /workflow (general + downloaded categories) every 15 minutes and posts a notification when a task transitions to a terminal state. A persisted id→status map drives the diff; the first run only seeds it, so enabling doesn't replay history. Toggle lives in the settings dialog and requests POST_NOTIFICATIONS on API 33+. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP.md | 2 +- android/README.md | 5 +- .../org/cloudreve/android/CloudreveApp.kt | 4 + .../org/cloudreve/android/api/ApiService.kt | 6 + .../java/org/cloudreve/android/api/Dto.kt | 14 +++ .../android/data/TaskNotifySettings.kt | 71 +++++++++++ .../cloudreve/android/ui/files/FilesScreen.kt | 41 +++++++ .../cloudreve/android/work/TaskPollWorker.kt | 111 ++++++++++++++++++ 8 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/org/cloudreve/android/data/TaskNotifySettings.kt create mode 100644 android/app/src/main/java/org/cloudreve/android/work/TaskPollWorker.kt diff --git a/ROADMAP.md b/ROADMAP.md index 59f2c4ea..616f7310 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -241,7 +241,7 @@ Lives in `android/` in this repo. Kotlin + Jetpack Compose, Material 3. - **API**: `api/v4` REST + OAuth token (entities exist: `oauthclient`, `oauthgrant`) — same surface the desktop `cloudreve-api` crate documents; port its models as the spec - **Core features**: browse/download/upload files, share links, full-text search (done — query bar + offset pagination + parent-path/snippet rows), camera-upload (done — periodic WorkManager MediaStore sync, Wi-Fi-only constraint, ID dedup, settings dialog), offline-favorite files (done — "Keep offline" downloads to filesDir/offline, DataStore registry, star dialog with open/refresh/remove), local sync folder via SAF/WorkManager -- **System integration** (the "native, complete" ask): share-sheet target (done — SEND/SEND_MULTIPLE → UploadWorker), DocumentsProvider (done — Files-app browse/open/thumb/rename/delete/search), quick-share tile, notifications on share/task events +- **System integration** (the "native, complete" ask): share-sheet target (done — SEND/SEND_MULTIPLE → UploadWorker), DocumentsProvider (done — Files-app browse/open/thumb/rename/delete/search), quick-share tile, notifications on share/task events (done — periodic /workflow poll → terminal-state notifications) - **Auth**: webview OAuth flow → token; later passkey if backend exposes - **WebDAV bridge**: `/dav` works as fallback file access until SDK matures - Non-goals: iOS, tablet-first layouts (works, not optimized) diff --git a/android/README.md b/android/README.md index f446fad2..241374fb 100644 --- a/android/README.md +++ b/android/README.md @@ -41,8 +41,11 @@ CI runs `assembleDebug` on every PR. - DocumentsProvider: the whole tree appears in the system Files app / SAF pickers — browse, open (cached download), thumbnails, rename, delete, and search all proxy to `api/v4` +- Task notifications: periodic `GET /workflow` poll posts a + notification when a task completes/fails/cancels — toggle in the + settings dialog, runtime POST_NOTIFICATIONS permission gated ## Planned next -- Quick-share tile, task/share notifications, local sync folder +- Quick-share tile, local sync folder - No iOS. Ever. diff --git a/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt b/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt index af6e2c4d..834630d5 100644 --- a/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt +++ b/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt @@ -6,6 +6,7 @@ import org.cloudreve.android.data.CameraUploadSettings import org.cloudreve.android.data.FavoritesStore import org.cloudreve.android.data.FileRepository import org.cloudreve.android.data.SessionManager +import org.cloudreve.android.data.TaskNotifySettings class CloudreveApp : Application() { @@ -18,6 +19,8 @@ class CloudreveApp : Application() { lateinit var cameraUploadSettings: CameraUploadSettings lateinit var favoritesStore: FavoritesStore private set + lateinit var taskNotifySettings: TaskNotifySettings + private set override fun onCreate() { super.onCreate() @@ -26,5 +29,6 @@ class CloudreveApp : Application() { fileRepository = FileRepository(apiClient) cameraUploadSettings = CameraUploadSettings(this) favoritesStore = FavoritesStore(this) + taskNotifySettings = TaskNotifySettings(this) } } diff --git a/android/app/src/main/java/org/cloudreve/android/api/ApiService.kt b/android/app/src/main/java/org/cloudreve/android/api/ApiService.kt index 4cc8ef0e..71ffb881 100644 --- a/android/app/src/main/java/org/cloudreve/android/api/ApiService.kt +++ b/android/app/src/main/java/org/cloudreve/android/api/ApiService.kt @@ -72,6 +72,12 @@ interface ApiService { @Query("offset") offset: Int = 0, ): Response> + @GET("api/v4/workflow") + suspend fun listTasks( + @Query("page_size") pageSize: Int = 20, + @Query("category") category: String = "general", + ): Response> + @PUT("api/v4/share") suspend fun createShare(@Body request: ShareCreateRequest): Response> } diff --git a/android/app/src/main/java/org/cloudreve/android/api/Dto.kt b/android/app/src/main/java/org/cloudreve/android/api/Dto.kt index 5e34ee2c..285e39be 100644 --- a/android/app/src/main/java/org/cloudreve/android/api/Dto.kt +++ b/android/app/src/main/java/org/cloudreve/android/api/Dto.kt @@ -192,3 +192,17 @@ data class SiteConfig( @SerialName("tos_url") val tosUrl: String = "", @SerialName("privacy_policy_url") val privacyPolicyUrl: String = "", ) + +@Serializable +data class TaskItem( + val id: String = "", + val status: String = "", + val type: String = "", + val error: String? = null, + @SerialName("updated_at") val updatedAt: String = "", +) + +@Serializable +data class TaskListResponse( + val tasks: List = emptyList(), +) diff --git a/android/app/src/main/java/org/cloudreve/android/data/TaskNotifySettings.kt b/android/app/src/main/java/org/cloudreve/android/data/TaskNotifySettings.kt new file mode 100644 index 00000000..d1b273a9 --- /dev/null +++ b/android/app/src/main/java/org/cloudreve/android/data/TaskNotifySettings.kt @@ -0,0 +1,71 @@ +package org.cloudreve.android.data + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private val Context.taskNotifyStore by preferencesDataStore(name = "task_notify") + +/** + * Task-notification preferences: the on/off toggle plus a map of + * task id → last seen status, so TaskPollWorker only notifies on + * transitions to a terminal state. The map is pruned to the newest + * entries to stay small. + */ +class TaskNotifySettings(private val context: Context) { + + private val json = Json { ignoreUnknownKeys = true } + + @Serializable + private data class Seen(val map: Map = emptyMap()) + + private object Keys { + val enabled = booleanPreferencesKey("enabled") + val seen = stringPreferencesKey("seen") + } + + val enabled: Flow = context.taskNotifyStore.data.map { + it[Keys.enabled] ?: false + } + + suspend fun enabledNow(): Boolean = enabled.first() + + suspend fun setEnabled(enabled: Boolean) { + context.taskNotifyStore.edit { it[Keys.enabled] = enabled } + } + + suspend fun seenNow(): Map = + runCatching { + json.decodeFromString( + context.taskNotifyStore.data.first()[Keys.seen] ?: "{}" + ).map + }.getOrDefault(emptyMap()) + + suspend fun markSeen(id: String, status: String) { + context.taskNotifyStore.edit { prefs -> + val map = runCatching { + json.decodeFromString(prefs[Keys.seen] ?: "{}").map + }.getOrDefault(emptyMap()).toMutableMap() + map.remove(id) + map[id] = status + if (map.size > MAX_SEEN) { + // Keep the newest tail; insertion order preserved. + val trimmed = map.entries.toList().takeLast(MAX_SEEN).associate { it.toPair() } + map.clear(); map.putAll(trimmed) + } + prefs[Keys.seen] = json.encodeToString(Seen(map)) + } + } + + companion object { + private const val MAX_SEEN = 200 + } +} diff --git a/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt b/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt index 939ee1f4..ce0c5442 100644 --- a/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt +++ b/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt @@ -83,6 +83,7 @@ import org.cloudreve.android.data.CameraUploadSettings import org.cloudreve.android.data.FavoriteEntry import org.cloudreve.android.util.CrUri import org.cloudreve.android.work.CameraUploadWorker +import org.cloudreve.android.work.TaskPollWorker import org.cloudreve.android.work.UploadWorker import java.text.DecimalFormat @@ -559,12 +560,25 @@ private fun CameraUploadDialog(onDismiss: () -> Unit) { val context = LocalContext.current val app = context.applicationContext as CloudreveApp val settings = app.cameraUploadSettings + val notifySettings = app.taskNotifySettings val scope = rememberCoroutineScope() val snap by settings.snapshot.collectAsState(initial = null) + val notifyEnabled by notifySettings.enabled.collectAsState(initial = false) var folderText by remember(snap?.remoteFolder) { mutableStateOf(snap?.remoteFolder ?: CameraUploadSettings.DEFAULT_FOLDER) } + val notifPermLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) { + scope.launch { + notifySettings.setEnabled(true) + TaskPollWorker.apply(context) + } + } + } + val permLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { grants -> @@ -644,6 +658,33 @@ private fun CameraUploadDialog(onDismiss: () -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + HorizontalDivider() + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Notify on finished tasks", Modifier.weight(1f)) + Switch( + checked = notifyEnabled, + onCheckedChange = { want -> + if (want) { + if (android.os.Build.VERSION.SDK_INT >= 33) { + notifPermLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) + } else { + scope.launch { + notifySettings.setEnabled(true) + TaskPollWorker.apply(context) + } + } + } else { + scope.launch { + notifySettings.setEnabled(false) + TaskPollWorker.cancel(context) + } + } + }, + ) + } } }, confirmButton = { diff --git a/android/app/src/main/java/org/cloudreve/android/work/TaskPollWorker.kt b/android/app/src/main/java/org/cloudreve/android/work/TaskPollWorker.kt new file mode 100644 index 00000000..8486b3e2 --- /dev/null +++ b/android/app/src/main/java/org/cloudreve/android/work/TaskPollWorker.kt @@ -0,0 +1,111 @@ +package org.cloudreve.android.work + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import org.cloudreve.android.CloudreveApp +import java.util.concurrent.TimeUnit + +/** + * Periodically polls /workflow for the user's tasks and posts a + * notification when a task reaches a terminal state (completed, error, + * canceled). The first run after enabling only seeds the seen-map so + * history doesn't flood the shade. + */ +class TaskPollWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val app = applicationContext as CloudreveApp + val settings = app.taskNotifySettings + if (!settings.enabledNow()) return Result.success() + + createChannel() + + val seen = settings.seenNow() + val tasks = runCatching { + app.apiClient.service().listTasks(pageSize = 20, category = "general") + .body()?.data?.tasks.orEmpty() + + app.apiClient.service().listTasks(pageSize = 20, category = "downloaded") + .body()?.data?.tasks.orEmpty() + }.getOrDefault(emptyList()) + + val fresh = seen.isNotEmpty() + tasks.forEach { t -> + val previous = seen[t.id] + if (previous == t.status) return@forEach + settings.markSeen(t.id, t.status) + if (fresh && t.status in TERMINAL) { + notify(t.id, t.type, t.status, t.error) + } + } + return Result.success() + } + + private fun notify(id: String, type: String, status: String, error: String?) { + if (!NotificationManagerCompat.from(applicationContext).areNotificationsEnabled()) return + val label = type.substringAfterLast('.').replace('_', ' ') + val text = when (status) { + "completed" -> "$label completed" + "canceled" -> "$label canceled" + else -> "$label failed" + (error?.let { ": $it" } ?: "") + } + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentTitle("Cloudreve task") + .setContentText(text) + .setAutoCancel(true) + .build() + NotificationManagerCompat.from(applicationContext) + .notify(id.hashCode(), notification) + } + + private fun createChannel() { + val manager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Task updates", NotificationManager.IMPORTANCE_DEFAULT) + ) + } + + companion object { + private const val WORK_NAME = "task_poll_periodic" + private const val CHANNEL_ID = "task_updates" + private val TERMINAL = setOf("completed", "error", "canceled") + + private val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + fun apply(context: Context) { + val wm = WorkManager.getInstance(context) + val periodic = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(constraints) + .build() + wm.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, periodic) + // Prime the seen-map immediately so enabling doesn't notify on history. + wm.enqueueUniqueWork( + "$WORK_NAME.once", + ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder().setConstraints(constraints).build(), + ) + } + + fun cancel(context: Context) { + val wm = WorkManager.getInstance(context) + wm.cancelUniqueWork(WORK_NAME) + wm.cancelUniqueWork("$WORK_NAME.once") + } + } +}