refactor(fgs): extract FlowProgressForegroundService; add dataSync Blossom sync service

Generalizes the shared foreground-service scaffolding (channel setup, start/stop-
when-idle over a StateFlow, the ProgressStyle notification skeleton, tap + cancel
intents, onTimeout) into an abstract FlowProgressForegroundService<T>. Kept as a
base — NOT one mega-service — because Android 14+ binds foregroundServiceType to
behavior, so each workload keeps its own subclass with the correct type.

- FlowProgressForegroundService: the shared base.
- PowMiningForegroundService: refactored onto the base (still shortService); mining
  state maps to the card via render(); behavior preserved.
- BlossomSyncForegroundService: new, typed dataSync (the right type for a multi-file
  sync that exceeds shortService's ~3-min budget), bound to blossomMirrorQueue.state.
  Started (from the foreground, as dataSync requires) when a "Sync all" sweep begins,
  so it survives backgrounding; stops itself when done.
- BlossomMirrorQueue: publishes the active state and fires onActive() synchronously so
  the FGS starts with live state; wired in AppModules.
- Manifest: FOREGROUND_SERVICE_DATA_SYNC permission + the service declaration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
This commit is contained in:
Claude
2026-07-18 04:33:10 +00:00
parent 4845e95c9f
commit 5d54a0ff90
7 changed files with 473 additions and 209 deletions
+10
View File
@@ -54,6 +54,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- Phone calls -->
@@ -373,6 +374,15 @@
android:stopWithTask="false"
android:exported="false" />
<!-- Keeps the "sync all blobs to all servers" (BUD-04) sweep alive while backgrounded.
dataSync is the correct type for an upload/download/sync; it must be started from the
foreground, which "Sync all" (user-initiated in the manager) satisfies. -->
<service
android:name=".service.uploads.blossom.BlossomSyncForegroundService"
android:foregroundServiceType="dataSync"
android:stopWithTask="false"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
@@ -108,6 +108,7 @@ import com.vitorpamplona.amethyst.service.safeCacheDir
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorkGate
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomSyncForegroundService
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
@@ -797,7 +798,12 @@ class AppModules(
}
/** App-level BUD-04 mirror sweep, so "sync all" keeps running as the user navigates. */
val blossomMirrorQueue by lazy { BlossomMirrorQueue(applicationIOScope) }
val blossomMirrorQueue by lazy {
BlossomMirrorQueue(
scope = applicationIOScope,
onActive = { BlossomSyncForegroundService.start(appContext) },
)
}
val powJobRestorer by lazy {
PowJobRestorer(powPublishQueue, powJobStore, scheduledPostStore)
@@ -0,0 +1,298 @@
/*
* 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.foreground
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.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
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.flow.StateFlow
import kotlinx.coroutines.launch
/**
* Shared scaffolding for a foreground service that shields a background job from the
* cached-apps freezer while it runs, rendering a live [NotificationCompat.ProgressStyle]
* progress card driven by a [StateFlow].
*
* This is deliberately NOT a single "do everything" service: Android 14+ binds
* `foregroundServiceType` to the service's actual behavior (and each type carries its
* own permission, budget and start rules), so each workload keeps its own concrete
* subclass with the correct [fgsType]. What's shared here is only the boilerplate —
* channel setup, start/stop-when-idle, the notification skeleton, tap + cancel intents,
* and `onTimeout` — parameterized by a handful of hooks.
*
* Subclasses supply the [fgsType], the [state] flow to watch, [isActive] to decide when
* to stop, [render] to build the card, and [cancelAll] for the cancel action. See
* `PowMiningForegroundService` (shortService) and `BlossomSyncForegroundService`
* (dataSync) for the two consumers.
*/
abstract class FlowProgressForegroundService<T> : 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<T>
/** 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<out FlowProgressForegroundService<*>>,
tag: String,
) {
try {
context.startForegroundService(Intent(context, clazz))
} catch (e: Exception) {
Log.w(tag, "Could not start foreground service (backgrounded?); work continues unprotected", e)
}
}
}
}
@@ -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<ImmutableList<PoWJobState>>() {
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<PoWJobState> = 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<PoWJobState>) = 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<PoWJobState>) = value.any { it.isMining }
override fun onStarted() {
scope.launch { hashRate = PoWEstimator.hashesPerSecond() }
}
private fun startForegroundCompat(jobs: ImmutableList<PoWJobState>) {
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<PoWJobState>) {
if (value.size > lastQueueSize) sessionTotal += value.size - lastQueueSize
lastQueueSize = value.size
}
private fun updateNotification(jobs: ImmutableList<PoWJobState>) {
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<PoWJobState>): Content {
val done = (sessionTotal - value.size).coerceAtLeast(0)
val total = (done + value.size).coerceAtLeast(1)
private fun buildNotification(jobs: ImmutableList<PoWJobState>): 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)
}
}
}
@@ -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 =
@@ -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<BlossomSyncState?>() {
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)
}
}
}
+3
View File
@@ -1557,6 +1557,9 @@
<string name="blossom_sync_gaps">Some of your files aren\'t on all your servers yet.</string>
<string name="blossom_syncing">Copying your files across servers…</string>
<string name="blossom_sync_done">Sync complete</string>
<string name="blossom_sync_cancel">Cancel</string>
<string name="blossom_sync_channel_name">Blossom sync</string>
<string name="blossom_sync_channel_description">Shows progress while copying your files across your Blossom servers.</string>
<string name="manage_stored_files_empty">No stored files found on your Blossom servers.</string>
<string name="blossom_mirror_to_missing">Mirror to missing</string>
<string name="blossom_delete_from">Delete from…</string>