Adding support for automatic and manual update checks. (#7)

This commit is contained in:
Alex Yong 2025-09-05 15:58:17 -04:00 committed by GitHub
parent 8208981c66
commit d4eaea0709
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 423 additions and 16 deletions

View File

@ -0,0 +1,122 @@
package chat.revolt.api.internals
import android.content.Context
import chat.revolt.BuildConfig
import chat.revolt.persistence.KVStorage
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class GitHubRelease(
@SerialName("tag_name") val tagName: String,
@SerialName("name") val name: String,
@SerialName("body") val body: String?,
@SerialName("html_url") val htmlUrl: String,
@SerialName("assets") val assets: List<GitHubAsset>
)
@Serializable
data class GitHubAsset(
@SerialName("name") val name: String,
@SerialName("browser_download_url") val browserDownloadUrl: String
)
@Serializable
data class UpdateInfo(
val version: String,
val downloadUrl: String?,
val releaseNotesUrl: String,
val releaseNotes: String?
)
class UpdateChecker(
private val context: Context,
private val kvStorage: KVStorage
) {
private val httpClient = HttpClient(OkHttp) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
}
}
private suspend fun fetchLatestRelease(): GitHubRelease? {
return try {
withContext(Dispatchers.IO) {
val response = httpClient.get("https://api.github.com/repos/alexjyong/android/releases/latest")
response.body<GitHubRelease>()
}
} catch (e: Exception) {
android.util.Log.e("UpdateChecker", "Failed to fetch latest release", e)
null
}
}
suspend fun checkForUpdates(): UpdateInfo? {
return try {
val release = fetchLatestRelease() ?: return null
val currentVersion = BuildConfig.VERSION_NAME
val latestVersion = release.tagName
// Simple string comparison - if they're different, there's an update
if (currentVersion != latestVersion) {
val apkAsset = release.assets.find { it.name.endsWith(".apk") }
UpdateInfo(
version = latestVersion,
downloadUrl = apkAsset?.browserDownloadUrl,
releaseNotesUrl = release.htmlUrl,
releaseNotes = release.body
)
} else {
null
}
} catch (e: Exception) {
android.util.Log.e("UpdateChecker", "Failed to check for updates", e)
null
}
}
suspend fun shouldCheckForUpdates(): Boolean {
val isEnabled = kvStorage.getBoolean("updateChecker/enabled") ?: true // Default enabled
if (!isEnabled) return false
val lastCheckTimeStr = kvStorage.get("updateChecker/lastCheck")
val lastCheckTime = lastCheckTimeStr?.toLongOrNull() ?: 0
val now = System.currentTimeMillis()
val dayInMillis = 24 * 60 * 60 * 1000L
return (now - lastCheckTime) > dayInMillis
}
suspend fun markUpdateCheckDone() {
kvStorage.set("updateChecker/lastCheck", System.currentTimeMillis().toString())
}
suspend fun isUpdateCheckerEnabled(): Boolean {
return kvStorage.getBoolean("updateChecker/enabled") ?: true
}
suspend fun setUpdateCheckerEnabled(enabled: Boolean) {
kvStorage.set("updateChecker/enabled", enabled)
}
suspend fun dismissUpdate(version: String) {
kvStorage.set("updateChecker/dismissed/$version", true)
}
suspend fun isUpdateDismissed(version: String): Boolean {
return kvStorage.getBoolean("updateChecker/dismissed/$version") ?: false
}
}

View File

@ -0,0 +1,104 @@
package chat.revolt.composables.generic
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.revolt.R
import chat.revolt.api.internals.UpdateInfo
@Composable
fun UpdateBanner(
updateInfo: UpdateInfo,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Update ${updateInfo.version} available",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
TextButton(
onClick = {
// Open release page in browser
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(updateInfo.releaseNotesUrl))
context.startActivity(intent)
}
) {
Text("View Release")
}
if (updateInfo.downloadUrl != null) {
TextButton(
onClick = {
// Open download link in browser
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(updateInfo.downloadUrl))
context.startActivity(intent)
}
) {
Text("Download")
}
}
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.update_banner_close)
)
}
}
}
}
}

