0.3.16
This commit is contained in:
+1
-29
@@ -38,31 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
|
||||
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
||||
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
||||
|
||||
// Kept in BuildConfig so the TV can show the exact corresponding-source location and
|
||||
// the complete legal documents offline. Deployments can override the public source URL
|
||||
// without changing application code.
|
||||
val membySourceUrl: String =
|
||||
(project.findProperty("memby.sourceUrl") as String?)
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
?: "https://g.sublogue.com/admin/memby"
|
||||
|
||||
fun buildConfigString(value: String): String =
|
||||
"\"" + value
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\r\n", "\\n")
|
||||
.replace("\n", "\\n") + "\""
|
||||
|
||||
// The About page's version history. Kept as one checked-in document rather than a Kotlin
|
||||
// list so a release only edits CHANGELOG.md, and the TV shows the history offline.
|
||||
val changelogText = rootProject.file("CHANGELOG.md").readText()
|
||||
val gplLicenseText = rootProject.file("LICENSE").readText()
|
||||
val projectNoticeText =
|
||||
rootProject.file("NOTICE").readText()
|
||||
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
|
||||
|
||||
val defaultVersionName = "0.3.15"
|
||||
val defaultVersionName = "0.3.16"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
@@ -126,10 +102,6 @@ extensions.configure<ApplicationExtension> {
|
||||
buildConfigField("String", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"")
|
||||
buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"")
|
||||
buildConfigField("String", "DIAGNOSTIC_LOG_LEVEL", "\"$membyDiagnosticLogLevel\"")
|
||||
buildConfigField("String", "SOURCE_CODE_URL", buildConfigString(membySourceUrl))
|
||||
buildConfigField("String", "GPL_LICENSE_TEXT", buildConfigString(gplLicenseText))
|
||||
buildConfigField("String", "PROJECT_NOTICE_TEXT", buildConfigString(projectNoticeText))
|
||||
buildConfigField("String", "CHANGELOG_TEXT", buildConfigString(changelogText))
|
||||
}
|
||||
|
||||
// Release signing. Android identifies an app by (applicationId, signing key), so
|
||||
|
||||
@@ -405,17 +405,6 @@ data class Settings(
|
||||
* and remains authoritative for anyone not listed here.
|
||||
*/
|
||||
val onboardedUserIds: Set<String> = emptySet(),
|
||||
/**
|
||||
* The app version this television has already announced. Deliberately device state
|
||||
* rather than a synced preference: what is new is a property of the APK sitting on
|
||||
* this set, and a viewer who signs into a second TV that is still a version behind has
|
||||
* not seen that build's update notice.
|
||||
*/
|
||||
val whatsNewSeenVersion: String? = null,
|
||||
/** The most recent app-update alert retained for this television's Notifications page. */
|
||||
val updateAlertVersion: String? = null,
|
||||
val updateAlertAt: String? = null,
|
||||
val updateAlertRead: Boolean = false,
|
||||
/**
|
||||
* The version the gateway last refused this build over, or null while it has said
|
||||
* nothing. Device state, and deliberately outlives both the session and the process:
|
||||
@@ -604,10 +593,6 @@ class SettingsStore(private val context: Context) {
|
||||
val PROFILES = stringPreferencesKey("profiles")
|
||||
val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
|
||||
val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids")
|
||||
val WHATS_NEW_VERSION = stringPreferencesKey("whats_new_seen_version")
|
||||
val UPDATE_ALERT_VERSION = stringPreferencesKey("update_alert_version")
|
||||
val UPDATE_ALERT_AT = stringPreferencesKey("update_alert_at")
|
||||
val UPDATE_ALERT_READ = booleanPreferencesKey("update_alert_read")
|
||||
val REQUIRED_UPDATE_VERSION = stringPreferencesKey("required_update_version")
|
||||
val PREFERENCES_REVISION = longPreferencesKey("preferences_revision")
|
||||
val ACTIVE_VIEWER_ID = stringPreferencesKey("active_viewer_id")
|
||||
@@ -1143,48 +1128,6 @@ class SettingsStore(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the version announced on this television, so the update toast appears exactly
|
||||
* once. Also written silently on a fresh install, which has not updated from an earlier
|
||||
* build and therefore has nothing to announce.
|
||||
*/
|
||||
suspend fun markWhatsNewSeen(version: String, updateAlertAt: String? = null) {
|
||||
val trimmed = version.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.WHATS_NEW_VERSION] = trimmed
|
||||
updateAlertAt?.trim()?.takeIf(String::isNotEmpty)?.let { occurredAt ->
|
||||
preferences[Keys.UPDATE_ALERT_VERSION] = trimmed
|
||||
preferences[Keys.UPDATE_ALERT_AT] = occurredAt
|
||||
preferences[Keys.UPDATE_ALERT_READ] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The local update notice's read flag, both ways.
|
||||
*
|
||||
* This alert is the one row on the Notifications page the gateway knows nothing about —
|
||||
* it is a property of the APK on *this* set — so its seen toggle has to be written here
|
||||
* rather than posted. Guarded on the version still being recorded: a flag left behind by
|
||||
* an alert somebody has already dismissed describes nothing.
|
||||
*/
|
||||
suspend fun setUpdateAlertRead(read: Boolean) {
|
||||
context.dataStore.edit { preferences ->
|
||||
if (!preferences[Keys.UPDATE_ALERT_VERSION].isNullOrBlank()) {
|
||||
preferences[Keys.UPDATE_ALERT_READ] = read
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun dismissUpdateAlert() {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences.remove(Keys.UPDATE_ALERT_VERSION)
|
||||
preferences.remove(Keys.UPDATE_ALERT_AT)
|
||||
preferences.remove(Keys.UPDATE_ALERT_READ)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that the gateway has refused this build, so the refusal survives the process
|
||||
* it arrived in. Ignored once this television is already running the version being
|
||||
@@ -1648,10 +1591,6 @@ class SettingsStore(private val context: Context) {
|
||||
themeIconSet = preferences[Keys.THEME_ICON_SET].orEmpty(),
|
||||
themeRevision = preferences[Keys.THEME_REVISION].orEmpty(),
|
||||
onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(),
|
||||
whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION],
|
||||
updateAlertVersion = preferences[Keys.UPDATE_ALERT_VERSION],
|
||||
updateAlertAt = preferences[Keys.UPDATE_ALERT_AT],
|
||||
updateAlertRead = preferences[Keys.UPDATE_ALERT_READ] ?: false,
|
||||
requiredUpdateVersion = preferences[Keys.REQUIRED_UPDATE_VERSION],
|
||||
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
|
||||
activeViewerId = preferences[Keys.ACTIVE_VIEWER_ID].orEmpty(),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -18,7 +17,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.Settings
|
||||
@@ -26,9 +24,7 @@ import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
|
||||
import com.ponzischeme89.memby.data.ServerConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig
|
||||
import com.ponzischeme89.memby.ui.whatsnew.WhatsNewDecision
|
||||
import com.ponzischeme89.memby.ui.seasonal.SeasonalDecorations
|
||||
import com.ponzischeme89.memby.ui.whatsnew.whatsNewDecision
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.update.InstallPermission
|
||||
import com.ponzischeme89.memby.update.RequiredUpdateSignal
|
||||
@@ -40,7 +36,6 @@ import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.time.Instant
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
@@ -76,7 +71,6 @@ internal fun AppRoot(
|
||||
onHomeInteractive: () -> Unit,
|
||||
) {
|
||||
val repo = ServiceLocator.repository
|
||||
val context = LocalContext.current
|
||||
// This client intentionally has no token provider and no dependency on the active
|
||||
// profile. Updates are an app lifecycle concern, checked before login/session work.
|
||||
val updateService = remember { ServerUpdateService.create(ServerConfig.gatewayUrl) }
|
||||
@@ -266,38 +260,6 @@ internal fun AppRoot(
|
||||
recommendationOnboarding = answered ?: RecommendationOnboarding(completed = true)
|
||||
}
|
||||
|
||||
// Whether this TV has already been told about the build it is running. Keyed
|
||||
// on the recorded version and the session, because a fresh install records the current
|
||||
// version during setup and a sign-in is what turns "waiting" into "announce it now".
|
||||
LaunchedEffect(settings?.whatsNewSeenVersion, settings?.isSignedIn) {
|
||||
val loaded = settings ?: return@LaunchedEffect
|
||||
val decision = whatsNewDecision(
|
||||
installedVersion = BuildConfig.VERSION_NAME,
|
||||
seenVersion = loaded.whatsNewSeenVersion,
|
||||
isSignedIn = loaded.isSignedIn,
|
||||
)
|
||||
when (decision) {
|
||||
is WhatsNewDecision.Notify -> {
|
||||
// Persist the receipt before producing the external effect. If Android
|
||||
// recreates the activity immediately after the toast, the new composition
|
||||
// must not announce the same build a second time.
|
||||
ServiceLocator.settings.markWhatsNewSeen(
|
||||
decision.version,
|
||||
updateAlertAt = Instant.now().toString(),
|
||||
)
|
||||
currentCoroutineContext().ensureActive()
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.app_updated_to_version, decision.version),
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
is WhatsNewDecision.MarkSeen ->
|
||||
ServiceLocator.settings.markWhatsNewSeen(decision.version)
|
||||
WhatsNewDecision.Nothing -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize().background(MembySurface)) {
|
||||
val loaded = settings
|
||||
val update = appUpdate
|
||||
|
||||
@@ -132,25 +132,6 @@ private val PLAYBACK_SOURCE_RESOLUTION_TIMEOUT = 15.seconds
|
||||
/** The maximum time one row-to-row focus move may hold the D-pad. */
|
||||
private val ROW_FOCUS_MOVE_TIMEOUT = 1_200.milliseconds
|
||||
|
||||
internal const val MEMBY_UPDATE_NOTIFICATION_ID = -1L
|
||||
|
||||
internal fun membyUpdateNotification(
|
||||
version: String?,
|
||||
occurredAt: String?,
|
||||
read: Boolean,
|
||||
): UserNotification? {
|
||||
val installed = version?.trim()?.takeIf(String::isNotEmpty) ?: return null
|
||||
return UserNotification(
|
||||
id = MEMBY_UPDATE_NOTIFICATION_ID,
|
||||
kind = "app-update",
|
||||
title = "Memby updated",
|
||||
message = "This TV is now running Memby $installed.",
|
||||
eventAt = occurredAt,
|
||||
createdAt = occurredAt.orEmpty(),
|
||||
readAt = if (read) occurredAt ?: "read" else null,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The followed show a press stands for, as much of it as the card already knows.
|
||||
*
|
||||
@@ -359,13 +340,7 @@ internal fun HomeScreen(
|
||||
var notificationsLoading by remember(settings.userId) { mutableStateOf(true) }
|
||||
var notificationsError by remember(settings.userId) { mutableStateOf<String?>(null) }
|
||||
var notificationsMutationBusy by remember(settings.userId) { mutableStateOf(false) }
|
||||
val displayedNotifications = listOfNotNull(
|
||||
membyUpdateNotification(
|
||||
settings.updateAlertVersion,
|
||||
settings.updateAlertAt,
|
||||
settings.updateAlertRead,
|
||||
),
|
||||
) + notificationState.notifications
|
||||
val displayedNotifications = notificationState.notifications
|
||||
var showNotifications by remember { mutableStateOf(false) }
|
||||
var showRequests by remember { mutableStateOf(false) }
|
||||
// The people under this account. Fetched once the launcher is up rather than on the
|
||||
@@ -1995,19 +1970,14 @@ internal fun HomeScreen(
|
||||
notificationsMutationBusy = true
|
||||
val previous = notificationState
|
||||
val offered = displayedNotifications.size
|
||||
val hadLocalUpdate = settings.updateAlertVersion != null
|
||||
notificationState = notificationState.copy(notifications = emptyList())
|
||||
closeUserQuickActions()
|
||||
scope.launch {
|
||||
// The update notice is this television's own — there is no server
|
||||
// row behind it, so it is dismissed locally and counted here.
|
||||
if (hadLocalUpdate) ServiceLocator.settings.dismissUpdateAlert()
|
||||
runCatching { repo.clearNotifications() }
|
||||
.onSuccess { cleared ->
|
||||
val total = cleared + if (hadLocalUpdate) 1 else 0
|
||||
Toast.makeText(
|
||||
context,
|
||||
clearedNotificationsMessage(total),
|
||||
clearedNotificationsMessage(cleared),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
homeViewModel.trackJourney(
|
||||
@@ -2018,7 +1988,7 @@ internal fun HomeScreen(
|
||||
// The one free-text field, and the count is the only
|
||||
// thing that separates one use of this shortcut from
|
||||
// the next.
|
||||
itemName = "$total cleared",
|
||||
itemName = "$cleared cleared",
|
||||
outcome = "success",
|
||||
)
|
||||
}
|
||||
@@ -2505,15 +2475,9 @@ internal fun HomeScreen(
|
||||
//
|
||||
// Optimistic and reversed on failure, like the dismissal below: the flag is
|
||||
// the only thing that changed, so a row that sat unmoved while its request
|
||||
// was in flight is one pressed a second time. The locally-held update notice
|
||||
// has no server row to post, so its flag is written to this television's own
|
||||
// settings instead — it is a fact about the APK on this set.
|
||||
// was in flight is one pressed a second time.
|
||||
onToggleSeen = onToggleSeen@{ notification ->
|
||||
val markingSeen = notification.unread
|
||||
if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) {
|
||||
scope.launch { ServiceLocator.settings.setUpdateAlertRead(markingSeen) }
|
||||
return@onToggleSeen
|
||||
}
|
||||
val previousReadAt = notification.readAt
|
||||
notificationState = notificationState.copy(
|
||||
notifications = notificationState.notifications.map {
|
||||
@@ -2552,10 +2516,6 @@ internal fun HomeScreen(
|
||||
// back where it was rather than quietly losing somebody's alert.
|
||||
onDismiss = onDismiss@{ notification ->
|
||||
if (notificationsMutationBusy) return@onDismiss
|
||||
if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) {
|
||||
scope.launch { ServiceLocator.settings.dismissUpdateAlert() }
|
||||
return@onDismiss
|
||||
}
|
||||
notificationsMutationBusy = true
|
||||
val previous = notificationState
|
||||
notificationState = notificationState.copy(
|
||||
@@ -2584,20 +2544,14 @@ internal fun HomeScreen(
|
||||
// emptying Seen must not also throw away an Inbox the viewer has not
|
||||
// read — a bulk action nobody can see the extent of is one nobody presses.
|
||||
val pendingIds = pending.map(UserNotification::id).toSet()
|
||||
val dismissLocalUpdate = MEMBY_UPDATE_NOTIFICATION_ID in pendingIds &&
|
||||
settings.updateAlertVersion != null
|
||||
notificationState = notificationState.copy(
|
||||
notifications = notificationState.notifications.filterNot {
|
||||
it.id in pendingIds
|
||||
},
|
||||
)
|
||||
scope.launch {
|
||||
if (dismissLocalUpdate) ServiceLocator.settings.dismissUpdateAlert()
|
||||
// The local update notice has no server row, so asking the gateway to
|
||||
// dismiss it would be one guaranteed failure per pass.
|
||||
val failed = pendingIds.filter { id ->
|
||||
id != MEMBY_UPDATE_NOTIFICATION_ID &&
|
||||
runCatching { repo.dismissNotification(id) }.isFailure
|
||||
runCatching { repo.dismissNotification(id) }.isFailure
|
||||
}
|
||||
runCatching { repo.getNotifications() }
|
||||
.onSuccess { notificationState = it }
|
||||
|
||||
@@ -329,7 +329,7 @@ fun UpdateScreen(
|
||||
// easy to back out of by accident.
|
||||
"Choose Update now — Memby downloads the new version, then your TV asks you " +
|
||||
"to confirm the install. If it asks permission to install apps, allow it and " +
|
||||
"the update continues. Your profiles and sign-in stay on this TV.",
|
||||
"the update continues.",
|
||||
color = UpdateFaint,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -370,7 +370,7 @@ fun UpdateScreen(
|
||||
if (update.isMandatory) {
|
||||
Spacer(Modifier.height(22.dp))
|
||||
Text(
|
||||
"Stuck? Ask whoever set up Memby for you.",
|
||||
"Stuck? Try clearing app data under Settings > App > Clear Data",
|
||||
color = UpdateFaint.copy(alpha = 0.75f),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
|
||||
@@ -147,6 +147,11 @@ internal object PlayerEngine {
|
||||
* Media3 1.11, and this app is pinned to the 1.9 line by the Jellyfin FFmpeg extension (see
|
||||
* `app/build.gradle.kts`). Dynamic scheduling is the part of that same work which *is*
|
||||
* available here, and it is behind its own switch.
|
||||
*
|
||||
* `Surface.setFrameRate()` (API 30+) matching is already ExoPlayer's default here —
|
||||
* [C.VIDEO_CHANGE_FRAME_RATE_STRATEGY_ONLY_IF_SEAMLESS] is what `ExoPlayer.Builder` sets
|
||||
* unless told otherwise, and it is the strongest option this Media3 line offers: 1.9.x
|
||||
* carries no `ALWAYS` strategy, only this and `OFF`. Nothing to set explicitly.
|
||||
*/
|
||||
private fun exoPlayerBuilder(context: Context) = ExoPlayer.Builder(context)
|
||||
.experimentalSetDynamicSchedulingEnabled(DYNAMIC_SCHEDULING_ENABLED)
|
||||
|
||||
@@ -778,10 +778,8 @@ internal fun SettingsPanelContent(
|
||||
val contentFocusRequester = remember { FocusRequester() }
|
||||
// And the way back out of it. The pane's first control had nothing above it on any
|
||||
// page, so Up there did nothing at all — which from the sofa is a screen that has
|
||||
// stopped responding rather than a list that has run out. About is where it bites,
|
||||
// because its pane is a changelog long enough that walking back up is the ordinary
|
||||
// way to leave it. Left already returns to the page list; this makes Up say the same
|
||||
// thing once the pane has no row above.
|
||||
// stopped responding rather than a list that has run out. Left already returns to the
|
||||
// page list; this makes Up say the same thing once the pane has no row above.
|
||||
val railSelectionFocusRequester = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Memby contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*/
|
||||
|
||||
package com.ponzischeme89.memby.ui.settings
|
||||
|
||||
/** One release as the About page shows it. */
|
||||
internal data class ReleaseNote(
|
||||
val version: String,
|
||||
val date: String,
|
||||
val changes: List<String>,
|
||||
)
|
||||
|
||||
private val ReleaseMonths = listOf(
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
)
|
||||
|
||||
/** Turns the changelog's wire-friendly ISO date into the day-first date shown in About. */
|
||||
internal fun formatReleaseDate(raw: String): String {
|
||||
val date = raw.trim()
|
||||
if (date.length != 10 || date[4] != '-' || date[7] != '-') return raw
|
||||
val year = date.substring(0, 4).toIntOrNull() ?: return raw
|
||||
val month = date.substring(5, 7).toIntOrNull() ?: return raw
|
||||
val day = date.substring(8, 10).toIntOrNull() ?: return raw
|
||||
if (month !in 1..12 || day !in 1..31) return raw
|
||||
return "$day ${ReleaseMonths[month - 1]} $year"
|
||||
}
|
||||
|
||||
private val HeadingPattern = Regex("""^##\s+v?(\d+\.\d+\.\d+)\s*(?:[—–-]\s*(.+))?$""")
|
||||
|
||||
/**
|
||||
* Parses CHANGELOG.md into the release list. Pure so it can be unit-tested, and
|
||||
* deliberately forgiving: anything that is not a `## <version>` heading or a `- ` bullet
|
||||
* under one is prose (the file's own format notes) and is skipped rather than rendered.
|
||||
*
|
||||
* Order is the file's own — newest first by convention, never re-sorted here, because a
|
||||
* version string is not reliably comparable once a release carries a suffix.
|
||||
*/
|
||||
internal fun parseChangelog(markdown: String): List<ReleaseNote> {
|
||||
val releases = mutableListOf<ReleaseNote>()
|
||||
var version: String? = null
|
||||
var date = ""
|
||||
var changes = mutableListOf<String>()
|
||||
|
||||
fun flush() {
|
||||
version?.let { releases += ReleaseNote(it, date, changes.toList()) }
|
||||
}
|
||||
|
||||
markdown.lineSequence().forEach { rawLine ->
|
||||
val line = rawLine.trim()
|
||||
val heading = HeadingPattern.find(line)
|
||||
when {
|
||||
heading != null -> {
|
||||
flush()
|
||||
version = heading.groupValues[1]
|
||||
date = heading.groupValues[2].trim()
|
||||
changes = mutableListOf()
|
||||
}
|
||||
version == null -> Unit
|
||||
line.startsWith("- ") || line.startsWith("* ") -> changes += line.drop(2).trim()
|
||||
// A wrapped bullet continues the previous one rather than starting a new
|
||||
// entry: the file is written to a column width, not to one line per change.
|
||||
line.isNotEmpty() && changes.isNotEmpty() ->
|
||||
changes[changes.lastIndex] = "${changes.last()} $line"
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return releases
|
||||
}
|
||||
|
||||
/** The catalogue Memby ships with, parsed once. */
|
||||
internal val MembyReleaseHistory: List<ReleaseNote> by lazy {
|
||||
parseChangelog(com.ponzischeme89.memby.BuildConfig.CHANGELOG_TEXT)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Memby contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*/
|
||||
|
||||
package com.ponzischeme89.memby.ui.whatsnew
|
||||
|
||||
/** What a launch should do about the update notice for the build that is running. */
|
||||
internal sealed interface WhatsNewDecision {
|
||||
/** Briefly announce this installed version, then record it. */
|
||||
data class Notify(val version: String) : WhatsNewDecision
|
||||
|
||||
/** Record the version without showing anything. */
|
||||
data class MarkSeen(val version: String) : WhatsNewDecision
|
||||
|
||||
/** Neither — this launch has nothing to say and nothing to write. */
|
||||
data object Nothing : WhatsNewDecision
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether a television that has just been updated should announce the new version.
|
||||
*
|
||||
* Pure, because the interesting part is which launches the toast must stay out of. Three
|
||||
* cases are deliberately not "notify":
|
||||
*
|
||||
* - **Already recorded.** The whole contract is once per update; every later launch of the
|
||||
* same build is silent, which is what [seenVersion] exists for.
|
||||
* - **A fresh install** (no record at all, and nobody signed in yet). It has not updated
|
||||
* from an earlier version, so it is marked seen during setup and the first launcher stays
|
||||
* quiet.
|
||||
* - **Signed out.** A toast belongs over the launcher, so an updated television with an
|
||||
* existing record waits until somebody signs in rather than consuming the notice early.
|
||||
*
|
||||
* Signed out with a record already present is [Nothing] rather than [MarkSeen]: the notes
|
||||
* belong over the launcher, so that launch simply waits for whoever is about to sign in.
|
||||
*/
|
||||
internal fun whatsNewDecision(
|
||||
installedVersion: String,
|
||||
seenVersion: String?,
|
||||
isSignedIn: Boolean,
|
||||
): WhatsNewDecision {
|
||||
val version = installedVersion.trim()
|
||||
if (version.isEmpty()) return WhatsNewDecision.Nothing
|
||||
if (seenVersion?.trim() == version) return WhatsNewDecision.Nothing
|
||||
if (seenVersion == null && !isSignedIn) return WhatsNewDecision.MarkSeen(version)
|
||||
if (!isSignedIn) return WhatsNewDecision.Nothing
|
||||
return WhatsNewDecision.Notify(version)
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 393 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A restrained memorial marker: recognisable at TV distance without turning a cast
|
||||
card into an obituary badge. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFB9C1C8"
|
||||
android:pathData="M9,2h6v4h3v5h-3v11H9V11H6V6h3z" />
|
||||
</vector>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="270"
|
||||
android:endColor="#00000000"
|
||||
android:startColor="#D9000000"
|
||||
android:type="linear" />
|
||||
</shape>
|
||||
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Focus is the only fill on the menu; the current choice is a quiet grey plate, so an
|
||||
unfocused row never competes with the one the remote is on. -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#FF52B54B" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_selected="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#1AFFFFFF" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#00000000" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FA0B0E11" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#24FFFFFF" />
|
||||
<corners
|
||||
android:bottomLeftRadius="24dp"
|
||||
android:topLeftRadius="24dp" />
|
||||
</shape>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#FF20252A" />
|
||||
<corners android:radius="10dp" />
|
||||
<stroke android:width="1dp" android:color="#30FFFFFF" />
|
||||
</shape>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="0"
|
||||
android:endColor="#00000000"
|
||||
android:startColor="#EB07090B"
|
||||
android:type="linear" />
|
||||
</shape>
|
||||
@@ -99,7 +99,6 @@
|
||||
<string name="end_credits_next_in_label">NEXT EPISODE STARTING IN</string>
|
||||
<string name="next_up_starting_now">Starting now…</string>
|
||||
<string name="app_name">Memby</string>
|
||||
<string name="app_updated_to_version">Memby has been updated to version %1$s.</string>
|
||||
<string name="screensaver_name">Memby Screensaver</string>
|
||||
<string name="developer_name">ponzischeme89</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Memby contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*/
|
||||
|
||||
package com.ponzischeme89.memby.ui.settings
|
||||
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LegalNoticesTest {
|
||||
|
||||
@Test
|
||||
fun `distributed app embeds source and complete GPL notice`() {
|
||||
assertTrue(BuildConfig.SOURCE_CODE_URL.startsWith("https://"))
|
||||
assertTrue(BuildConfig.PROJECT_NOTICE_TEXT.contains("Memby"))
|
||||
assertTrue(BuildConfig.PROJECT_NOTICE_TEXT.contains(BuildConfig.SOURCE_CODE_URL))
|
||||
assertTrue(BuildConfig.GPL_LICENSE_TEXT.contains("GNU GENERAL PUBLIC LICENSE"))
|
||||
assertTrue(BuildConfig.GPL_LICENSE_TEXT.contains("Version 2, June 1991"))
|
||||
assertTrue(BuildConfig.GPL_LICENSE_TEXT.contains("END OF TERMS AND CONDITIONS"))
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package com.ponzischeme89.memby.ui.settings
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.ZoneId
|
||||
|
||||
class VersionHistoryTest {
|
||||
|
||||
@Test
|
||||
fun `parses versions dates and bullets`() {
|
||||
val releases = parseChangelog(
|
||||
"""
|
||||
# Changelog
|
||||
|
||||
Prose before the first release is format documentation, not history.
|
||||
|
||||
## 0.2.16 — 2026-08-03
|
||||
- Signed-in devices can be renamed.
|
||||
- MDBList ratings.
|
||||
|
||||
## 0.1.53
|
||||
- Renamed to Memby.
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(listOf("0.2.16", "0.1.53"), releases.map { it.version })
|
||||
assertEquals("2026-08-03", releases[0].date)
|
||||
assertEquals(2, releases[0].changes.size)
|
||||
assertEquals("MDBList ratings.", releases[0].changes[1])
|
||||
// A release with no date renders without one rather than showing a placeholder.
|
||||
assertEquals("", releases[1].date)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `joins a bullet wrapped across lines`() {
|
||||
val releases = parseChangelog(
|
||||
"""
|
||||
## 1.0.0 - 2026-01-01
|
||||
- A change long enough that the file
|
||||
wraps it onto a second line.
|
||||
- A second change.
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"A change long enough that the file wraps it onto a second line.",
|
||||
"A second change.",
|
||||
),
|
||||
releases.single().changes,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignores text that is not a release`() {
|
||||
assertTrue(parseChangelog("# Changelog\n\nNothing released yet.\n").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the shipped changelog is readable and newest first`() {
|
||||
val releases = MembyReleaseHistory
|
||||
assertTrue("CHANGELOG.md parsed to no releases", releases.size >= 2)
|
||||
assertTrue(releases.all { it.changes.isNotEmpty() })
|
||||
assertEquals(
|
||||
"the About page shows the file's order verbatim",
|
||||
releases.map { it.version }.sortedByDescending { version ->
|
||||
version.split('.').fold(0) { acc, part -> acc * 1_000 + part.toInt() }
|
||||
},
|
||||
releases.map { it.version },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `release dates are shown day first`() {
|
||||
assertEquals("11 Aug 2026", formatReleaseDate("2026-08-11"))
|
||||
assertEquals("unknown", formatReleaseDate("unknown"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `device activity is shown in local day first format`() {
|
||||
assertEquals(
|
||||
"11 Aug 2026, 10:05 pm",
|
||||
formatDeviceLastSeen("2026-08-11T10:05:00Z", ZoneId.of("Pacific/Auckland")),
|
||||
)
|
||||
assertEquals("not-a-date", formatDeviceLastSeen("not-a-date", ZoneId.of("UTC")))
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package com.ponzischeme89.memby.ui.whatsnew
|
||||
|
||||
import com.ponzischeme89.memby.ui.MEMBY_UPDATE_NOTIFICATION_ID
|
||||
import com.ponzischeme89.memby.ui.membyUpdateNotification
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class WhatsNewTest {
|
||||
|
||||
private fun decide(
|
||||
installed: String = "0.2.24",
|
||||
seen: String? = "0.2.23",
|
||||
signedIn: Boolean = true,
|
||||
) = whatsNewDecision(installed, seen, signedIn)
|
||||
|
||||
@Test
|
||||
fun `an updated television is notified of the build it is running`() {
|
||||
assertEquals(WhatsNewDecision.Notify("0.2.24"), decide())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same build is only announced once`() {
|
||||
assertEquals(WhatsNewDecision.Nothing, decide(seen = "0.2.24"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fresh install records the version instead of announcing it`() {
|
||||
assertEquals(
|
||||
WhatsNewDecision.MarkSeen("0.2.24"),
|
||||
decide(seen = null, signedIn = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an install that predates the record is announced once it is signed in`() {
|
||||
assertEquals(WhatsNewDecision.Notify("0.2.24"), decide(seen = null, signedIn = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a signed-out television with a record waits rather than consuming the notice`() {
|
||||
assertEquals(WhatsNewDecision.Nothing, decide(seen = "0.2.23", signedIn = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an updated build does not depend on a changelog entry`() {
|
||||
assertEquals(WhatsNewDecision.Notify("0.9.0"), decide(installed = "0.9.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable version does nothing at all`() {
|
||||
assertEquals(WhatsNewDecision.Nothing, decide(installed = " "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an installed update becomes a local Notifications entry`() {
|
||||
val alert = membyUpdateNotification("0.2.52", "2026-08-11T10:00:00Z", read = false)!!
|
||||
assertEquals(MEMBY_UPDATE_NOTIFICATION_ID, alert.id)
|
||||
assertEquals("Memby updated", alert.title)
|
||||
assertEquals("This TV is now running Memby 0.2.52.", alert.message)
|
||||
assertFalse(alert.readAt != null)
|
||||
assertNull(membyUpdateNotification(" ", null, read = false))
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user