This commit is contained in:
ponzischeme89
2026-08-24 09:16:19 +12:00
parent 30155e1c5f
commit 067395a362
38 changed files with 104 additions and 60756 deletions
+1 -29
View File
@@ -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

-12
View File
@@ -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>
-1
View File
@@ -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))
}
}