View File

@ -70,12 +70,15 @@ import chat.revolt.BuildConfig
import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.DirectMessages
import chat.revolt.api.internals.UpdateChecker
import chat.revolt.api.internals.UpdateInfo
import chat.revolt.api.realtime.DisconnectionState
import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.routes.push.subscribePush
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.composables.chat.DisconnectedNotice
import chat.revolt.composables.generic.UpdateBanner
import chat.revolt.composables.screens.chat.drawer.ChannelSideDrawer
import chat.revolt.dialogs.NotificationRationaleDialog
import chat.revolt.internals.Changelogs
@ -158,8 +161,10 @@ class ChatRouterViewModel @Inject constructor(
var showNotificationRationale by mutableStateOf(false)
var showEarlyAccessSpark by mutableStateOf(false)
var showSwipeToReplySpark by mutableStateOf(false)
var updateInfo by mutableStateOf<UpdateInfo?>(null)
private val changelogs = Changelogs(context, kvStorage)
private val updateChecker = UpdateChecker(context, kvStorage)
init {
viewModelScope.launch {
@ -201,6 +206,14 @@ class ChatRouterViewModel @Inject constructor(
if (!hasNotificationPermission && BuildConfig.DEBUG) {
showNotificationRationale = true
}
if (updateChecker.shouldCheckForUpdates()) {
val update = updateChecker.checkForUpdates()
if (update != null && !updateChecker.isUpdateDismissed(update.version)) {
updateInfo = update
}
updateChecker.markUpdateCheckDone()
}
}
}
@ -245,6 +258,15 @@ class ChatRouterViewModel @Inject constructor(
}
}
fun dismissUpdate() {
viewModelScope.launch {
updateInfo?.let {
updateChecker.dismissUpdate(it.version)
updateInfo = null
}
}
}
fun dismissEarlyAccessSpark() {
showEarlyAccessSpark = false
viewModelScope.launch {
@ -899,21 +921,32 @@ fun ChatRouterScreen(
},
content = {
Box(Modifier.fillMaxSize()) {
ChannelNavigator(
dest = viewModel.currentDestination,
topNav = topNav,
useDrawer = true,
disableBackHandler = disableBackHandler,
toggleDrawer = {
toggleDrawerLambda()
},
drawerState = drawerState,
drawerGestureEnabled = useSidebarGesture,
setDrawerGestureEnabled = {
useSidebarGesture = it
},
onEnterVoiceUI = onEnterVoiceUI,
)
Column {
viewModel.updateInfo?.let { updateInfo ->
UpdateBanner(
updateInfo = updateInfo,
onDismiss = viewModel::dismissUpdate
)
}
Box(Modifier.weight(1f)) {
ChannelNavigator(
dest = viewModel.currentDestination,
topNav = topNav,
useDrawer = true,
disableBackHandler = disableBackHandler,
toggleDrawer = {
toggleDrawerLambda()
},
drawerState = drawerState,
drawerGestureEnabled = useSidebarGesture,
setDrawerGestureEnabled = {
useSidebarGesture = it
},
onEnterVoiceUI = onEnterVoiceUI,
)
}
}
// This is the overlay on the main content when the drawer is open
val interactionSource = remember { MutableInteractionSource() }

View File

@ -2,14 +2,20 @@ package chat.revolt.screens.settings
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -18,10 +24,16 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
@ -33,23 +45,87 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import chat.revolt.BuildConfig
import chat.revolt.R
import chat.revolt.activities.InviteActivity
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.UpdateChecker
import chat.revolt.api.internals.UpdateInfo
import chat.revolt.api.settings.FeatureFlags
import chat.revolt.api.settings.LoadedSettings
import chat.revolt.composables.generic.ListHeader
import chat.revolt.composables.generic.UpdateBanner
import chat.revolt.persistence.KVStorage
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
import android.content.Context
@HiltViewModel
class SettingsScreenViewModel @Inject constructor(
private val kvStorage: KVStorage
private val kvStorage: KVStorage,
@ApplicationContext private val context: Context
) : ViewModel() {
private val updateChecker = UpdateChecker(context, kvStorage)
var isUpdateCheckerEnabled by mutableStateOf(false)
private set
var isCheckingForUpdates by mutableStateOf(false)
private set
var manualCheckResult by mutableStateOf<String?>(null)
private set
var foundUpdate by mutableStateOf<UpdateInfo?>(null)
private set
init {
viewModelScope.launch {
isUpdateCheckerEnabled = updateChecker.isUpdateCheckerEnabled()
}
}
fun toggleUpdateChecker(enabled: Boolean) {
isUpdateCheckerEnabled = enabled
viewModelScope.launch {
updateChecker.setUpdateCheckerEnabled(enabled)
}
}
fun manualCheckForUpdates() {
if (isCheckingForUpdates) return
viewModelScope.launch {
isCheckingForUpdates = true
manualCheckResult = null
foundUpdate = null
try {
val updateInfo = updateChecker.checkForUpdates()
if (updateInfo != null) {
foundUpdate = updateInfo
manualCheckResult = context.getString(R.string.update_check_available, updateInfo.version)
} else {
manualCheckResult = context.getString(R.string.update_check_up_to_date)
}
} catch (e: Exception) {
manualCheckResult = context.getString(R.string.update_check_failed)
} finally {
isCheckingForUpdates = false
}
}
}
fun clearManualCheckResult() {
manualCheckResult = null
foundUpdate = null
}
fun logout() {
runBlocking {
kvStorage.remove("sessionToken")
@ -104,6 +180,13 @@ fun SettingsScreen(
.fillMaxSize()
.padding(vertical = 10.dp)
) {
viewModel.foundUpdate?.let { updateInfo ->
UpdateBanner(
updateInfo = updateInfo,
onDismiss = { viewModel.clearManualCheckResult() }
)
}
ListHeader {
Text(stringResource(R.string.settings_category_account))
}
@ -217,6 +300,63 @@ fun SettingsScreen(
}
)
ListItem(
headlineContent = {
Text(text = "App Updates")
},
supportingContent = {
Column {
viewModel.manualCheckResult?.let { result ->
Text(
text = result,
style = MaterialTheme.typography.bodySmall,
color = if (viewModel.foundUpdate != null) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.padding(top = 2.dp)
)
}
}
},
leadingContent = {
SettingsIcon {
Icon(
painter = painterResource(R.drawable.icn_download_24dp),
contentDescription = null,
)
}
},
trailingContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Manual check button - more compact
Button(
onClick = { viewModel.manualCheckForUpdates() },
enabled = !viewModel.isCheckingForUpdates
) {
if (viewModel.isCheckingForUpdates) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp),
strokeWidth = 2.dp
)
} else {
Text(stringResource(R.string.update_check_now))
}
}
Switch(
checked = viewModel.isUpdateCheckerEnabled,
onCheckedChange = viewModel::toggleUpdateChecker
)
}
}
)
ListHeader {
Text(stringResource(R.string.settings_category_miscellaneous))
}

View File

@ -805,6 +805,14 @@
<string name="keyboard_shortcut_messaging_new_line">New Line</string>
<string name="keyboard_shortcut_messaging_send_message">Send Message</string>
<string name="update_banner_close">Close</string>
<string name="update_check_now">Check Now</string>
<string name="update_checking">Checking...</string>
<string name="update_check_clear">Clear</string>
<string name="update_check_up_to_date">You\'re up to date!</string>
<string name="update_check_failed">Failed to check for updates</string>
<string name="update_check_available">Update %1$s available!</string>
<string name="geogate_header">Not available in your region</string>
<string name="geogate_channel_icon_alt">Unavailable channel</string>
<string name="geogate_description">Revolt may block content in certain jurisdictions in response to legislation or legal notices</string>