From 5d54a0ff90b85ccf42a53d593f6678f91c68ef58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 04:33:10 +0000 Subject: [PATCH] refactor(fgs): extract FlowProgressForegroundService; add dataSync Blossom sync service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. 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 Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP --- amethyst/src/main/AndroidManifest.xml | 10 + .../com/vitorpamplona/amethyst/AppModules.kt | 8 +- .../FlowProgressForegroundService.kt | 298 ++++++++++++++++++ .../service/pow/PowMiningForegroundService.kt | 261 ++++----------- .../uploads/blossom/BlossomMirrorQueue.kt | 8 +- .../blossom/BlossomSyncForegroundService.kt | 94 ++++++ amethyst/src/main/res/values/strings.xml | 3 + 7 files changed, 473 insertions(+), 209 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/foreground/FlowProgressForegroundService.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomSyncForegroundService.kt 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…