Add prefs & page for app updates

This commit is contained in:
Damontecres 2025-10-11 18:32:19 -04:00
parent 14de2442bd
commit cbc680da7d
No known key found for this signature in database
9 changed files with 652 additions and 359 deletions

View file

@ -332,29 +332,36 @@ sealed interface AppPreference<T> {
getter = { }, getter = { },
setter = { prefs, _ -> prefs }, setter = { prefs, _ -> prefs },
) )
// val AutoCheckForUpdates =
// AppSwitchPreference(
// title = R.string.check_for_updates,
// prefKey = R.string.pref_key_auto_check_updates,
// defaultValue = true,
// getter = { it.updatePreferences.checkForUpdates },
// setter = { prefs, value ->
// prefs.updateUpdatePreferences { checkForUpdates = value }
// },
// summaryOn = R.string.enabled,
// summaryOff = R.string.disabled,
// )
// val UpdateUrl = val Update =
// AppStringPreference( AppClickablePreference(
// title = R.string.update_url, title = R.string.check_for_updates,
// defaultValue = "", getter = { },
// getter = { it.updatePreferences.updateUrl }, setter = { prefs, _ -> prefs },
// setter = { prefs, value -> )
// prefs.updateUpdatePreferences { updateUrl = value }
// }, val AutoCheckForUpdates =
// summary = R.string.update_url_summary, AppSwitchPreference(
// ) title = R.string.auto_check_for_updates,
defaultValue = true,
getter = { it.autoCheckForUpdates },
setter = { prefs, value ->
prefs.update { autoCheckForUpdates = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val UpdateUrl =
AppStringPreference(
title = R.string.update_url,
defaultValue = "", // TODO
getter = { it.updateUrl },
setter = { prefs, value ->
prefs.update { updateUrl = value }
},
summary = R.string.update_url_summary,
)
val OssLicenseInfo = val OssLicenseInfo =
AppDestinationPreference( AppDestinationPreference(
@ -465,7 +472,7 @@ val basicPreferences =
preferences = preferences =
listOf( listOf(
AppPreference.InstalledVersion, AppPreference.InstalledVersion,
AppPreference.OssLicenseInfo, AppPreference.Update,
), ),
), ),
PreferenceGroup( PreferenceGroup(
@ -494,6 +501,21 @@ val advancedPreferences =
AppPreference.PlaybackDebugInfo, AppPreference.PlaybackDebugInfo,
), ),
), ),
PreferenceGroup(
title = R.string.updates,
preferences =
listOf(
AppPreference.AutoCheckForUpdates,
AppPreference.UpdateUrl,
),
),
PreferenceGroup(
title = R.string.more,
preferences =
listOf(
AppPreference.OssLicenseInfo,
),
),
) )
data class AppSwitchPreference( data class AppSwitchPreference(

View file

@ -17,6 +17,9 @@ class AppPreferencesSerializer
AppPreferences AppPreferences
.newBuilder() .newBuilder()
.apply { .apply {
updateUrl = AppPreference.UpdateUrl.defaultValue
autoCheckForUpdates = AppPreference.AutoCheckForUpdates.defaultValue
playbackPreferences = playbackPreferences =
PlaybackPreferences PlaybackPreferences
.newBuilder() .newBuilder()

View file

@ -73,6 +73,9 @@ sealed class Destination(
constructor(item: BaseItem) : this(item.id, item.resumeMs ?: 0, item) constructor(item: BaseItem) : this(item.id, item.resumeMs ?: 0, item)
} }
@Serializable
data object UpdateApp : Destination(true)
@Serializable @Serializable
data object License : Destination(true) data object License : Destination(true)
} }

View file

@ -17,6 +17,7 @@ import com.github.damontecres.dolphin.ui.main.HomePage
import com.github.damontecres.dolphin.ui.main.SearchPage import com.github.damontecres.dolphin.ui.main.SearchPage
import com.github.damontecres.dolphin.ui.playback.PlaybackPage import com.github.damontecres.dolphin.ui.playback.PlaybackPage
import com.github.damontecres.dolphin.ui.preferences.PreferencesPage import com.github.damontecres.dolphin.ui.preferences.PreferencesPage
import com.github.damontecres.dolphin.ui.setup.InstallUpdatePage
import com.github.damontecres.dolphin.ui.setup.SwitchServerContent import com.github.damontecres.dolphin.ui.setup.SwitchServerContent
import com.github.damontecres.dolphin.ui.setup.SwitchUserContent import com.github.damontecres.dolphin.ui.setup.SwitchUserContent
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
@ -134,6 +135,8 @@ fun DestinationContent(
} }
} }
Destination.UpdateApp -> InstallUpdatePage(preferences, modifier)
Destination.License -> LicenseInfo(modifier) Destination.License -> LicenseInfo(modifier)
Destination.Search -> Destination.Search ->

