Adding support for automatic and manual update checks. (#7)
This commit is contained in:
parent
8208981c66
commit
d4eaea0709
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue