feat(stopwatch): the foreground service, its silent notification and the receiver
The service is alive exactly while the stopwatch is not idle, and it is `specialUse` with the subtype spelled out — reusing `systemExempted` would have the app claim it is continuing alarm functionality, which it is not. No wake lock, ever: the notification carries the platform chronometer counting up from a base derived when it is posted and never stored, so the system draws the ticking and the process can sleep through it. Its channel is `IMPORTANCE_LOW` because nothing here ever alerts. The Lap, Pause, Resume and Reset buttons are broadcasts carrying no extras — the receiver reads the stored run rather than trusting an intent — and the body opens the Stopwatch tab. Boot, time change and package replacement all reach the third engine now, alongside the other two.
This commit is contained in:
@@ -34,6 +34,13 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
|
||||
|
||||
<!-- The stopwatch's foreground service. specialUse, not systemExempted:
|
||||
that type's justification is an app with an exact-alarm permission
|
||||
continuing alarm functionality in the background, and a stopwatch
|
||||
schedules nothing, wakes nothing and rings nothing. Claiming it would
|
||||
have the app tell the platform something untrue (M7 D3). -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<!-- Keep the CPU awake for the length of a ring; the screen is the window's
|
||||
business, not this one's. -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
@@ -126,6 +133,27 @@
|
||||
android:name=".timer.receiver.TimerActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- The stopwatch: one foreground service, up while the run is not
|
||||
idle. specialUse rather than systemExempted (M7 D3), and not
|
||||
shortService, which caps at ~3 minutes against a stopwatch that may
|
||||
run for hours. It holds no wake lock: there is no deadline to wake
|
||||
for, and the elapsed value is derived from a persisted monotonic
|
||||
anchor at read time. The <property> is API 31+ and ignored below. -->
|
||||
<service
|
||||
android:name=".stopwatch.service.StopwatchService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="stopwatch" />
|
||||
</service>
|
||||
|
||||
<!-- The stopwatch notification's buttons. Broadcasts rather than service
|
||||
starts, and they carry nothing: there is one stopwatch (M7 D16). -->
|
||||
<receiver
|
||||
android:name=".stopwatch.receiver.StopwatchActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Protected system broadcasts are delivered to unexported receivers —
|
||||
this is how androidx.work declares its own RescheduleReceiver.
|
||||
LOCKED_BOOT_COMPLETED is deliberately absent: the database and the
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
|
||||
import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications
|
||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||
import de.jeanlucmakiola.clockula.timer.service.TimerNotifications
|
||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||
@@ -32,6 +34,9 @@ class ClockulaApp : Application() {
|
||||
@Inject
|
||||
lateinit var timerEngine: TimerEngine
|
||||
|
||||
@Inject
|
||||
lateinit var stopwatchEngine: StopwatchEngine
|
||||
|
||||
@Inject
|
||||
@ApplicationScope
|
||||
lateinit var scope: CoroutineScope
|
||||
@@ -52,6 +57,7 @@ class ClockulaApp : Application() {
|
||||
|
||||
RingNotifications.createChannels(this)
|
||||
TimerNotifications.createChannel(this)
|
||||
StopwatchNotifications.createChannel(this)
|
||||
|
||||
// The same three steps as a BOOT_COMPLETED, and taken through the engine
|
||||
// rather than one by one so the whole pass runs under the engine's lock:
|
||||
@@ -65,6 +71,12 @@ class ClockulaApp : Application() {
|
||||
// timers nothing — the anchors and the AlarmManager slot survive it —
|
||||
// but the service may not have, so this brings it back.
|
||||
timerEngine.onBootCompleted()
|
||||
// And the third, after the other two. Process death costs the
|
||||
// stopwatch nothing — the run is in DataStore and the laps in Room —
|
||||
// but the service may not have survived it, so this brings it back;
|
||||
// and if this is a new boot the gate above has already repaired the
|
||||
// run, leaving it paused at what it banked (M7 D17).
|
||||
stopwatchEngine.onBootCompleted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
|
||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||
import de.jeanlucmakiola.floret.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -32,6 +33,9 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
|
||||
@Inject
|
||||
lateinit var timerEngine: TimerEngine
|
||||
|
||||
@Inject
|
||||
lateinit var stopwatchEngine: StopwatchEngine
|
||||
|
||||
@Inject
|
||||
@ApplicationScope
|
||||
lateinit var scope: CoroutineScope
|
||||
@@ -47,12 +51,15 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
|
||||
Intent.ACTION_BOOT_COMPLETED -> {
|
||||
engine.onBootCompleted()
|
||||
timerEngine.onBootCompleted()
|
||||
stopwatchEngine.onBootCompleted()
|
||||
}
|
||||
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED -> {
|
||||
engine.onPackageReplaced()
|
||||
// An app update clears AlarmManager for the timers too.
|
||||
timerEngine.resync()
|
||||
// And it took the stopwatch's service with it.
|
||||
stopwatchEngine.resync()
|
||||
}
|
||||
|
||||
Intent.ACTION_TIME_CHANGED,
|
||||
@@ -67,6 +74,11 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
|
||||
// path — so it is re-anchored from the monotonic one
|
||||
// before the resync (M6 D4, D19).
|
||||
timerEngine.onSystemTimeOrZoneChanged()
|
||||
// The stopwatch writes nothing at all here: its reading
|
||||
// is monotonic, so it cannot move. The call exists so
|
||||
// the notification re-posts with a freshly derived
|
||||
// chronometer base (M7 D4, D17).
|
||||
stopwatchEngine.onSystemTimeChanged()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.clockula.stopwatch.receiver
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.clockula.alarm.receiver.HiltBroadcastReceiver
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
|
||||
import de.jeanlucmakiola.floret.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The stopwatch notification's buttons. A broadcast and not a service start,
|
||||
* because a background foreground-service start can be refused and a broadcast
|
||||
* cannot — and not an activity, because a control should not have to open the
|
||||
* app (D16).
|
||||
*
|
||||
* Nothing is carried at all: there is exactly one stopwatch, so a button has
|
||||
* nothing to identify. Every verb is guarded by the state it makes sense for,
|
||||
* so a button pressed against a stale notification is a silent no-op and the
|
||||
* notification is re-posted from the truth afterwards.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class StopwatchActionReceiver : HiltBroadcastReceiver() {
|
||||
|
||||
@Inject
|
||||
lateinit var engine: StopwatchEngine
|
||||
|
||||
@Inject
|
||||
@ApplicationScope
|
||||
lateinit var scope: CoroutineScope
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
super.onReceive(context, intent)
|
||||
|
||||
val action = intent.action ?: return
|
||||
val pendingResult = goAsync()
|
||||
scope.launch {
|
||||
try {
|
||||
when (action) {
|
||||
StopwatchIntents.ACTION_LAP -> engine.lap()
|
||||
StopwatchIntents.ACTION_PAUSE -> engine.pause()
|
||||
StopwatchIntents.ACTION_RESUME -> engine.start()
|
||||
StopwatchIntents.ACTION_RESET -> engine.reset()
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package de.jeanlucmakiola.clockula.stopwatch.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import de.jeanlucmakiola.clockula.MainActivity
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.format.StopwatchFormat
|
||||
import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchNotificationAction
|
||||
import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchNotificationState
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
|
||||
import de.jeanlucmakiola.clockula.stopwatch.receiver.StopwatchActionReceiver
|
||||
|
||||
/**
|
||||
* One channel and one id for the one stopwatch. The running readout is the
|
||||
* **platform chronometer**, counting *up*, so the system draws the ticking and
|
||||
* the service posts once per state change rather than sixty times a second (D4).
|
||||
*
|
||||
* The channel is `IMPORTANCE_LOW` and silent: nothing here ever finishes, so
|
||||
* nothing here ever needs to heads-up. That is the load-bearing difference from
|
||||
* the timers' HIGH channel, and it is why there is no alert rule in sight.
|
||||
*/
|
||||
object StopwatchNotifications {
|
||||
|
||||
const val STOPWATCH_CHANNEL_ID: String = "stopwatch"
|
||||
|
||||
/** Beside the alarm's 1_001 and the timers' 1_002. */
|
||||
const val STOPWATCH_NOTIFICATION_ID: Int = 1_003
|
||||
|
||||
/** Idempotent; called from the app class on every start. */
|
||||
fun createChannel(context: Context) {
|
||||
val channel = NotificationChannel(
|
||||
STOPWATCH_CHANNEL_ID,
|
||||
context.getString(R.string.channel_stopwatch_name),
|
||||
// LOW, not HIGH: a stopwatch never expires, so it never has anything
|
||||
// to interrupt the user with (D4).
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = context.getString(R.string.channel_stopwatch_description)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
}
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* The notification the service goes foreground with the instant it starts,
|
||||
* before it has read anything: `startForeground` must not wait on a
|
||||
* DataStore read. [stopwatch] replaces it under the same id a moment later.
|
||||
*/
|
||||
fun preparing(context: Context): Notification =
|
||||
NotificationCompat.Builder(context, STOPWATCH_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(context.getString(R.string.stopwatch_notification_title))
|
||||
.setCategory(NotificationCompat.CATEGORY_STOPWATCH)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setSilent(true)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setShowWhen(false)
|
||||
.setContentIntent(showPendingIntent(context))
|
||||
.build()
|
||||
|
||||
/** Two actions at most, and they are the pair the tab's controls offer for that mode (D4). */
|
||||
fun stopwatch(context: Context, state: StopwatchNotificationState): Notification {
|
||||
val builder = NotificationCompat.Builder(context, STOPWATCH_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(context.getString(R.string.stopwatch_notification_title))
|
||||
.setCategory(NotificationCompat.CATEGORY_STOPWATCH)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
// Nothing here ever alerts: the channel is LOW and every post is
|
||||
// silent, so there is no session window and no one heads-up to spend.
|
||||
.setSilent(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(showPendingIntent(context))
|
||||
|
||||
if (state.lapCount > 0) {
|
||||
builder.setSubText(
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.stopwatch_laps_recorded,
|
||||
state.lapCount,
|
||||
state.lapCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val base = state.chronometerBase
|
||||
if (base != null) {
|
||||
// Derived at post time and never stored, which is how the record
|
||||
// keeps its "no wall-clock field whatsoever" rule. Whole seconds,
|
||||
// because a shade entry redrawing at 100 Hz is absurd — and the
|
||||
// platform chronometer does not offer hundredths anyway (D4).
|
||||
builder.setWhen(base.toEpochMilliseconds())
|
||||
.setShowWhen(true)
|
||||
.setUsesChronometer(true)
|
||||
.setChronometerCountDown(false)
|
||||
} else {
|
||||
// Frozen, so the full hundredths are stable — and the precision is
|
||||
// the point of having stopped.
|
||||
builder.setShowWhen(false).setContentText(
|
||||
context.getString(
|
||||
R.string.stopwatch_paused_at,
|
||||
StopwatchFormat.precise(state.elapsed),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
state.actions.forEach { action ->
|
||||
builder.addAction(
|
||||
R.drawable.ic_notification,
|
||||
context.getString(labelFor(action)),
|
||||
actionPendingIntent(context, action),
|
||||
)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun labelFor(action: StopwatchNotificationAction): Int = when (action) {
|
||||
StopwatchNotificationAction.LAP -> R.string.stopwatch_lap
|
||||
StopwatchNotificationAction.PAUSE -> R.string.stopwatch_pause
|
||||
StopwatchNotificationAction.RESUME -> R.string.stopwatch_resume
|
||||
StopwatchNotificationAction.RESET -> R.string.stopwatch_reset
|
||||
}
|
||||
|
||||
/** Opens the Stopwatch tab, through the hook M6 built for the timers (D16). */
|
||||
private fun showPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
StopwatchIntents.REQUEST_SHOW,
|
||||
Intent(context, MainActivity::class.java)
|
||||
.setAction(StopwatchIntents.ACTION_SHOW_STOPWATCH)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||
PENDING_INTENT_FLAGS,
|
||||
)
|
||||
|
||||
/** No extras and no per-id stride: there is exactly one stopwatch (D16). */
|
||||
private fun actionPendingIntent(
|
||||
context: Context,
|
||||
action: StopwatchNotificationAction,
|
||||
): PendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
requestCode(action),
|
||||
Intent(context, StopwatchActionReceiver::class.java).setAction(actionString(action)),
|
||||
PENDING_INTENT_FLAGS,
|
||||
)
|
||||
|
||||
private fun actionString(action: StopwatchNotificationAction): String = when (action) {
|
||||
StopwatchNotificationAction.LAP -> StopwatchIntents.ACTION_LAP
|
||||
StopwatchNotificationAction.PAUSE -> StopwatchIntents.ACTION_PAUSE
|
||||
StopwatchNotificationAction.RESUME -> StopwatchIntents.ACTION_RESUME
|
||||
StopwatchNotificationAction.RESET -> StopwatchIntents.ACTION_RESET
|
||||
}
|
||||
|
||||
private fun requestCode(action: StopwatchNotificationAction): Int = when (action) {
|
||||
StopwatchNotificationAction.LAP -> StopwatchIntents.REQUEST_LAP
|
||||
StopwatchNotificationAction.PAUSE -> StopwatchIntents.REQUEST_PAUSE
|
||||
StopwatchNotificationAction.RESUME -> StopwatchIntents.REQUEST_RESUME
|
||||
StopwatchNotificationAction.RESET -> StopwatchIntents.REQUEST_RESET
|
||||
}
|
||||
|
||||
private const val PENDING_INTENT_FLAGS: Int =
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package de.jeanlucmakiola.clockula.stopwatch.service
|
||||
|
||||
import android.app.Notification
|
||||
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.NotificationManagerCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
|
||||
import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchNotificationState
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The stopwatch's foreground service, alive exactly while the run is **not
|
||||
* IDLE** — the live pill's predicate and [StopwatchReadings.isActive][de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchReadings.isActive],
|
||||
* so the shade, the pill and the tab cannot disagree about whether there is a
|
||||
* stopwatch (D15). PAUSED is in it because dropping the notification on pause
|
||||
* would leave a user who paused from the shade with no way to resume.
|
||||
*
|
||||
* It holds **no policy and no state at all**: every decision arrives from
|
||||
* [StopwatchEngine.notificationState], and there is nothing here to alert once,
|
||||
* because nothing here ever finishes. It also holds **no wake lock, ever** — a
|
||||
* stopwatch has no deadline to wake for, and its elapsed value is derived from
|
||||
* a persisted monotonic anchor at read time, so a CPU asleep for an hour loses
|
||||
* nothing (D3).
|
||||
*
|
||||
* It is not the timekeeper. The anchors are, and they survive its absence.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class StopwatchService : Service() {
|
||||
|
||||
@Inject
|
||||
lateinit var engine: StopwatchEngine
|
||||
|
||||
@Inject
|
||||
lateinit var stopwatch: StopwatchRepository
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
private var collector: Job? = null
|
||||
|
||||
/**
|
||||
* `startForeground` is for going foreground, not for posting: every engine
|
||||
* verb ends in a sync, so re-posting the placeholder on each
|
||||
* `onStartCommand` would replace the live notification until the
|
||||
* collector's next pass.
|
||||
*/
|
||||
private var wentForeground: Boolean = false
|
||||
|
||||
/** The last start command's id, so [stop] can decline to die on a newer one. */
|
||||
private var lastStartId: Int = 0
|
||||
|
||||
/** A verb's way of asking for another pass when nothing observable changed. */
|
||||
private val refresh = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
lastStartId = startId
|
||||
if (intent?.action == StopwatchIntents.ACTION_SERVICE_STOP) {
|
||||
stop()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
// Before any suspension point, and on the first path only: the platform
|
||||
// gives a foreground service a few seconds to post its notification.
|
||||
if (!wentForeground) startForeground(StopwatchNotifications.preparing(this))
|
||||
|
||||
if (collector == null) {
|
||||
collector = scope.launch {
|
||||
triggers().collect { apply(engine.notificationState()) }
|
||||
}
|
||||
} else {
|
||||
refresh.tryEmit(Unit)
|
||||
}
|
||||
|
||||
// Sticky rather than re-delivered: the intent carries nothing, because a
|
||||
// service the system recreated must know exactly as much as one the
|
||||
// engine started — and it reads all of it from storage.
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/** The run and its laps: the subtext counts the laps, so the table is a trigger too. */
|
||||
private fun triggers(): Flow<Unit> = merge(
|
||||
combine(stopwatch.run(), stopwatch.laps()) { _, _ -> Unit },
|
||||
refresh,
|
||||
)
|
||||
|
||||
private fun apply(state: StopwatchNotificationState?) {
|
||||
if (state == null) {
|
||||
// Nothing active: the service stops itself rather than waiting to be
|
||||
// told to, so "down when the stopwatch is idle" needs no second caller.
|
||||
stop()
|
||||
return
|
||||
}
|
||||
startForeground(StopwatchNotifications.stopwatch(this, state))
|
||||
}
|
||||
|
||||
private fun startForeground(notification: Notification) {
|
||||
wentForeground = true
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// specialUse, and deliberately **not** systemExempted: that type's
|
||||
// justification is alarm functionality continuing in the background,
|
||||
// and a stopwatch schedules nothing, wakes nothing and rings nothing.
|
||||
// Claiming it would be the app telling the platform something untrue (D3).
|
||||
startForeground(
|
||||
StopwatchNotifications.STOPWATCH_NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
|
||||
)
|
||||
} else {
|
||||
startForeground(StopwatchNotifications.STOPWATCH_NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stop() {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
// The placeholder is posted before anything is read, so a service that
|
||||
// starts with nothing active must take its own notification with it.
|
||||
NotificationManagerCompat.from(this).cancel(StopwatchNotifications.STOPWATCH_NOTIFICATION_ID)
|
||||
wentForeground = false
|
||||
// `stopSelfResult`, not `stopSelf`: resetting the stopwatch and starting
|
||||
// a new run immediately delivers a start command to this still-live
|
||||
// instance, and dying anyway would leave a running stopwatch with no
|
||||
// notification until the next resync.
|
||||
stopSelfResult(lastStartId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun start(context: Context) {
|
||||
context.startForegroundService(Intent(context, StopwatchService::class.java))
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.startService(
|
||||
Intent(context, StopwatchService::class.java)
|
||||
.setAction(StopwatchIntents.ACTION_SERVICE_STOP),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user