Show toast & add option to fetch release notes when app updates (#1182)

## Description
When Wholphin is updated, it will show a short toast message. This works
for both app store and side loaded installs.

You can now click on the "Installed version" in settings to fetch the
release notes for the current version.

### Related issues
Closes #1058

### Testing
Emulator

## Screenshots
![release_notes
Large](https://github.com/user-attachments/assets/2c03eb74-abb2-4b45-8d6f-01e8ef72116c)

## AI or LLM usage
None
This commit is contained in:
Ray 2026-04-03 12:44:39 -04:00 committed by GitHub
parent 46beee00bb
commit feb8c31791
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 207 additions and 85 deletions

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
@ -51,14 +52,15 @@ import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
@ -394,6 +396,7 @@ class MainActivity : AppCompatActivity() {
class MainActivityViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val preferences: DataStore<AppPreferences>,
val serverRepository: ServerRepository,
private val navigationManager: SetupNavigationManager,
@ -402,9 +405,19 @@ class MainActivityViewModel
private val appUpgradeHandler: AppUpgradeHandler,
) : ViewModel() {
fun appStart() {
viewModelScope.launchIO {
viewModelScope.launchDefault {
try {
val needUpgrade = appUpgradeHandler.needUpgrade()
if (needUpgrade) {
showToast(
context,
context.getString(
R.string.updated_toast,
appUpgradeHandler.currentVersion.toString(),
),
)
appUpgradeHandler.run()
}
appUpgradeHandler.copySubfont(false)
val prefs =
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
@ -447,7 +460,7 @@ class MainActivityViewModel
navigationManager.navigateTo(SetupDestination.ServerList)
}
}
viewModelScope.launchIO {
viewModelScope.launchDefault {
// Create the mediaCodecCapabilitiesTest if needed
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
}

View file

@ -1,11 +1,11 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import android.content.pm.PackageInfo
import android.os.Build
import androidx.core.content.edit
import androidx.datastore.core.DataStore
import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
@ -43,12 +43,10 @@ class AppUpgradeHandler
private val appPreferences: DataStore<AppPreferences>,
private val seerrServerDao: SeerrServerDao,
) {
suspend fun run() {
val pkgInfo =
WholphinApplication.instance.packageManager.getPackageInfo(
WholphinApplication.instance.packageName,
0,
)
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
fun needUpgrade(): Boolean {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null)
val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1)
@ -60,10 +58,25 @@ class AppUpgradeHandler
} else {
pkgInfo.versionCode.toLong()
}
if (newVersion != previousVersion || newVersionCode != previousVersionCode) {
Timber.i(
"App updated: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode",
"App versions: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode",
)
return newVersion != previousVersion || newVersionCode != previousVersionCode
}
suspend fun run() {
Timber.i("App upgrade started")
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null)
val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1)
val newVersion = pkgInfo.versionName!!
val newVersionCode =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
pkgInfo.longVersionCode
} else {
pkgInfo.versionCode.toLong()
}
// Store the previous and new version info
prefs.edit(true) {
putString(VERSION_NAME_PREVIOUS_KEY, previousVersion)
@ -82,9 +95,6 @@ class AppUpgradeHandler
Timber.e(ex, "Exception during app upgrade")
}
Timber.i("App upgrade complete")
} else {
Timber.d("No app update needed")
}
}
/**

View file

@ -128,18 +128,36 @@ class UpdateChecker
return Version.fromString(pkgInfo.versionName!!)
}
suspend fun getRelease(version: Version): Release? {
val url =
"https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}"
return withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(url)
.get()
.build()
getRelease(request)
}
}
/**
* Get the latest released version
*/
suspend fun getLatestRelease(updateUrl: String): Release? {
return withContext(Dispatchers.IO) {
suspend fun getLatestRelease(updateUrl: String): Release? =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(updateUrl)
.get()
.build()
okHttpClient.newCall(request).execute().use {
getRelease(request)
}
private fun getRelease(request: Request): Release? {
return okHttpClient.newCall(request).execute().use {
if (it.isSuccessful && it.body != null) {
val result = Json.parseToJsonElement(it.body!!.string())
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
@ -161,19 +179,18 @@ class UpdateChecker
m.groupValues[1]
}.toList()
} else {
listOf()
emptyList()
}
return@use Release(version, downloadUrl, publishedAt, body, notes)
} else {
Timber.w("Update version parsing failed. name=$name")
}
} else {
Timber.w("Update check failed: ${it.message}")
Timber.w("Update check failed ${it.code}: ${it.message}")
}
return@use null
}
}
}
/**
* Download and install an update
@ -347,7 +364,19 @@ data class Release(
val publishedAt: String?,
val body: String?,
val notes: List<String>,
)
) {
val content =
"# ${version}\n" +
(
(notes.joinToString("\n").takeIf { it.isNotNullOrBlank() } ?: "") +
(body ?: "")
).replace(
Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"),
"#$1",
)
// Remove the last line for full changelog since its just a link
.replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "")
}
interface DownloadCallback {
fun contentLength(contentLength: Long)

View file

@ -24,7 +24,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -55,20 +54,26 @@ import com.github.damontecres.wholphin.preferences.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences
import com.github.damontecres.wholphin.preferences.screensaverPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.services.Release
import com.github.damontecres.wholphin.services.SeerrConnectionStatus
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.github.damontecres.wholphin.ui.setup.ReleaseNotes
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog
import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
@ -93,6 +98,7 @@ fun PreferencesContent(
val currentUser by viewModel.currentUser.observeAsState()
val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
var showPinFlow by remember { mutableStateOf(false) }
var showVersionDialog by remember { mutableStateOf(false) }
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
val seerrConnection by viewModel.seerrConnection.collectAsState()
@ -252,15 +258,13 @@ fun PreferencesContent(
}
when (pref) {
AppPreference.InstalledVersion -> {
var clickCount by remember { mutableIntStateOf(0) }
ClickPreference(
title = stringResource(R.string.installed_version),
onClick = {
if (movementSounds) playOnClickSound(context)
if (clickCount++ >= 2) {
clickCount = 0
showVersionDialog = true
},
onLongClick = {
viewModel.navigationManager.navigateTo(Destination.Debug)
}
},
summary = installedVersion.toString(),
interactionSource = interactionSource,
@ -615,6 +619,33 @@ fun PreferencesContent(
},
)
}
if (showVersionDialog) {
LaunchedEffect(Unit) {
viewModel.fetchReleaseNotes()
}
val release by viewModel.releaseNotes.collectAsState()
ScrollableDialog(
onDismissRequest = { showVersionDialog = false },
) {
item {
when (val r = release) {
is DataLoadingState.Error -> {
ErrorMessage(message = "Error", exception = r.exception)
}
DataLoadingState.Pending,
DataLoadingState.Loading,
-> {
LoadingPage()
}
is DataLoadingState.Success<Release> -> {
ReleaseNotes(r.data)
}
}
}
}
}
}
@Composable

View file

@ -11,10 +11,13 @@ import com.github.damontecres.wholphin.preferences.resetSubtitles
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.Release
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.RememberTabManager
@ -22,9 +25,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
@ -42,6 +47,7 @@ class PreferencesViewModel
private val seerrServerRepository: SeerrServerRepository,
private val deviceInfo: DeviceInfo,
private val clientInfo: ClientInfo,
private val updateChecker: UpdateChecker,
) : ViewModel(),
RememberTabManager by rememberTabManager {
val currentUser get() = serverRepository.currentUser
@ -51,6 +57,8 @@ class PreferencesViewModel
private val _quickConnectStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
val quickConnectStatus: StateFlow<LoadingState> = _quickConnectStatus
val releaseNotes = MutableStateFlow<DataLoadingState<Release>>(DataLoadingState.Pending)
init {
viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user ->
@ -99,6 +107,23 @@ class PreferencesViewModel
}
}
fun fetchReleaseNotes() {
viewModelScope.launchIO {
releaseNotes.update { DataLoadingState.Loading }
try {
val release = updateChecker.getRelease(updateChecker.getInstalledVersion())
if (release != null) {
releaseNotes.update { DataLoadingState.Success(release) }
} else {
releaseNotes.update { DataLoadingState.Error("Release not found") }
}
} catch (ex: Exception) {
Timber.e(ex, "Error fetching release")
releaseNotes.update { DataLoadingState.Error(ex) }
}
}
}
companion object {
suspend fun resetSubtitleSettings(appPreferences: DataStore<AppPreferences>) {
appPreferences.updateData {

View file

@ -53,7 +53,6 @@ import com.github.damontecres.wholphin.services.Release
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.TextButton
@ -66,6 +65,7 @@ import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.Version
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownTypography
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -272,15 +272,7 @@ fun InstallUpdatePageContent(
},
) {
item {
Markdown(
(release.notes.joinToString("\n\n") + (release.body ?: ""))
.replace(
Regex("https://github.com/damontecres/\\w+/pull/(\\d+)"),
"#$1",
)
// Remove the last line for full changelog since its just a link
.replace(Regex("\\*\\*Full Changelog\\*\\*.*"), ""),
)
ReleaseNotes(release)
}
}
Column(
@ -317,6 +309,25 @@ fun InstallUpdatePageContent(
}
}
@Composable
fun ReleaseNotes(
release: Release,
modifier: Modifier = Modifier,
) {
Markdown(
content = release.content,
typography =
markdownTypography(
h1 = MaterialTheme.typography.headlineLarge,
h2 = MaterialTheme.typography.headlineMedium,
h3 = MaterialTheme.typography.headlineSmall,
text = MaterialTheme.typography.bodySmall,
code = MaterialTheme.typography.bodySmall,
),
modifier = modifier,
)
}
@Composable
fun DownloadDialog(
contentLength: Long,
@ -392,6 +403,7 @@ private fun InstallUpdatePageContentPreview() {
downloadUrl = "https://url",
publishedAt = null,
body =
"## Header 2\n" +
"Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " +
"ex sapien vitae pellentesque sem placerat. In id cursus mi pretium " +
"tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. " +
@ -399,6 +411,7 @@ private fun InstallUpdatePageContentPreview() {
"Iaculis massa nisl malesuada lacinia integer nunc posuere. " +
"Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora " +
"torquent per conubia nostra inceptos himenaeos.\n\n" +
"### Header 3\n" +
"Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " +
"ex sapien vitae pellentesque sem placerat. In id cursus mi pretium " +
"tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. " +

View file

@ -748,5 +748,6 @@
<string name="select_all">Select all</string>
<string name="separate_types">Separate types</string>
<string name="prefer_logos">Prefer showing logos for titles</string>
<string name="updated_toast">Wholphin updated to %s</string>
</resources>