mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
@@ -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)
|
||||
|
||||
+298
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
-207
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -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 =
|
||||
|
||||
+94
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user