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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ 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]

### Added

- **Announcements can be aimed at particular clients.** Admin only. An
announcement can now be shown only on Web, Android, iOS or the watch, with a
"Show on" section in the editor and a marker in the list; leaving it unticked
shows it to everyone, as before. The app also names itself when asking, so an
announcement aimed at Android reaches it. Needs a server that supports
targeting; on an older one everything carries on as it was.

## [1.5.0] - 2026-10-12

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -729,8 +729,22 @@ interface SheafApiService {
@GET("/v1/notifications/server-config")
suspend fun getNotificationServerConfig(): NotificationServerConfig

/**
* Active announcements for this client.
*
* [platform] names what this client is. Announcements aimed at no platform
* always come back; one aimed at a platform comes back only when that
* platform is named, so naming nothing would quietly hide anything
* targeted at Android. Defaulted here rather than at the call site so a
* future caller cannot forget it.
*
* A server that predates platform targeting ignores the parameter and
* returns everything, which is what it meant before.
*/
@GET("/v1/announcements")
suspend fun getAnnouncements(): List<AnnouncementPublic>
suspend fun getAnnouncements(
@Query("platform") platform: String = ANNOUNCEMENT_PLATFORM,
): List<AnnouncementPublic>

@GET("/v1/admin/announcements")
suspend fun listAllAnnouncements(): List<AnnouncementRead>
Expand Down Expand Up @@ -1105,6 +1119,17 @@ interface SheafApiService {
suspend fun markBoardSeen(@Body body: MarkSeenRequest)
}

/**
* What this client calls itself when asking for announcements.
*
* The same name the app already sends as its client header, and one of the
* four the server knows (`web`, `android`, `ios`, `watch`). The phone is not
* the watch: the Wear app is its own client and would name itself, so an
* announcement aimed at the watch is not something this build should collect
* on its behalf.
*/
const val ANNOUNCEMENT_PLATFORM: String = "android"

/** Returns null when deletion was immediate (204) or queued payload when safeguarded (202). */
suspend fun SheafApiService.deleteMemberOrQueue(
id: String,
Expand Down
10 changes: 10 additions & 0 deletions sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1367,6 +1367,9 @@ data class AnnouncementPublic(
val body: String,
val severity: String,
val dismissible: Boolean,
// Empty means every client. Absent on servers older than platform
// targeting, where every announcement was for everyone.
val platforms: List<String> = emptyList(),
@Json(name = "starts_at") val startsAt: String?,
@Json(name = "expires_at") val expiresAt: String?,
@Json(name = "created_at") val createdAt: String,
Expand All @@ -1381,6 +1384,7 @@ data class AnnouncementRead(
val dismissible: Boolean,
val active: Boolean,
@Json(name = "visible_while_logged_out") val visibleWhileLoggedOut: Boolean = false,
val platforms: List<String> = emptyList(),
@Json(name = "starts_at") val startsAt: String?,
@Json(name = "expires_at") val expiresAt: String?,
@Json(name = "created_at") val createdAt: String,
Expand All @@ -1396,6 +1400,9 @@ data class AnnouncementCreate(
val dismissible: Boolean = true,
val active: Boolean = true,
@Json(name = "visible_while_logged_out") val visibleWhileLoggedOut: Boolean = false,
// Empty means every client, which is the default and what every
// announcement meant before targeting existed.
val platforms: List<String> = emptyList(),
@Json(name = "starts_at") val startsAt: String? = null,
@Json(name = "expires_at") val expiresAt: String? = null,
)
Expand All @@ -1408,6 +1415,9 @@ data class AnnouncementUpdate(
val dismissible: Boolean? = null,
val active: Boolean? = null,
@Json(name = "visible_while_logged_out") val visibleWhileLoggedOut: Boolean? = null,
// Send an empty list to go back to every client; null omits the key, which
// the server reads as "leave it alone".
val platforms: List<String>? = null,
@Json(name = "starts_at") val startsAt: String? = null,
@Json(name = "expires_at") val expiresAt: String? = null,
@Json(name = "clear_starts_at") val clearStartsAt: Boolean = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter

/**
* The platforms an announcement can be aimed at, in the server's order.
*
* Mirrors `AnnouncementPlatform` server-side. A name the server does not know
* is a 422 on write, deliberately, so this list is the only place the strings
* are written down on this client.
*/
private val ANNOUNCEMENT_PLATFORMS = listOf(
"web" to "Web",
"android" to "Android",
"ios" to "iOS",
"watch" to "Watch",
)

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdminPanelScreen(
Expand Down Expand Up @@ -300,6 +314,11 @@ fun AdminPanelScreen(
dismissible = create.dismissible,
active = create.active,
visibleWhileLoggedOut = create.visibleWhileLoggedOut,
// Always sent, never omitted: an empty list is how
// a targeted announcement goes back to everyone,
// and omitting it would silently keep the old
// targeting after the boxes were unticked.
platforms = create.platforms,
startsAt = create.startsAt,
expiresAt = create.expiresAt,
// PATCH reads an omitted timestamp as "leave it
Expand Down Expand Up @@ -551,6 +570,19 @@ private fun AnnouncementListItem(
color = MaterialTheme.colorScheme.tertiary,
)
}
// Untargeted is the common case and says nothing, matching
// the web list: a badge on every row would be noise.
if (announcement.platforms.isNotEmpty()) {
Text("·", style = MaterialTheme.typography.bodySmall)
Text(
announcement.platforms.joinToString(", ") { platform ->
ANNOUNCEMENT_PLATFORMS.firstOrNull { it.first == platform }
?.second ?: platform
} + " only",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
)
}
}
},
trailingContent = {
Expand Down Expand Up @@ -591,6 +623,9 @@ private fun AnnouncementDialog(
var dismissible by remember { mutableStateOf(initial?.dismissible ?: true) }
var active by remember { mutableStateOf(initial?.active ?: true) }
var loggedOut by remember { mutableStateOf(initial?.visibleWhileLoggedOut ?: false) }
// Order-insensitive: the server folds duplicates and case on the way in,
// and "which clients" is a set rather than a sequence.
var platforms by remember { mutableStateOf(initial?.platforms.orEmpty().toSet()) }
var startsAt by remember { mutableStateOf(parseAdminInstant(initial?.startsAt)) }
var expiresAt by remember { mutableStateOf(parseAdminInstant(initial?.expiresAt)) }

Expand Down Expand Up @@ -645,6 +680,31 @@ private fun AnnouncementDialog(
}
}

HorizontalDivider()
Text("Show on", style = MaterialTheme.typography.labelMedium)
// None ticked is the default and means everyone, so the line
// below says which it is rather than leaving an empty row to
// be read as "nobody".
ANNOUNCEMENT_PLATFORMS.forEach { (value, label) ->
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = value in platforms,
onCheckedChange = { on ->
platforms = if (on) platforms + value else platforms - value
},
)
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
Text(
if (platforms.isEmpty()) "Everyone sees this."
else "Only " + ANNOUNCEMENT_PLATFORMS
.filter { it.first in platforms }
.joinToString(", ") { it.second } + " see this.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)

HorizontalDivider()
ScheduleField(
label = "Starts",
Expand Down Expand Up @@ -682,6 +742,7 @@ private fun AnnouncementDialog(
dismissible = dismissible,
active = active,
visibleWhileLoggedOut = loggedOut,
platforms = platforms.toList(),
startsAt = startsAt?.toString(),
expiresAt = expiresAt?.toString(),
))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package systems.lupine.sheaf.data.api

import kotlinx.coroutines.runBlocking
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import systems.lupine.sheaf.di.NetworkModule
import java.net.ServerSocket
import kotlin.concurrent.thread
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

/**
* The announcements request names what this client is.
*
* Worth a real request rather than reading the annotation back: the platform
* is a Kotlin default argument on a Retrofit interface, which reaches the
* proxy through a synthetic method, and a default that quietly failed to
* apply would leave the app asking for the untargeted set only. Nothing would
* look broken; announcements aimed at Android would simply never arrive.
*
* A bare socket rather than a mock web server, so the check costs no new
* dependency: all it has to do is read one request line and answer `[]`.
*/
class AnnouncementPlatformQueryTest {

@Test fun `the announcements request names android`() {
val server = ServerSocket(0)
var requestLine: String? = null

val serving = thread {
server.accept().use { socket ->
val reader = socket.getInputStream().bufferedReader()
requestLine = reader.readLine()
// Drain the headers so the client is not left writing into a
// closed stream while we reply.
while (true) {
val line = reader.readLine()
if (line.isNullOrEmpty()) break
}
socket.getOutputStream().write(
(
"HTTP/1.1 200 OK\r\n" +
"Content-Type: application/json\r\n" +
"Content-Length: 2\r\n" +
"Connection: close\r\n\r\n[]"
).toByteArray()
)
}
}

try {
val api = Retrofit.Builder()
.baseUrl("http://127.0.0.1:${server.localPort}/")
.addConverterFactory(MoshiConverterFactory.create(NetworkModule.provideMoshi()))
.build()
.create(SheafApiService::class.java)

runBlocking { api.getAnnouncements() }
serving.join(5_000)

val line = requestLine
assertTrue(line != null, "no request reached the server")
assertEquals("GET /v1/announcements?platform=android HTTP/1.1", line)
} finally {
server.close()
}
}

@Test fun `the platform is one the server knows`() {
// An unknown name is a 422 by design, so a typo here would break every
// announcement fetch rather than silently narrowing one.
assertTrue(ANNOUNCEMENT_PLATFORM in listOf("web", "android", "ios", "watch"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package systems.lupine.sheaf.data.model
import com.squareup.moshi.Moshi
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

/**
Expand Down Expand Up @@ -228,4 +229,47 @@ class ModelContractsTest {
assertTrue(""""clear_starts_at":true""" in json, json)
assertTrue("starts_at" !in json.replace("clear_starts_at", ""), json)
}

@Test fun `an announcement carries the platforms it is aimed at`() {
val read = moshi.adapter(AnnouncementRead::class.java).fromJson(
"""
{"id":"a1","title":"Android only","body":"B","severity":"info",
"dismissible":true,"active":true,"visible_while_logged_out":false,
"platforms":["android"],"starts_at":null,"expires_at":null,
"created_at":"2026-10-13T00:00:00Z","updated_at":"2026-10-13T00:00:00Z",
"created_by":null}
""".trimIndent(),
)
assertEquals(listOf("android"), read?.platforms)
}

@Test fun `an announcement from a server without targeting is for everyone`() {
// Empty is the server's own default and is what every announcement
// meant before targeting existed, so an absent key must not read as
// "aimed at nothing".
val read = moshi.adapter(AnnouncementRead::class.java).fromJson(
"""
{"id":"a1","title":"T","body":"B","severity":"info","dismissible":true,
"active":true,"starts_at":null,"expires_at":null,
"created_at":"2026-10-13T00:00:00Z","updated_at":"2026-10-13T00:00:00Z",
"created_by":null}
""".trimIndent(),
)
assertEquals(emptyList(), read?.platforms)
}

@Test fun `clearing the targeting is sent, not omitted`() {
// The server reads an omitted platforms as "leave it alone", so
// unticking every box has to put an empty list on the wire or the old
// targeting quietly survives a save that looked like it removed it.
val json = moshi.adapter(AnnouncementUpdate::class.java)
.toJson(AnnouncementUpdate(title = "T", platforms = emptyList()))
assertTrue(""""platforms":[]""" in json, json)
}

@Test fun `an untouched targeting is left out of the patch`() {
val json = moshi.adapter(AnnouncementUpdate::class.java)
.toJson(AnnouncementUpdate(title = "T"))
assertFalse("platforms" in json, json)
}
}
Loading