Add app settings with update track and auto-check options

- Add AppSettingsScreen with update track selection and auto-check toggle
- Remove checkUpdateAvailable() as all vendors now support update checking
- Add missing Chinese translations for update-related strings
This commit is contained in:
世界
2025-12-16 18:25:10 +08:00
parent be175ccd73
commit e2e2c2ca7b
21 changed files with 862 additions and 21 deletions

View File

@@ -21,6 +21,8 @@ import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -58,6 +60,7 @@ import io.nekohasekai.sfa.bg.ServiceConnection
import io.nekohasekai.sfa.bg.ServiceNotification
import io.nekohasekai.sfa.compose.base.GlobalEventBus
import io.nekohasekai.sfa.compose.base.UiEvent
import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog
import io.nekohasekai.sfa.compose.navigation.SFANavHost
import io.nekohasekai.sfa.compose.navigation.Screen
import io.nekohasekai.sfa.compose.navigation.bottomNavigationScreens
@@ -71,6 +74,8 @@ import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ktx.hasPermission
import io.nekohasekai.sfa.ktx.launchCustomTab
import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.vendor.Vendor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -129,6 +134,16 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
connection.reconnect()
if (Settings.checkUpdateEnabled) {
lifecycleScope.launch(Dispatchers.IO) {
try {
val updateInfo = Vendor.checkUpdateAsync()
UpdateState.setUpdate(updateInfo)
} catch (_: Exception) {
}
}
}
setContent {
SFATheme {
SFAApp()
@@ -236,6 +251,16 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
}, onDismiss = { showBackgroundLocationDialog = false })
}
// Handle update available dialog
val updateInfo by UpdateState.updateInfo
var showUpdateDialog by remember { mutableStateOf(true) }
if (showUpdateDialog && updateInfo != null) {
UpdateAvailableDialog(
updateInfo = updateInfo!!,
onDismiss = { showUpdateDialog = false },
)
}
// Initialize the dashboard view model and store reference
val dashboardViewModel: DashboardViewModel = viewModel()
if (!::dashboardViewModel.isInitialized) {
@@ -253,6 +278,7 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
val isSettingsSubScreen = currentDestination?.route?.startsWith("settings/") == true
val settingsScreenTitle =
when (currentDestination?.route) {
"settings/app" -> stringResource(R.string.title_app_settings)
"settings/core" -> stringResource(R.string.core)
"settings/service" -> stringResource(R.string.service)
"settings/profile_override" -> stringResource(R.string.profile_override)
@@ -443,10 +469,19 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
bottomBar = {
// Only show bottom bar when not in settings sub-screens
if (!isSettingsSubScreen) {
val hasUpdate by UpdateState.hasUpdate
NavigationBar {
bottomNavigationScreens.forEach { screen ->
NavigationBarItem(
icon = { Icon(screen.icon, contentDescription = null) },
icon = {
if (screen == Screen.Settings && hasUpdate) {
BadgedBox(badge = { Badge() }) {
Icon(screen.icon, contentDescription = null)
}
} else {
Icon(screen.icon, contentDescription = null)
}
},
selected =
currentDestination?.hierarchy?.any {
it.route == screen.route

View File

@@ -0,0 +1,68 @@
package io.nekohasekai.sfa.compose.component
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.update.UpdateInfo
@Composable
fun UpdateAvailableDialog(
updateInfo: UpdateInfo,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.check_update)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
Text(
text = stringResource(R.string.new_version_available, updateInfo.versionName),
style = MaterialTheme.typography.bodyMedium,
)
if (!updateInfo.releaseNotes.isNullOrBlank()) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = updateInfo.releaseNotes.take(500) +
if (updateInfo.releaseNotes.length > 500) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
confirmButton = {
TextButton(
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(updateInfo.releaseUrl))
context.startActivity(intent)
onDismiss()
},
) {
Text(stringResource(R.string.update))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
},
)
}

View File

@@ -11,6 +11,7 @@ import io.nekohasekai.sfa.compose.screen.dashboard.DashboardScreen
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel
import io.nekohasekai.sfa.compose.screen.log.LogScreen
import io.nekohasekai.sfa.compose.screen.log.LogViewModel
import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen
import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen
@@ -57,6 +58,36 @@ fun SFANavHost(
}
// Settings subscreens with slide animations
composable(
route = "settings/app",
enterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(300),
)
},
exitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(300),
)
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(300),
)
},
popExitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(300),
)
},
) {
AppSettingsScreen(navController = navController)
}
composable(
route = "settings/core",
enterTransition = {

View File

@@ -0,0 +1,388 @@
package io.nekohasekai.sfa.compose.screen.settings
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Autorenew
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.NewReleases
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.Switch
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.update.UpdateCheckException
import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.update.UpdateTrack
import io.nekohasekai.sfa.vendor.Vendor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSettingsScreen(navController: NavController) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val hasUpdate by UpdateState.hasUpdate
val updateInfo by UpdateState.updateInfo
val isChecking by UpdateState.isChecking
var showTrackDialog by remember { mutableStateOf(false) }
var currentTrack by remember { mutableStateOf(Settings.updateTrack) }
var checkUpdateEnabled by remember { mutableStateOf(Settings.checkUpdateEnabled) }
var showErrorDialog by remember { mutableStateOf<Int?>(null) }
if (showTrackDialog) {
UpdateTrackDialog(
currentTrack = currentTrack,
onTrackSelected = { track ->
currentTrack = track
scope.launch(Dispatchers.IO) {
Settings.updateTrack = track
}
showTrackDialog = false
},
onDismiss = { showTrackDialog = false },
)
}
showErrorDialog?.let { messageRes ->
AlertDialog(
onDismissRequest = { showErrorDialog = null },
title = { Text(stringResource(R.string.check_update)) },
text = { Text(stringResource(messageRes)) },
confirmButton = {
TextButton(onClick = { showErrorDialog = null }) {
Text(stringResource(R.string.ok))
}
},
)
}
Column(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
// Info Card
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
ListItem(
headlineContent = {
Text(
stringResource(R.string.app_version_title),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(
BuildConfig.VERSION_NAME,
style = MaterialTheme.typography.bodyMedium,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
trailingContent = {
if (hasUpdate) {
Badge { Text("New") }
}
},
modifier =
Modifier
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
if (Vendor.supportsTrackSelection()) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.update_track),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
val trackName = when (UpdateTrack.fromString(currentTrack)) {
UpdateTrack.STABLE -> stringResource(R.string.update_track_stable)
UpdateTrack.BETA -> stringResource(R.string.update_track_beta)
}
Text(trackName, style = MaterialTheme.typography.bodyMedium)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.NewReleases,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier =
Modifier
.clickable { showTrackDialog = true },
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
ListItem(
headlineContent = {
Text(
stringResource(R.string.check_update_automatic),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.Autorenew,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
trailingContent = {
Switch(
checked = checkUpdateEnabled,
onCheckedChange = { checked ->
checkUpdateEnabled = checked
scope.launch(Dispatchers.IO) {
Settings.checkUpdateEnabled = checked
}
},
)
},
modifier =
Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)),
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Action Section
Text(
text = stringResource(R.string.action),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
ListItem(
headlineContent = {
Text(
stringResource(R.string.check_update),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.Refresh,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
trailingContent = {
if (isChecking) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
}
},
modifier =
Modifier
.clip(
if (hasUpdate) {
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
} else {
RoundedCornerShape(12.dp)
},
)
.clickable(enabled = !isChecking) {
scope.launch {
UpdateState.isChecking.value = true
withContext(Dispatchers.IO) {
try {
val result = Vendor.checkUpdateAsync()
UpdateState.setUpdate(result)
if (result == null) {
showErrorDialog = R.string.no_updates_available
}
} catch (_: UpdateCheckException.TrackNotSupported) {
showErrorDialog = R.string.update_track_not_supported
} catch (_: Exception) {
}
}
UpdateState.isChecking.value = false
}
},
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
if (hasUpdate && updateInfo != null) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.update),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(
updateInfo!!.versionName,
style = MaterialTheme.typography.bodyMedium,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.Download,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier =
Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.clickable {
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(updateInfo!!.releaseUrl),
)
context.startActivity(intent)
},
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
}
}
}
}
@Composable
private fun UpdateTrackDialog(
currentTrack: String,
onTrackSelected: (String) -> Unit,
onDismiss: () -> Unit,
) {
val tracks = listOf(
"stable" to stringResource(R.string.update_track_stable),
"beta" to stringResource(R.string.update_track_beta),
)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.update_track)) },
text = {
Column {
tracks.forEach { (value, label) ->
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { onTrackSelected(value) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = currentTrack == value,
onClick = { onTrackSelected(value) },
)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 8.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
},
)
}

View File

@@ -17,9 +17,11 @@ import androidx.compose.material.icons.outlined.Code
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.Favorite
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SwapHoriz
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.Badge
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -29,6 +31,7 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -40,6 +43,7 @@ import androidx.navigation.NavController
import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.update.UpdateState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -48,6 +52,7 @@ import kotlinx.coroutines.launch
fun SettingsScreen(navController: NavController) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val hasUpdate by UpdateState.hasUpdate
Column(
modifier =
@@ -69,6 +74,35 @@ fun SettingsScreen(navController: NavController) {
),
) {
Column {
ListItem(
headlineContent = {
Text(
stringResource(R.string.title_app_settings),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
trailingContent = {
if (hasUpdate) {
Badge()
}
},
modifier =
Modifier
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.clickable { navController.navigate("settings/app") },
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
ListItem(
headlineContent = {
Text(
@@ -85,7 +119,6 @@ fun SettingsScreen(navController: NavController) {
},
modifier =
Modifier
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.clickable { navController.navigate("settings/core") },
colors =
ListItemDefaults.colors(

View File

@@ -4,6 +4,7 @@ object SettingsKey {
const val SELECTED_PROFILE = "selected_profile"
const val SERVICE_MODE = "service_mode"
const val CHECK_UPDATE_ENABLED = "check_update_enabled"
const val UPDATE_TRACK = "update_track"
const val DISABLE_MEMORY_LIMIT = "disable_memory_limit"
const val DYNAMIC_NOTIFICATION = "dynamic_notification"
const val USE_COMPOSE_UI = "use_compose_ui"

View File

@@ -40,6 +40,7 @@ object Settings {
var startedByUser by dataStore.boolean(SettingsKey.STARTED_BY_USER)
var checkUpdateEnabled by dataStore.boolean(SettingsKey.CHECK_UPDATE_ENABLED) { true }
var updateTrack by dataStore.string(SettingsKey.UPDATE_TRACK) { "stable" }
var disableMemoryLimit by dataStore.boolean(SettingsKey.DISABLE_MEMORY_LIMIT)
var dynamicNotification by dataStore.boolean(SettingsKey.DYNAMIC_NOTIFICATION) { true }
var useComposeUI by dataStore.boolean(SettingsKey.USE_COMPOSE_UI) { true }

View File

@@ -209,11 +209,9 @@ class MainActivity :
}
private fun startIntegration() {
if (Vendor.checkUpdateAvailable()) {
lifecycleScope.launch(Dispatchers.IO) {
if (Settings.checkUpdateEnabled) {
Vendor.checkUpdate(this@MainActivity, false)
}
lifecycleScope.launch(Dispatchers.IO) {
if (Settings.checkUpdateEnabled) {
Vendor.checkUpdate(this@MainActivity, false)
}
}
}

View File

@@ -82,10 +82,6 @@ class SettingsFragment : Fragment() {
}
}
}
if (!Vendor.checkUpdateAvailable()) {
binding.checkUpdateEnabled.isVisible = false
binding.checkUpdateButton.isVisible = false
}
binding.checkUpdateEnabled.addTextChangedListener {
lifecycleScope.launch(Dispatchers.IO) {
val newValue = EnabledType.valueOf(requireContext(), it).boolValue

View File

@@ -0,0 +1,5 @@
package io.nekohasekai.sfa.update
sealed class UpdateCheckException : Exception() {
class TrackNotSupported : UpdateCheckException()
}

View File

@@ -0,0 +1,10 @@
package io.nekohasekai.sfa.update
data class UpdateInfo(
val versionCode: Int,
val versionName: String,
val downloadUrl: String,
val releaseUrl: String,
val releaseNotes: String?,
val isPrerelease: Boolean,
)

View File

@@ -0,0 +1,19 @@
package io.nekohasekai.sfa.update
import androidx.compose.runtime.mutableStateOf
object UpdateState {
val hasUpdate = mutableStateOf(false)
val updateInfo = mutableStateOf<UpdateInfo?>(null)
val isChecking = mutableStateOf(false)
fun setUpdate(info: UpdateInfo?) {
updateInfo.value = info
hasUpdate.value = info != null
}
fun clear() {
hasUpdate.value = false
updateInfo.value = null
}
}

View File

@@ -0,0 +1,15 @@
package io.nekohasekai.sfa.update
enum class UpdateTrack {
STABLE,
BETA;
companion object {
fun fromString(value: String): UpdateTrack {
return when (value.lowercase()) {
"beta" -> BETA
else -> STABLE
}
}
}
}

View File

@@ -2,10 +2,9 @@ package io.nekohasekai.sfa.vendor
import android.app.Activity
import androidx.camera.core.ImageAnalysis
import io.nekohasekai.sfa.update.UpdateInfo
interface VendorInterface {
fun checkUpdateAvailable(): Boolean
fun checkUpdate(
activity: Activity,
byUser: Boolean,
@@ -21,4 +20,16 @@ interface VendorInterface {
* @return true if available, false if disabled (e.g., for Play Store builds)
*/
fun isPerAppProxyAvailable(): Boolean = true
/**
* Check if track selection is available (e.g., stable/beta)
* @return true if track selection is supported
*/
fun supportsTrackSelection(): Boolean = false
/**
* Check for updates asynchronously
* @return UpdateInfo if update is available, null otherwise
*/
fun checkUpdateAsync(): UpdateInfo? = null
}