From feb8c3179107322a520351be5d122746fe588aa2 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:44:39 -0400 Subject: [PATCH] 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 --- .../damontecres/wholphin/MainActivity.kt | 21 +++- .../wholphin/services/AppUpgradeHandler.kt | 70 ++++++++------ .../wholphin/services/UpdateChecker.kt | 95 ++++++++++++------- .../ui/preferences/PreferencesContent.kt | 45 +++++++-- .../ui/preferences/PreferencesViewModel.kt | 25 +++++ .../wholphin/ui/setup/InstallUpdatePage.kt | 35 ++++--- app/src/main/res/values/strings.xml | 1 + 7 files changed, 207 insertions(+), 85 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index e8791bde..44b0bcb9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -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, 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 { - appUpgradeHandler.run() + 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() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index c1507d9f..23093cfd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -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, 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,31 +58,43 @@ class AppUpgradeHandler } else { pkgInfo.versionCode.toLong() } - if (newVersion != previousVersion || newVersionCode != previousVersionCode) { - Timber.i( - "App updated: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode", - ) - // Store the previous and new version info - prefs.edit(true) { - putString(VERSION_NAME_PREVIOUS_KEY, previousVersion) - putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode) - putString(VERSION_NAME_CURRENT_KEY, newVersion) - putLong(VERSION_CODE_CURRENT_KEY, newVersionCode) + Timber.i( + "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() } - try { - copySubfont(true) - upgradeApp( - Version.fromString(previousVersion ?: "0.0.0"), - Version.fromString(newVersion), - appPreferences, - ) - } catch (ex: Exception) { - Timber.e(ex, "Exception during app upgrade") - } - Timber.i("App upgrade complete") - } else { - Timber.d("No app update needed") + // Store the previous and new version info + prefs.edit(true) { + putString(VERSION_NAME_PREVIOUS_KEY, previousVersion) + putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode) + putString(VERSION_NAME_CURRENT_KEY, newVersion) + putLong(VERSION_CODE_CURRENT_KEY, newVersionCode) } + try { + copySubfont(true) + upgradeApp( + Version.fromString(previousVersion ?: "0.0.0"), + Version.fromString(newVersion), + appPreferences, + ) + } catch (ex: Exception) { + Timber.e(ex, "Exception during app upgrade") + } + Timber.i("App upgrade complete") } /** diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt index 9e0665dd..f1701416 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt @@ -128,50 +128,67 @@ 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 { - if (it.isSuccessful && it.body != null) { - val result = Json.parseToJsonElement(it.body!!.string()) - val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull - val version = Version.tryFromString(name) - val publishedAt = - result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull - val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull - val downloadUrl = - result.jsonObject["assets"] - ?.jsonArray - ?.let { assets -> getDownloadUrl(assets, BuildConfig.DEBUG) } - Timber.v("version=$version, downloadUrl=$downloadUrl") - if (version != null) { - val notes = - if (body.isNotNullOrBlank()) { - NOTE_REGEX - .findAll(body) - .map { m -> - m.groupValues[1] - }.toList() - } else { - listOf() - } - return@use Release(version, downloadUrl, publishedAt, body, notes) - } else { - Timber.w("Update version parsing failed. name=$name") - } + 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 + val version = Version.tryFromString(name) + val publishedAt = + result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull + val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull + val downloadUrl = + result.jsonObject["assets"] + ?.jsonArray + ?.let { assets -> getDownloadUrl(assets, BuildConfig.DEBUG) } + Timber.v("version=$version, downloadUrl=$downloadUrl") + if (version != null) { + val notes = + if (body.isNotNullOrBlank()) { + NOTE_REGEX + .findAll(body) + .map { m -> + m.groupValues[1] + }.toList() + } else { + emptyList() + } + return@use Release(version, downloadUrl, publishedAt, body, notes) } else { - Timber.w("Update check failed: ${it.message}") + Timber.w("Update version parsing failed. name=$name") } - return@use null + } else { + Timber.w("Update check failed ${it.code}: ${it.message}") } + return@use null } } @@ -347,7 +364,19 @@ data class Release( val publishedAt: String?, val body: String?, val notes: List, -) +) { + 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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index c214d3cb..18b630f6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -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 - viewModel.navigationManager.navigateTo(Destination.Debug) - } + 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 -> { + ReleaseNotes(r.data) + } + } + } + } + } } @Composable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 58f9a5e3..80dd7934 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -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.Pending) val quickConnectStatus: StateFlow = _quickConnectStatus + val releaseNotes = MutableStateFlow>(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.updateData { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt index cc6f2d3d..7e5a6ec5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt @@ -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,13 +403,15 @@ private fun InstallUpdatePageContentPreview() { downloadUrl = "https://url", publishedAt = null, body = - "Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " + + "## 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. " + "Pulvinar vivamus fringilla lacus nec metus bibendum egestas. " + "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. " + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b7dbbf3b..ce4bf2b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -748,5 +748,6 @@ Select all Separate types Prefer showing logos for titles + Wholphin updated to %s