Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,35 @@ All notable changes to the Sheaf Android client are recorded here. Format loosel
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project
uses semantic versioning (`MAJOR.MINOR.PATCH`).

## [Unreleased]

### Fixed

- **Home stops waiting on a slow call it doesn't draw.** Who's fronting now
appears as soon as its own data arrives, instead of waiting for every
background request the screen makes. Most visible after a switch, and on
systems with a lot of members and history where the quick-switch ranking can
take a few seconds to come back. The watch's tiles no longer wait on it
either.

### Added

- **The fronting notification has settings of its own.** Notifications now has
a "Fronting notification" screen holding the on/off switch and three new
options: which icon it uses, whether the names show without expanding it, and
whether it comes back if you swipe it away. Everything defaults to what it
did before, so nothing changes unless you change it.

- **Names behind an expand.** With "show names without expanding" off, the
notification says only "Tap to expand" until you open it, on the lock
screen as well as in the shade.
- **Bring it back if dismissed.** Android always lets a notification be
swiped away, whatever an app asks for. With this on, it returns about five
minutes later rather than staying gone until the next refresh.
- **Icon choice.** A generic people glyph, as before, or the Sheaf mark.
Note this is the status bar only, and some phones hide silent
notifications from the status bar entirely.

## [1.3.3] - 2026-09-10

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ class PreferencesRepository @Inject constructor(
// Settings → Appearance.
val KEY_THEME_SYNCED = booleanPreferencesKey("theme_synced")
val KEY_FRONT_NOTIFICATION = booleanPreferencesKey("front_notification")
// Fronting-notification appearance. All three default to the
// behaviour that shipped before they existed, so nobody's notification
// changes under them on upgrade.
val KEY_FRONT_NOTIF_LOGO = booleanPreferencesKey("front_notification_logo")
val KEY_FRONT_NOTIF_NAMES = booleanPreferencesKey("front_notification_names")
val KEY_FRONT_NOTIF_RESPAWN = booleanPreferencesKey("front_notification_respawn")
val KEY_CF_CLIENT_ID = stringPreferencesKey("cf_client_id")
val KEY_CF_CLIENT_SECRET = stringPreferencesKey("cf_client_secret")
val KEY_FILE_CDN_BASE = stringPreferencesKey("file_cdn_base")
Expand Down Expand Up @@ -105,6 +111,30 @@ class PreferencesRepository @Inject constructor(
val themeSynced: Flow<Boolean> =
context.dataStore.data.map { it[KEY_THEME_SYNCED] ?: true }
val frontNotification: Flow<Boolean> = context.dataStore.data.map { it[KEY_FRONT_NOTIFICATION] ?: false }

/**
* Use the Sheaf mark instead of the generic group-of-people glyph.
*
* Defaults to the people icon, which is the discreet choice: a notification
* that does not announce which app posted it to anyone glancing at the
* status bar. Opting into the logo is a decision to be recognisable.
*/
val frontNotificationLogo: Flow<Boolean> =
context.dataStore.data.map { it[KEY_FRONT_NOTIF_LOGO] ?: false }

/**
* Show who is fronting on the collapsed notification.
*
* Off puts the names behind an expand: the shade shows only that Sheaf has
* something to say, and the names appear when the user opens it. Defaults
* on, which is what the notification has always done.
*/
val frontNotificationNames: Flow<Boolean> =
context.dataStore.data.map { it[KEY_FRONT_NOTIF_NAMES] ?: true }

/** Re-post the notification a few minutes after it is swiped away. */
val frontNotificationRespawn: Flow<Boolean> =
context.dataStore.data.map { it[KEY_FRONT_NOTIF_RESPAWN] ?: false }
val cfClientId: Flow<String?> = context.dataStore.data.map { it[KEY_CF_CLIENT_ID] }
val cfClientSecret: Flow<String?> = context.dataStore.data.map { it[KEY_CF_CLIENT_SECRET] }
val appLockEnabled: Flow<Boolean> = context.dataStore.data.map { it[KEY_APP_LOCK] ?: false }
Expand Down Expand Up @@ -211,6 +241,18 @@ class PreferencesRepository @Inject constructor(
context.dataStore.edit { it[KEY_FRONT_NOTIFICATION] = enabled }
}

suspend fun saveFrontNotificationLogo(useLogo: Boolean) {
context.dataStore.edit { it[KEY_FRONT_NOTIF_LOGO] = useLogo }
}

suspend fun saveFrontNotificationNames(showNames: Boolean) {
context.dataStore.edit { it[KEY_FRONT_NOTIF_NAMES] = showNames }
}

suspend fun saveFrontNotificationRespawn(respawn: Boolean) {
context.dataStore.edit { it[KEY_FRONT_NOTIF_RESPAWN] = respawn }
}

suspend fun saveAppLock(enabled: Boolean) {
context.dataStore.edit { it[KEY_APP_LOCK] = enabled }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,44 @@ import systems.lupine.sheaf.R
import javax.inject.Inject
import javax.inject.Singleton

/**
* How the fronting notification should look, from the user's settings.
*
* Every default here is what the notification did before any of this was
* configurable, so an upgrade changes nobody's shade.
*/
data class FrontNotificationStyle(
/** The Sheaf mark rather than the generic people glyph. */
val useLogo: Boolean = false,
/** Names on the collapsed notification, rather than behind an expand. */
val showNames: Boolean = true,
/** Re-post a few minutes after the user swipes it away. */
val respawn: Boolean = false,
)

@Singleton
class FrontNotificationHelper @Inject constructor(
@ApplicationContext private val context: Context,
) {
companion object {
const val CHANNEL_ID = "sheaf_fronting"
const val NOTIFICATION_ID = 1001

/**
* What the collapsed notification says when names are hidden.
*
* Deliberately says nothing about fronting: the point of the setting is
* that somebody reading the shade over your shoulder learns nothing,
* and "someone is fronting" is already more than nothing.
*/
const val DISCREET_TEXT = "Tap to expand"

/** Who is fronting, as a sentence. */
fun frontingText(memberNames: List<String>): String = when {
memberNames.isEmpty() -> "No one is fronting"
memberNames.size == 1 -> "${memberNames[0]} is fronting"
else -> "${memberNames.dropLast(1).joinToString(", ")} and ${memberNames.last()} are fronting"
}
}

fun createChannel() {
Expand All @@ -30,20 +61,16 @@ class FrontNotificationHelper @Inject constructor(
"Currently fronting",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Persistent notification showing who is currently fronting"
description = "Ongoing silent notification showing who is currently fronting"
setSound(null, null)
enableVibration(false)
}
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}

@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
fun post(memberNames: List<String>) {
val text = when {
memberNames.isEmpty() -> "No one is fronting"
memberNames.size == 1 -> "${memberNames[0]} is fronting"
else -> "${memberNames.dropLast(1).joinToString(", ")} and ${memberNames.last()} are fronting"
}
fun post(memberNames: List<String>, style: FrontNotificationStyle = FrontNotificationStyle()) {
val names = frontingText(memberNames)
val launchIntent = Intent(context, MainActivity::class.java).apply {
// SINGLE_TOP + CLEAR_TOP brings the existing task to the front
// instead of pushing a duplicate MainActivity onto the back stack.
Expand All @@ -57,21 +84,53 @@ class FrontNotificationHelper @Inject constructor(
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)

val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(
if (style.useLogo) R.drawable.ic_notification_sheaf else R.drawable.ic_notification
)
.setContentTitle("Currently Fronting")
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
// Collapsed line. With names hidden this is the whole of what the
// shade shows; the names live in the expanded style below, which
// the user opens deliberately.
.setContentText(if (style.showNames) names else DISCREET_TEXT)
.setStyle(NotificationCompat.BigTextStyle().bigText(names))
.setContentIntent(contentIntent)
// Still set, and still worth setting: pre-14 it resists a swipe,
// and it keeps the notification out of "clear all". Android 14
// onwards lets the user dismiss it regardless, which is why the
// respawn option below exists rather than a promise we cannot keep.
.setOngoing(true)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.build()
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification)

// What a locked screen shows. Without this the system substitutes its
// own "contents hidden" line for a VISIBILITY_PRIVATE notification;
// with it we choose the wording, and with names hidden it matches what
// the unlocked shade says instead of being a louder placeholder.
if (!style.showNames) {
builder.setPublicVersion(
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(
if (style.useLogo) R.drawable.ic_notification_sheaf else R.drawable.ic_notification
)
.setContentTitle("Currently Fronting")
.setContentText(DISCREET_TEXT)
.setSilent(true)
.build()
)
}

if (style.respawn) {
builder.setDeleteIntent(FrontNotificationRespawn.deleteIntent(context))
}

NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, builder.build())
}

fun cancel() {
FrontNotificationRespawn.cancel(context)
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package systems.lupine.sheaf.notification

import android.Manifest
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.first
import systems.lupine.sheaf.data.db.LocalCache
import systems.lupine.sheaf.data.repository.PreferencesRepository
import java.util.concurrent.TimeUnit

/**
* Bringing the fronting notification back after it is swiped away.
*
* Android 14 made ongoing notifications dismissable, so `setOngoing(true)` no
* longer means what it says and there is no API that restores the old
* behaviour. A foreground service would not fix it either - those are
* dismissable too - and it would cost two permissions on a manifest that is
* deliberately four lines long.
*
* So the notification comes back instead, and on a delay. An instant re-post
* is the behaviour of an app fighting its user; five minutes reads as "this is
* meant to stay", leaves a swipe working as a way to clear the shade now, and
* costs nothing if the user turns the whole thing off in between (the work is
* cancelled, and the worker re-checks the setting before posting anyway).
*/
object FrontNotificationRespawn {

private const val WORK_NAME = "front_notification_respawn"
private val DELAY_MINUTES = 5L

/** Fired by the system when the user swipes the notification away. */
fun deleteIntent(context: Context): PendingIntent =
PendingIntent.getBroadcast(
context,
0,
Intent(context, DismissedReceiver::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)

fun schedule(context: Context) {
WorkManager.getInstance(context).enqueueUniqueWork(
WORK_NAME,
// Replace: a second dismissal restarts the clock rather than
// queueing a second re-post behind the first.
ExistingWorkPolicy.REPLACE,
OneTimeWorkRequestBuilder<RespawnWorker>()
.setInitialDelay(DELAY_MINUTES, TimeUnit.MINUTES)
.build(),
)
}

fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
}
}

/**
* Receives the dismissal and schedules the re-post. Does no work itself: the
* broadcast has to return promptly, and five minutes later is not now.
*/
@AndroidEntryPoint
class DismissedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
FrontNotificationRespawn.schedule(context.applicationContext)
}
}

/**
* Re-posts the notification, reading who is fronting from the local cache
* rather than the network: this runs on a timer with no user waiting, and a
* notification that is five minutes stale is corrected by the next refresh
* anyway. Being offline must not turn a re-post into a failed request.
*/
@HiltWorker
class RespawnWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted params: WorkerParameters,
private val prefs: PreferencesRepository,
private val cache: LocalCache,
private val helper: FrontNotificationHelper,
) : CoroutineWorker(context, params) {

override suspend fun doWork(): Result {
// Re-checked rather than assumed: the user may have turned the
// notification, or the respawn itself, off in the intervening minutes.
if (!prefs.frontNotification.first()) return Result.success()
if (!prefs.frontNotificationRespawn.first()) return Result.success()
if (ContextCompat.checkSelfPermission(
context, Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
return Result.success()
}

val fronts = cache.getFronts() ?: return Result.success()
val members = cache.getMembers() ?: return Result.success()
val frontingIds = fronts.flatMap { it.memberIds }.toSet()
val names = members.filter { it.id in frontingIds }.map { it.displayNameOrName }

helper.post(
names,
FrontNotificationStyle(
useLogo = prefs.frontNotificationLogo.first(),
showNames = prefs.frontNotificationNames.first(),
respawn = true,
),
)
return Result.success()
}
}
9 changes: 9 additions & 0 deletions sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ object Routes {
const val SETTINGS_RETENTION = "settings/retention"
const val NOTIFICATIONS_REDEEM = "notifications/redeem/{code}"
const val NOTIFICATIONS_RECEIVING = "settings/notifications/receiving"
const val NOTIFICATIONS_FRONTING = "settings/notifications/fronting"
const val NOTIFICATIONS_DEVICES = "settings/notifications/devices"
const val NOTIFICATIONS_OWNED = "settings/notifications/owned"
const val NOTIFICATIONS_CREATE = "settings/notifications/owned/new"
Expand Down Expand Up @@ -523,6 +524,14 @@ fun SheafApp(
onNavigateToReceiving = { navController.navigate(Routes.NOTIFICATIONS_RECEIVING) },
onNavigateToYourDevices = { navController.navigate(Routes.NOTIFICATIONS_DEVICES) },
onNavigateToChannelsYouOwn = { navController.navigate(Routes.NOTIFICATIONS_OWNED) },
onNavigateToFrontingNotification = {
navController.navigate(Routes.NOTIFICATIONS_FRONTING)
},
)
}
composable(Routes.NOTIFICATIONS_FRONTING) {
systems.lupine.sheaf.ui.settings.FrontingNotificationSettingsScreen(
onNavigateUp = { navController.navigateUp() },
)
}
composable(Routes.NOTIFICATIONS_RECEIVING) {
Expand Down
Loading
Loading