diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml
index fa5b7740ef..b2e245363b 100644
--- a/amethyst/src/main/AndroidManifest.xml
+++ b/amethyst/src/main/AndroidManifest.xml
@@ -54,6 +54,7 @@
+
@@ -373,6 +374,15 @@
android:stopWithTask="false"
android:exported="false" />
+
+
+
: Service() {
+ protected val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
+ private var watchJob: Job? = null
+
+ /** The Android 14+ `ServiceInfo.FOREGROUND_SERVICE_TYPE_*` this service runs as. */
+ protected abstract val fgsType: Int
+ protected abstract val channelId: String
+ protected abstract val channelNameRes: Int
+ protected abstract val channelDescRes: Int
+ protected abstract val notificationId: Int
+
+ /** The intent action that routes back here to cancel everything. */
+ protected abstract val cancelAction: String
+ protected abstract val cancelLabelRes: Int
+ protected open val smallIcon: Int = R.drawable.amethyst
+
+ /** When non-null, re-render the card on this cadence (for clock-driven text like "time left"). */
+ protected open val refreshMs: Long? = null
+
+ protected abstract fun state(): StateFlow
+
+ /** Keep the service (and notification) alive while this is true; stop once it goes false. */
+ protected abstract fun isActive(value: T): Boolean
+
+ protected abstract fun render(value: T): Content
+
+ /** Invoked by the cancel action. */
+ protected abstract fun cancelAll()
+
+ /** Called for every emission before [render]; use to update derived subclass state. */
+ protected open fun onEmission(value: T) {}
+
+ /** Only consulted for the [refreshMs] clock loop; skip re-renders when nothing is moving. */
+ protected open fun needsClockRefresh(value: T): Boolean = true
+
+ /** One-time setup once the watch loop starts (e.g. a benchmark). */
+ protected open fun onStarted() {}
+
+ /** How to draw the progress bar of the card. */
+ sealed interface Bar {
+ data object Indeterminate : Bar
+
+ /** A single bar filled to [fraction] in `0f..1f`. */
+ data class Determinate(
+ val fraction: Double,
+ ) : Bar
+
+ /** [total] equal segments, [done] of them filled — good for "N of M". */
+ data class Segmented(
+ val total: Int,
+ val done: Int,
+ ) : Bar
+ }
+
+ data class Content(
+ val title: String,
+ val text: String?,
+ val bar: Bar,
+ )
+
+ private val tapIntent: PendingIntent by lazy {
+ PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ },
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+ }
+
+ private val cancelIntent: PendingIntent by lazy {
+ PendingIntent.getService(
+ this,
+ 1,
+ Intent(this, this.javaClass).setAction(cancelAction),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(
+ intent: Intent?,
+ flags: Int,
+ startId: Int,
+ ): Int {
+ // Android's contract: every onStartCommand after startForegroundService must call
+ // startForeground promptly, even on the stop path.
+ runCatching { startForegroundCompat(state().value) }
+ .onFailure {
+ Log.w(logTag(), "startForeground failed; work continues without the service", it)
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ if (intent?.action == cancelAction) {
+ cancelAll()
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ watch()
+ return START_NOT_STICKY
+ }
+
+ /** Foreground-service budget exhausted (shortService ~3 min; dataSync on newer OS). Exit cleanly. */
+ override fun onTimeout(startId: Int) {
+ Log.d(logTag()) { "foreground-service budget exhausted; stopping" }
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+
+ override fun onDestroy() {
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ private fun watch() {
+ if (watchJob != null) return
+ onStarted()
+ watchJob =
+ scope.launch {
+ state().collect { value ->
+ onEmission(value)
+ if (!isActive(value)) {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ } else {
+ updateNotification(value)
+ }
+ }
+ }
+
+ refreshMs?.let { ms ->
+ scope.launch {
+ while (true) {
+ val v = state().value
+ if (isActive(v) && needsClockRefresh(v)) updateNotification(v)
+ delay(ms)
+ }
+ }
+ }
+ }
+
+ private fun startForegroundCompat(value: T) {
+ ensureChannel()
+ val notification = buildNotification(value)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ startForeground(notificationId, notification, fgsType)
+ } else {
+ startForeground(notificationId, notification)
+ }
+ }
+
+ private fun updateNotification(value: T) {
+ val manager = NotificationManagerCompat.from(this)
+ if (!manager.areNotificationsEnabled()) return
+ try {
+ manager.notify(notificationId, buildNotification(value))
+ } catch (_: SecurityException) {
+ // POST_NOTIFICATIONS revoked mid-flight; the FGS keeps running.
+ }
+ }
+
+ private fun buildNotification(value: T): Notification {
+ val content = render(value)
+ val style =
+ when (val bar = content.bar) {
+ is Bar.Indeterminate -> NotificationCompat.ProgressStyle().setProgressIndeterminate(true)
+ is Bar.Determinate ->
+ if (bar.fraction.isFinite() && bar.fraction in 0.0..1.0) {
+ NotificationCompat
+ .ProgressStyle()
+ .setProgressSegments(listOf(NotificationCompat.ProgressStyle.Segment(100)))
+ .setProgress((bar.fraction * 100).toInt())
+ } else {
+ NotificationCompat.ProgressStyle().setProgressIndeterminate(true)
+ }
+ is Bar.Segmented ->
+ NotificationCompat
+ .ProgressStyle()
+ .setProgressSegments(List(bar.total.coerceAtLeast(1)) { NotificationCompat.ProgressStyle.Segment(1) })
+ .setProgress(bar.done)
+ }
+
+ return NotificationCompat
+ .Builder(this, channelId)
+ .setSmallIcon(smallIcon)
+ .setContentTitle(content.title)
+ .setContentText(content.text)
+ .setStyle(style)
+ .setContentIntent(tapIntent)
+ .addAction(0, stringRes(this, cancelLabelRes), cancelIntent)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setCategory(NotificationCompat.CATEGORY_PROGRESS)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
+ .build()
+ }
+
+ private fun ensureChannel() {
+ val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+ if (manager.getNotificationChannel(channelId) != null) return
+ manager.createNotificationChannel(
+ NotificationChannel(channelId, stringRes(this, channelNameRes), NotificationManager.IMPORTANCE_LOW).apply {
+ description = stringRes(this@FlowProgressForegroundService, channelDescRes)
+ setShowBadge(false)
+ },
+ )
+ }
+
+ protected open fun logTag(): String = this.javaClass.simpleName
+
+ companion object {
+ /**
+ * Best-effort start of a [FlowProgressForegroundService] subclass. A start from the
+ * background (e.g. a restore) may be denied — the work then proceeds unprotected and
+ * the service starts on the next foreground trigger.
+ */
+ fun start(
+ context: Context,
+ clazz: Class>,
+ tag: String,
+ ) {
+ try {
+ context.startForegroundService(Intent(context, clazz))
+ } catch (e: Exception) {
+ Log.w(tag, "Could not start foreground service (backgrounded?); work continues unprotected", e)
+ }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt
index 36fe373b50..d61ea2c8ae 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt
@@ -20,34 +20,17 @@
*/
package com.vitorpamplona.amethyst.service.pow
-import android.app.Notification
-import android.app.NotificationChannel
-import android.app.NotificationManager
-import android.app.PendingIntent
-import android.app.Service
import android.content.Context
-import android.content.Intent
import android.content.pm.ServiceInfo
-import android.os.Build
-import android.os.IBinder
-import androidx.core.app.NotificationCompat
-import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState
-import com.vitorpamplona.amethyst.ui.MainActivity
+import com.vitorpamplona.amethyst.service.foreground.FlowProgressForegroundService
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
-import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
@@ -56,161 +39,72 @@ import kotlinx.coroutines.launch
* finish mining even after the user backgrounds the app.
*
* Uses the Android 14+ `shortService` type — no special permission, but a
- * hard ~3 minute budget. On [onTimeout] the service exits cleanly; every
+ * hard ~3 minute budget. On `onTimeout` the service exits cleanly; every
* persistable job is already checkpointed by [PowJobStore], so anything still
* unmined resumes on the next app launch. Started on every enqueue (the app
* is necessarily in the foreground then), stops itself when the queue drains.
*
- * The notification is a live progress card ([NotificationCompat.ProgressStyle]):
- * one track segment per post, filling as jobs complete, indeterminate while a
- * single post mines, with a cancel-all action. On Android 16+ it renders as a
- * Live Updates chip; older versions fall back to a standard progress bar.
+ * The notification card (a live [androidx.core.app.NotificationCompat.ProgressStyle]) and all the
+ * service lifecycle live in [FlowProgressForegroundService]; this subclass only maps mining state
+ * to that card.
*/
-class PowMiningForegroundService : Service() {
- private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
- private var watchJob: Job? = null
+class PowMiningForegroundService : FlowProgressForegroundService>() {
+ override val fgsType: Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE
+ override val channelId = CHANNEL_ID
+ override val channelNameRes = R.string.pow_notification_channel_name
+ override val channelDescRes = R.string.pow_notification_channel_description
+ override val notificationId = NOTIFICATION_ID
+ override val cancelAction = ACTION_CANCEL_ALL
+ override val cancelLabelRes = R.string.pow_notification_cancel_all
- // Session totals so the progress track can show "done / enqueued since the
- // service started" — the queue itself only knows what is still pending.
+ // clock-driven refresh for the time-left text and bar; the shortService budget (~3 min)
+ // caps this at a handful of updates.
+ override val refreshMs: Long = PROGRESS_REFRESH_MS
+
+ // Session totals so the progress track can show "done / enqueued since the service
+ // started" — the queue itself only knows what is still pending.
private var sessionTotal = 0
private var lastQueueSize = 0
- // Benchmarked once per service run (~250 ms, cached by the estimator);
- // read from the notification builder to compute expected durations.
+ // Benchmarked once per service run (~250 ms, cached by the estimator).
@Volatile
private var hashRate: Double? = null
- // Built once per service instance: the intents never change, and
- // buildNotification runs on every queue update.
- private val tapIntent: PendingIntent by lazy {
- PendingIntent.getActivity(
- this,
- 0,
- Intent(this, MainActivity::class.java).apply {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
- },
- PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
- )
- }
-
- private val cancelIntent: PendingIntent by lazy {
- PendingIntent.getService(
- this,
- 1,
- Intent(this, PowMiningForegroundService::class.java).setAction(ACTION_CANCEL_ALL),
- PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
- )
- }
-
override fun onCreate() {
super.onCreate()
running = true
}
- override fun onBind(intent: Intent?): IBinder? = null
-
- override fun onStartCommand(
- intent: Intent?,
- flags: Int,
- startId: Int,
- ): Int {
- // Android's contract: every onStartCommand after startForegroundService
- // must call startForeground promptly, even on the stop path.
- runCatching { startForegroundCompat(currentJobs()) }
- .onFailure {
- Log.w(TAG, "startForeground failed; mining continues without the service", it)
- stopSelf()
- return START_NOT_STICKY
- }
-
- if (intent?.action == ACTION_CANCEL_ALL) {
- Amethyst.instance.powPublishQueue.cancelAll()
- stopForeground(STOP_FOREGROUND_REMOVE)
- stopSelf()
- return START_NOT_STICKY
- }
-
- watchQueue()
- return START_NOT_STICKY
- }
-
- /**
- * The shortService budget (~3 min) is exhausted. Exit before the system
- * ANRs us: persisted jobs are checkpointed and resume on next launch;
- * in-memory jobs keep mining opportunistically until the process freezes.
- */
- override fun onTimeout(startId: Int) {
- Log.d(TAG) { "shortService budget exhausted; ${currentJobs().size} job(s) left to resume later" }
- stopForeground(STOP_FOREGROUND_REMOVE)
- stopSelf()
- }
-
override fun onDestroy() {
running = false
- scope.cancel()
super.onDestroy()
}
- private fun currentJobs(): ImmutableList = Amethyst.instance.powPublishQueue.jobs.value
+ override fun state() = Amethyst.instance.powPublishQueue.jobs
- private fun watchQueue() {
- if (watchJob != null) return
- watchJob =
- scope.launch {
- Amethyst.instance.powPublishQueue.jobs.collect { jobs ->
- if (jobs.size > lastQueueSize) sessionTotal += jobs.size - lastQueueSize
- lastQueueSize = jobs.size
+ override fun isActive(value: ImmutableList) = value.isNotEmpty()
- if (jobs.isEmpty()) {
- stopForeground(STOP_FOREGROUND_REMOVE)
- stopSelf()
- } else {
- updateNotification(jobs)
- }
- }
- }
+ override fun cancelAll() = Amethyst.instance.powPublishQueue.cancelAll()
- // the estimated-time-left figure and progress fraction only move with
- // the clock, not with queue events: benchmark the hash rate once,
- // then refresh the card periodically while something is mining.
- scope.launch {
- hashRate = PoWEstimator.hashesPerSecond()
- while (true) {
- val jobs = currentJobs()
- if (jobs.any { it.isMining }) updateNotification(jobs)
- delay(PROGRESS_REFRESH_MS)
- }
- }
+ override fun needsClockRefresh(value: ImmutableList) = value.any { it.isMining }
+
+ override fun onStarted() {
+ scope.launch { hashRate = PoWEstimator.hashesPerSecond() }
}
- private fun startForegroundCompat(jobs: ImmutableList) {
- ensureChannel(this)
- val notification = buildNotification(jobs)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
- startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE)
- } else {
- startForeground(NOTIFICATION_ID, notification)
- }
+ override fun onEmission(value: ImmutableList) {
+ if (value.size > lastQueueSize) sessionTotal += value.size - lastQueueSize
+ lastQueueSize = value.size
}
- private fun updateNotification(jobs: ImmutableList) {
- val manager = NotificationManagerCompat.from(this)
- if (!manager.areNotificationsEnabled()) return
- try {
- manager.notify(NOTIFICATION_ID, buildNotification(jobs))
- } catch (_: SecurityException) {
- // POST_NOTIFICATIONS revoked mid-flight; the FGS keeps running.
- }
- }
+ override fun render(value: ImmutableList): Content {
+ val done = (sessionTotal - value.size).coerceAtLeast(0)
+ val total = (done + value.size).coerceAtLeast(1)
- private fun buildNotification(jobs: ImmutableList): Notification {
- val done = (sessionTotal - jobs.size).coerceAtLeast(0)
- val total = (done + jobs.size).coerceAtLeast(1)
+ val current = value.firstOrNull { it.isMining } ?: value.firstOrNull()
- val current = jobs.firstOrNull { it.isMining } ?: jobs.firstOrNull()
-
- // expected duration for the job being mined right now, so the card can
- // say "≈ 10 minutes left" and fill its bar toward a predictable end.
+ // expected duration for the job being mined right now, so the card can say
+ // "≈ 10 minutes left" and fill its bar toward a predictable end.
val rate = hashRate
val startedAt = current?.miningStartedAt
val expectedSec = if (current != null && rate != null) PoWEstimator.estimateSeconds(current.difficulty, rate) else null
@@ -229,44 +123,23 @@ class PowMiningForegroundService : Service() {
val fraction = if (expectedSec != null && elapsedSec != null) elapsedSec / expectedSec else null
- val progressStyle: NotificationCompat.ProgressStyle =
- if (total <= 1) {
- // single post: fill toward the estimated duration; past the
- // mean the search is memoryless, so sweep instead of lying.
- if (fraction != null && fraction < 1.0) {
- NotificationCompat
- .ProgressStyle()
- .setProgressSegments(listOf(NotificationCompat.ProgressStyle.Segment(100)))
- .setProgress((fraction * 100).toInt())
- } else {
- NotificationCompat.ProgressStyle().setProgressIndeterminate(true)
- }
+ val title =
+ if (value.size > 1) {
+ pluralStringRes(this, R.plurals.pow_mining_progress, value.size, value.size)
} else {
- NotificationCompat
- .ProgressStyle()
- .setProgressSegments(List(total) { NotificationCompat.ProgressStyle.Segment(1) })
- .setProgress(done)
+ stringRes(this, R.string.pow_mining_title)
}
- return NotificationCompat
- .Builder(this, CHANNEL_ID)
- .setSmallIcon(R.drawable.amethyst)
- .setContentTitle(
- if (jobs.size > 1) {
- pluralStringRes(this, R.plurals.pow_mining_progress, jobs.size, jobs.size)
- } else {
- stringRes(this, R.string.pow_mining_title)
- },
- ).setContentText(text)
- .setStyle(progressStyle)
- .setContentIntent(tapIntent)
- .addAction(0, stringRes(this, R.string.pow_notification_cancel_all), cancelIntent)
- .setOngoing(true)
- .setOnlyAlertOnce(true)
- .setCategory(NotificationCompat.CATEGORY_PROGRESS)
- .setPriority(NotificationCompat.PRIORITY_LOW)
- .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
- .build()
+ val bar =
+ if (total <= 1) {
+ // single post: fill toward the estimated duration; past the mean the search is
+ // memoryless, so sweep instead of lying.
+ if (fraction != null && fraction < 1.0) Bar.Determinate(fraction) else Bar.Indeterminate
+ } else {
+ Bar.Segmented(total, done)
+ }
+
+ return Content(title, text, bar)
}
companion object {
@@ -275,45 +148,19 @@ class PowMiningForegroundService : Service() {
private const val NOTIFICATION_ID = 0x504F57 // "POW"
private const val ACTION_CANCEL_ALL = "com.vitorpamplona.amethyst.pow.CANCEL_ALL"
- // clock-driven refresh cadence for the time-left text and bar; the
- // shortService budget (~3 min) caps this at a handful of updates.
private const val PROGRESS_REFRESH_MS = 30_000L
- // Best-effort de-dup for start(): the queue calls it on EVERY enqueue,
- // and each call otherwise round-trips through system_server. A stale
- // false only costs one redundant startForegroundService (which Android
- // routes to the existing instance's onStartCommand anyway).
+ // Best-effort de-dup for start(): the queue calls it on EVERY enqueue.
@Volatile
private var running = false
/**
- * Best-effort start: enqueue happens while the user is interacting
- * with the app, so the foreground-start allowance normally holds. A
- * restore during a cold background launch may be denied — mining then
- * proceeds unprotected and the service starts on the next enqueue.
+ * Best-effort start: enqueue happens while the user is interacting with the app,
+ * so the foreground-start allowance normally holds.
*/
fun start(context: Context) {
if (running) return
- try {
- context.startForegroundService(Intent(context, PowMiningForegroundService::class.java))
- } catch (e: Exception) {
- Log.w(TAG, "Could not start mining foreground service (backgrounded?); mining continues unprotected", e)
- }
- }
-
- private fun ensureChannel(context: Context) {
- val manager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
- if (manager.getNotificationChannel(CHANNEL_ID) != null) return
- manager.createNotificationChannel(
- NotificationChannel(
- CHANNEL_ID,
- stringRes(context, R.string.pow_notification_channel_name),
- NotificationManager.IMPORTANCE_LOW,
- ).apply {
- description = stringRes(context, R.string.pow_notification_channel_description)
- setShowBadge(false)
- },
- )
+ FlowProgressForegroundService.start(context, PowMiningForegroundService::class.java, TAG)
}
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomMirrorQueue.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomMirrorQueue.kt
index fb637725f0..5437fb0054 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomMirrorQueue.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomMirrorQueue.kt
@@ -68,6 +68,8 @@ data class BlossomMirrorResult(
*/
class BlossomMirrorQueue(
private val scope: CoroutineScope,
+ /** Invoked (on the foreground thread that called [start]) when a sweep begins, to start the FGS. */
+ private val onActive: () -> Unit = {},
) {
data class Task(
val hash: HexKey,
@@ -95,11 +97,15 @@ class BlossomMirrorQueue(
val work = tasks.flatMap { t -> t.targets.map { t to it } }
if (work.isEmpty()) return
+ // Publish the active state and start the foreground service synchronously (we're on the
+ // foreground thread here, which is what dataSync FGS starts require) before the sweep runs.
+ _state.value = BlossomSyncState(total = work.size, done = 0, failed = 0, running = true)
+ onActive()
+
job =
scope.launch {
var done = 0
var failed = 0
- _state.value = BlossomSyncState(total = work.size, done = 0, failed = 0, running = true)
for ((task, target) in work) {
_state.value = _state.value?.copy(currentHost = BlossomServerUrl.domain(target))
val ok =
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomSyncForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomSyncForegroundService.kt
new file mode 100644
index 0000000000..e5a3739883
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomSyncForegroundService.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.uploads.blossom
+
+import android.content.Context
+import android.content.pm.ServiceInfo
+import com.vitorpamplona.amethyst.Amethyst
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.service.foreground.FlowProgressForegroundService
+import com.vitorpamplona.amethyst.ui.stringRes
+
+/**
+ * Foreground service that keeps the BUD-04 "sync all" sweep ([BlossomMirrorQueue]) running
+ * while the app is backgrounded. Uses the Android 14+ `dataSync` type — the correct type for
+ * an upload/download/sync operation (a multi-file mirror routinely exceeds the `shortService`
+ * ~3-minute budget that PoW mining uses).
+ *
+ * `dataSync` must be started while the app is foreground; "Sync all" is user-initiated from the
+ * manager, so that holds. All of the notification + lifecycle lives in the shared
+ * [FlowProgressForegroundService]; this maps the sweep's [BlossomSyncState] onto the card.
+ */
+class BlossomSyncForegroundService : FlowProgressForegroundService() {
+ override val fgsType: Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
+ override val channelId = CHANNEL_ID
+ override val channelNameRes = R.string.blossom_sync_channel_name
+ override val channelDescRes = R.string.blossom_sync_channel_description
+ override val notificationId = NOTIFICATION_ID
+ override val cancelAction = ACTION_CANCEL
+ override val cancelLabelRes = R.string.blossom_sync_cancel
+
+ override fun onCreate() {
+ super.onCreate()
+ running = true
+ }
+
+ override fun onDestroy() {
+ running = false
+ super.onDestroy()
+ }
+
+ override fun state() = Amethyst.instance.blossomMirrorQueue.state
+
+ override fun isActive(value: BlossomSyncState?) = value?.running == true
+
+ override fun cancelAll() = Amethyst.instance.blossomMirrorQueue.cancel()
+
+ // State emits on every mirror step (including currentHost changes), so no clock refresh.
+ override val refreshMs: Long? = null
+
+ override fun render(value: BlossomSyncState?): Content {
+ if (value == null) return Content(stringRes(this, R.string.blossom_syncing), null, Bar.Indeterminate)
+ val text =
+ buildString {
+ append("${value.done} / ${value.total}")
+ if (value.currentHost != null) append(" · ${value.currentHost}")
+ if (value.failed > 0) append(" · ${value.failed} failed")
+ }
+ return Content(stringRes(this, R.string.blossom_syncing), text, Bar.Determinate(value.fraction.toDouble()))
+ }
+
+ companion object {
+ private const val TAG = "BlossomSyncFgs"
+ private const val CHANNEL_ID = "blossom_sync"
+ private const val NOTIFICATION_ID = 0x424C4F // "BLO"
+ private const val ACTION_CANCEL = "com.vitorpamplona.amethyst.blossom.SYNC_CANCEL"
+
+ @Volatile
+ private var running = false
+
+ /** Started when a sweep begins (from the foreground); stops itself when it finishes. */
+ fun start(context: Context) {
+ if (running) return
+ FlowProgressForegroundService.start(context, BlossomSyncForegroundService::class.java, TAG)
+ }
+ }
+}
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 0b39fe1c5c..2dfb9d6327 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -1557,6 +1557,9 @@
Some of your files aren\'t on all your servers yet.
Copying your files across servers…
Sync complete
+ Cancel
+ Blossom sync
+ Shows progress while copying your files across your Blossom servers.
No stored files found on your Blossom servers.
Mirror to missing
Delete from…