feat(android): task notifications via periodic workflow poll

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>
pull/3589/head
Tomas Dvorak 2 weeks ago
parent 575707bd43
commit 0c7119b2a6

@ -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)

@ -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.

@ -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)
}
}

@ -72,6 +72,12 @@ interface ApiService {
@Query("offset") offset: Int = 0,
): Response<ApiResponse<SearchResponse>>
@GET("api/v4/workflow")
suspend fun listTasks(
@Query("page_size") pageSize: Int = 20,
@Query("category") category: String = "general",
): Response<ApiResponse<TaskListResponse>>
@PUT("api/v4/share")
suspend fun createShare(@Body request: ShareCreateRequest): Response<ApiResponse<String>>
}

@ -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<TaskItem> = emptyList(),
)

@ -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<String, String> = emptyMap())
private object Keys {
val enabled = booleanPreferencesKey("enabled")
val seen = stringPreferencesKey("seen")
}
val enabled: Flow<Boolean> = 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<String, String> =
runCatching {
json.decodeFromString<Seen>(
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<Seen>(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
}
}

@ -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 = {

@ -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<TaskPollWorker>(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<TaskPollWorker>().setConstraints(constraints).build(),
)
}
fun cancel(context: Context) {
val wm = WorkManager.getInstance(context)
wm.cancelUniqueWork(WORK_NAME)
wm.cancelUniqueWork("$WORK_NAME.once")
}
}
}
Loading…
Cancel
Save