diff --git a/app/src/main/java/chat/revolt/api/internals/UpdateChecker.kt b/app/src/main/java/chat/revolt/api/internals/UpdateChecker.kt new file mode 100644 index 00000000..7989e559 --- /dev/null +++ b/app/src/main/java/chat/revolt/api/internals/UpdateChecker.kt @@ -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 +) + +@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() + } + } 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/composables/generic/UpdateBanner.kt b/app/src/main/java/chat/revolt/composables/generic/UpdateBanner.kt new file mode 100644 index 00000000..dbf63ef5 --- /dev/null +++ b/app/src/main/java/chat/revolt/composables/generic/UpdateBanner.kt @@ -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) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt index 9f6d9a37..58327460 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -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(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() } diff --git a/app/src/main/java/chat/revolt/screens/settings/SettingsScreen.kt b/app/src/main/java/chat/revolt/screens/settings/SettingsScreen.kt index 01af3010..f744b140 100644 --- a/app/src/main/java/chat/revolt/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/chat/revolt/screens/settings/SettingsScreen.kt @@ -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(null) + private set + + var foundUpdate by mutableStateOf(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)) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 78d7e200..273b29b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -805,6 +805,14 @@ New Line Send Message + Close + Check Now + Checking... + Clear + You\'re up to date! + Failed to check for updates + Update %1$s available! + Not available in your region Unavailable channel Revolt may block content in certain jurisdictions in response to legislation or legal notices