View file

@ -20,6 +20,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -45,22 +46,21 @@ import com.github.damontecres.dolphin.preferences.advancedPreferences
import com.github.damontecres.dolphin.preferences.basicPreferences import com.github.damontecres.dolphin.preferences.basicPreferences
import com.github.damontecres.dolphin.preferences.uiPreferences import com.github.damontecres.dolphin.preferences.uiPreferences
import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.playOnClickSound import com.github.damontecres.dolphin.ui.playOnClickSound
import com.github.damontecres.dolphin.ui.playSoundOnFocus import com.github.damontecres.dolphin.ui.playSoundOnFocus
import com.github.damontecres.dolphin.ui.setup.UpdateViewModel
import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.ui.tryRequestFocus
import com.github.damontecres.dolphin.util.ExceptionHandler import com.github.damontecres.dolphin.util.ExceptionHandler
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class Release(
val version: String,
)
@Composable @Composable
fun PreferencesContent( fun PreferencesContent(
initialPreferences: AppPreferences, initialPreferences: AppPreferences,
preferenceScreenOption: PreferenceScreenOption, preferenceScreenOption: PreferenceScreenOption,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PreferencesViewModel = hiltViewModel(), viewModel: PreferencesViewModel = hiltViewModel(),
updateVM: UpdateViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -74,19 +74,16 @@ fun PreferencesContent(
} }
} }
val movementSounds = true val release by updateVM.release.observeAsState(null)
val installedVersion = LaunchedEffect(Unit) {
remember { context.packageManager.getPackageInfo(context.packageName, 0).versionName } if (preferences.autoCheckForUpdates) {
var updateVersion by remember { mutableStateOf<Release?>(null) } updateVM.init(preferences.updateUrl)
val updateAvailable = false }
// remember(updateVersion) { updateVersion?.version?.isGreaterThan(installedVersion) == true } }
// if (preferences.updatePreferences.checkForUpdates) { val movementSounds = true
// LaunchedEffect(Unit) { val installedVersion = updateVM.currentVersion
// updateVersion = val updateAvailable = release?.version?.isGreaterThan(installedVersion) ?: false
// UpdateChecker.getLatestRelease(context, preferences.updatePreferences.updateUrl)
// }
// }
val prefList = val prefList =
when (preferenceScreenOption) { when (preferenceScreenOption) {
@ -135,22 +132,9 @@ fun PreferencesContent(
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
) )
} }
prefList.forEachIndexed { groupIndex, group -> if (preferenceScreenOption == PreferenceScreenOption.BASIC &&
item { preferences.autoCheckForUpdates &&
Text( updateAvailable
text = stringResource(group.title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Start,
modifier =
Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 4.dp),
)
}
if (updateAvailable &&
groupIndex == 0 &&
preferenceScreenOption == PreferenceScreenOption.BASIC
) { ) {
item { item {
val updateFocusRequester = remember { FocusRequester() } val updateFocusRequester = remember { FocusRequester() }
@ -164,11 +148,9 @@ fun PreferencesContent(
title = stringResource(R.string.install_update), title = stringResource(R.string.install_update),
onClick = { onClick = {
if (movementSounds) playOnClickSound(context) if (movementSounds) playOnClickSound(context)
updateVersion?.let { viewModel.navigationManager.navigateTo(Destination.UpdateApp)
// navigationManager.navigateTo(Destination.UpdateApp(it))
}
}, },
summary = updateVersion?.version?.toString(), summary = release?.version?.toString(),
modifier = modifier =
Modifier Modifier
.focusRequester(updateFocusRequester) .focusRequester(updateFocusRequester)
@ -176,6 +158,19 @@ fun PreferencesContent(
) )
} }
} }
prefList.forEachIndexed { groupIndex, group ->
item {
Text(
text = stringResource(group.title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Start,
modifier =
Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 4.dp),
)
}
group.preferences.forEachIndexed { prefIndex, pref -> group.preferences.forEachIndexed { prefIndex, pref ->
pref as AppPreference<Any> pref as AppPreference<Any>
item { item {
@ -210,57 +205,45 @@ fun PreferencesContent(
) )
} }
// AppPreference.Update -> { AppPreference.Update -> {
// ClickPreference( ClickPreference(
// title = title =
// if (updateVersion != null && updateAvailable) { if (release != null && updateAvailable) {
// stringResource(R.string.install_update) stringResource(R.string.install_update)
// } else if (!preferences.updatePreferences.checkForUpdates && updateVersion == null) { } else if (!preferences.autoCheckForUpdates && release == null) {
// stringResource(R.string.check_for_updates) stringResource(R.string.check_for_updates)
// } else { } else {
// stringResource(R.string.no_update_available) stringResource(R.string.no_update_available)
// }, },
// onClick = { onClick = {
// if (movementSounds) playOnClickSound(context) if (movementSounds) playOnClickSound(context)
// if (updateVersion != null && updateAvailable) { if (release != null && updateAvailable) {
// updateVersion?.let { release?.let {
// navigationManager.navigate( viewModel.navigationManager.navigateTo(Destination.UpdateApp)
// Destination.UpdateApp(it), }
// ) } else {
// } updateVM.init(preferences.updateUrl)
// } else { }
// scope.launch { },
// updateVersion = onLongClick = {
// UpdateChecker.getLatestRelease( if (movementSounds) playOnClickSound(context)
// context, viewModel.navigationManager.navigateTo(Destination.UpdateApp)
// preferences.updatePreferences.updateUrl, },
// ) summary =
// } if (updateAvailable) {
// } release?.version?.toString()
// }, } else {
// onLongClick = { null
// if (movementSounds) playOnClickSound(context) },
// updateVersion?.let { interactionSource = interactionSource,
// navigationManager.navigate( modifier =
// Destination.UpdateApp(it), Modifier
// ) .ifElse(
// } groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
// }, Modifier.focusRequester(focusRequester),
// summary = ),
// if (updateAvailable) { )
// updateVersion?.version?.toString() }
// } else {
// null
// },
// interactionSource = interactionSource,
// modifier =
// Modifier
// .ifElse(
// groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
// Modifier.focusRequester(focusRequester),
// ),
// )
// }
else -> { else -> {
val value = pref.getter.invoke(preferences) val value = pref.getter.invoke(preferences)

View file

@ -0,0 +1,263 @@
package com.github.damontecres.dolphin.ui.setup
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.PreviewTvSpec
import com.github.damontecres.dolphin.ui.components.ErrorMessage
import com.github.damontecres.dolphin.ui.components.LoadingPage
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.ui.theme.DolphinTheme
import com.github.damontecres.dolphin.util.ExceptionHandler
import com.github.damontecres.dolphin.util.LoadingExceptionHandler
import com.github.damontecres.dolphin.util.LoadingState
import com.github.damontecres.dolphin.util.Release
import com.github.damontecres.dolphin.util.UpdateChecker
import com.github.damontecres.dolphin.util.Version
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
class UpdateViewModel
@Inject
constructor(
val updater: UpdateChecker,
val navigationManager: NavigationManager,
) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val release = MutableLiveData<Release?>(null)
val currentVersion = updater.getInstalledVersion()
fun init(updateUrl: String) {
loading.value = LoadingState.Loading
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to check for update")) {
val release = updater.getLatestRelease(updateUrl)
withContext(Dispatchers.Main) {
this@UpdateViewModel.release.value = release
loading.value = LoadingState.Success
}
}
}
fun installRelease(release: Release) {
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to install update")) {
updater.installRelease(release)
}
}
}
@Composable
fun InstallUpdatePage(
preferences: UserPreferences,
modifier: Modifier = Modifier,
viewModel: UpdateViewModel = hiltViewModel(),
) {
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val release by viewModel.release.observeAsState(null)
LaunchedEffect(Unit) {
viewModel.init(preferences.appPreferences.updateUrl)
}
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state, modifier)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage(modifier)
LoadingState.Success ->
release?.let {
if (it.version.isGreaterThan(viewModel.currentVersion)) {
InstallUpdatePageContent(
currentVersion = viewModel.currentVersion,
release = it,
onInstallRelease = {
viewModel.installRelease(it)
},
onCancel = {
viewModel.navigationManager.goBack()
},
modifier = modifier,
)
} else {
Text(
text = "No update available",
)
}
}
}
}
@Composable
fun InstallUpdatePageContent(
currentVersion: Version,
release: Release,
onInstallRelease: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
) {
val scrollAmount = 100f
val columnState = rememberLazyListState()
val scope = rememberCoroutineScope()
fun scroll(reverse: Boolean = false) {
scope.launch(ExceptionHandler()) {
columnState.scrollBy(if (reverse) -scrollAmount else scrollAmount)
}
}
val columnInteractionSource = remember { MutableInteractionSource() }
val columnFocused by columnInteractionSource.collectIsFocusedAsState()
val columnColor =
if (columnFocused) {
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
} else {
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
}
LazyColumn(
state = columnState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.focusable(interactionSource = columnInteractionSource)
.fillMaxHeight()
.fillMaxWidth(.6f)
.background(
columnColor,
shape = RoundedCornerShape(16.dp),
).onKeyEvent {
if (it.type == KeyEventType.KeyUp) {
return@onKeyEvent false
}
if (it.key == Key.DirectionDown) {
scroll(false)
return@onKeyEvent true
}
if (it.key == Key.DirectionUp) {
scroll(true)
return@onKeyEvent true
}
return@onKeyEvent false
},
) {
item {
Text(
// TODO render markdown
text = release.notes.joinToString("\n\n") + (release.body ?: ""),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.CenterVertically)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), shape = RoundedCornerShape(16.dp))
.padding(16.dp),
) {
Text(
text = "Update available",
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "$currentVersion => " + release.version.toString(),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Button(
onClick = onInstallRelease,
) {
Text(
text = "Download & Update",
)
}
Button(
onClick = onCancel,
) {
Text(
text = "Cancel",
)
}
}
}
}
@PreviewTvSpec
@Composable
private fun InstallUpdatePageContentPreview() {
DolphinTheme {
InstallUpdatePageContent(
currentVersion = Version.fromString("v0.4.0"),
release =
Release(
version = Version.fromString("v0.5.3"),
downloadUrl = "https://url",
publishedAt = null,
body =
"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" +
"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.",
notes = listOf(),
),
onInstallRelease = {},
onCancel = {},
)
}
}

