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
parent
575707bd43
commit
0c7119b2a6
@ -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
|
||||
}
|
||||
}
|
||||
@ -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…
Reference in new issue