View file

@ -16,8 +16,10 @@ import androidx.core.content.FileProvider
import androidx.core.content.edit import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.hilt.StandardOkHttpClient
import com.github.damontecres.dolphin.ui.findActivity import com.github.damontecres.dolphin.ui.findActivity
import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -31,13 +33,18 @@ import okhttp3.Request
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.util.Date import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
class UpdateChecker( @Singleton
private val context: Context, class UpdateChecker
private val okHttpClient: OkHttpClient, @Inject
) { constructor(
@param:ApplicationContext private val context: Context,
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) {
companion object { companion object {
// TODO apk names // TODO apk names
private const val ASSET_NAME = "Dolphin.apk" private const val ASSET_NAME = "Dolphin.apk"
@ -303,7 +310,7 @@ class UpdateChecker(
Timber.e(ex, "Exception during cleanup") Timber.e(ex, "Exception during cleanup")
} }
} }
} }
@Serializable @Serializable
data class Release( data class Release(

View file

@ -63,4 +63,7 @@ message AppPreferences {
PlaybackPreferences playback_preferences = 3; PlaybackPreferences playback_preferences = 3;
HomePagePreferences home_page_preferences = 4; HomePagePreferences home_page_preferences = 4;
InterfacePreferences interface_preferences = 5; InterfacePreferences interface_preferences = 5;
bool auto_check_for_updates = 6;
string update_url = 7;
} }

View file

@ -68,5 +68,11 @@
<string name="skip_comercials_behavior">Skip commercials behavior</string> <string name="skip_comercials_behavior">Skip commercials behavior</string>
<string name="skip_outro_behavior">Skip outro behavior</string> <string name="skip_outro_behavior">Skip outro behavior</string>
<string name="skip_intro_behavior">Skip intro behavior</string> <string name="skip_intro_behavior">Skip intro behavior</string>
<string name="check_for_updates">Check for updates</string>
<string name="auto_check_for_updates">Automatically check for updates</string>
<string name="update_url">Update URL</string>
<string name="update_url_summary">URL used to check for app updates</string>
<string name="updates">Updates</string>
<string name="no_update_available">No update available</string>
</resources> </resources>