From d93b9f1bcbf60afc98d39a8dd5a8e255523053d1 Mon Sep 17 00:00:00 2001 From: Infi Date: Tue, 3 Jan 2023 05:37:42 +0100 Subject: [PATCH] feat: connection handling - also adds an internet check on launch --- .../main/java/chat/revolt/api/RevoltAPI.kt | 9 +- .../revolt/api/realtime/RealtimeSocket.kt | 20 +- .../components/chat/DisconnectedNotice.kt | 82 ++++++ .../screens/splash/DisconnectedScreen.kt | 57 ++++ .../java/chat/revolt/screens/SplashScreen.kt | 54 +++- .../revolt/screens/chat/ChatRouterScreen.kt | 245 ++++++++++-------- .../revolt/screens/chat/views/HomeScreen.kt | 39 +-- app/src/main/res/values/strings.xml | 11 + 8 files changed, 361 insertions(+), 156 deletions(-) create mode 100644 app/src/main/java/chat/revolt/components/chat/DisconnectedNotice.kt create mode 100644 app/src/main/java/chat/revolt/components/screens/splash/DisconnectedScreen.kt diff --git a/app/src/main/java/chat/revolt/api/RevoltAPI.kt b/app/src/main/java/chat/revolt/api/RevoltAPI.kt index ae5ae224..ce78e3df 100644 --- a/app/src/main/java/chat/revolt/api/RevoltAPI.kt +++ b/app/src/main/java/chat/revolt/api/RevoltAPI.kt @@ -4,6 +4,7 @@ import android.os.Handler import android.os.Looper import android.util.Log import androidx.compose.runtime.mutableStateMapOf +import chat.revolt.api.realtime.DisconnectionState import chat.revolt.api.realtime.RealtimeSocket import chat.revolt.api.routes.user.fetchSelf import chat.revolt.api.schemas.* @@ -98,13 +99,7 @@ object RevoltAPI { } else { Log.e("RevoltAPI", "WebSocket error", e) } - RealtimeSocket.open = false - - Log.i("RevoltAPI", "Reconnecting in 2.5 seconds...") - Thread.sleep(2500) - runBlocking { - connectWS() - } // FIXME ASAP move this to ChatRouting (whenever that's a thing) and do it properly + RealtimeSocket.updateDisconnectionState(DisconnectionState.Disconnected) } } socketThread!!.start() diff --git a/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt b/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt index 21caf8cb..5e9d4e46 100644 --- a/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt +++ b/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt @@ -2,6 +2,7 @@ package chat.revolt.api.realtime import android.util.Log import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf import chat.revolt.api.REVOLT_WEBSOCKET import chat.revolt.api.RevoltAPI import chat.revolt.api.RevoltHttp @@ -14,16 +15,29 @@ import io.ktor.websocket.* import kotlinx.coroutines.channels.consumeEach import java.util.Calendar +enum class DisconnectionState { + Disconnected, + Reconnecting, + Connected +} + object RealtimeSocket { var socket: WebSocketSession? = null - var open: Boolean = false + + private var _disconnectionState = mutableStateOf(DisconnectionState.Disconnected) + val disconnectionState: DisconnectionState + get() = _disconnectionState.value + + fun updateDisconnectionState(state: DisconnectionState) { + _disconnectionState.value = state + } suspend fun connect(token: String) { RevoltHttp.ws(REVOLT_WEBSOCKET) { socket = this Log.d("RealtimeSocket", "Connected to websocket.") - open = true + updateDisconnectionState(DisconnectionState.Connected) // Send authorization frame val authFrame = AuthorizationFrame("Authenticate", token) @@ -46,7 +60,7 @@ object RealtimeSocket { } suspend fun sendPing() { - if (!open) return + if (disconnectionState != DisconnectionState.Connected) return val pingPacket = PingFrame("Ping", Calendar.getInstance().timeInMillis.toInt()) socket?.send(RevoltJson.encodeToString(PingFrame.serializer(), pingPacket)) diff --git a/app/src/main/java/chat/revolt/components/chat/DisconnectedNotice.kt b/app/src/main/java/chat/revolt/components/chat/DisconnectedNotice.kt new file mode 100644 index 00000000..c4af2107 --- /dev/null +++ b/app/src/main/java/chat/revolt/components/chat/DisconnectedNotice.kt @@ -0,0 +1,82 @@ +package chat.revolt.components.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +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.realtime.DisconnectionState + +@Composable +private fun DisconnectedNoticeBase( + background: Color, + icon: ImageVector, + text: String, + canTapToRetry: Boolean = false, + onRetry: () -> Unit = {} +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(background) + .padding(vertical = 8.dp, horizontal = 16.dp) + .clickable(enabled = canTapToRetry, onClick = onRetry), + ) { + Icon( + modifier = Modifier.padding(end = 8.dp), + imageVector = icon, + contentDescription = null + ) + Text( + text = text, + fontWeight = FontWeight.Bold + ) + if (canTapToRetry) { + Text( + text = stringResource(R.string.tap_to_reconnect), + modifier = Modifier.padding(start = 8.dp), + fontWeight = FontWeight.Normal + ) + } + } +} + +@Composable +fun DisconnectedNotice( + state: DisconnectionState, + onReconnect: () -> Unit +) { + when (state) { + DisconnectionState.Disconnected -> DisconnectedNoticeBase( + background = Color(0xfffe4654), + icon = Icons.Default.Warning, + text = stringResource(id = R.string.disconnected), + canTapToRetry = true, + onRetry = onReconnect + ) + DisconnectionState.Reconnecting -> DisconnectedNoticeBase( + background = Color(0xfffcb205), + icon = Icons.Default.Refresh, + text = stringResource(id = R.string.reconnecting), + ) + DisconnectionState.Connected -> DisconnectedNoticeBase( + background = Color(0xff4b9f6a), + icon = Icons.Default.Done, + text = stringResource(id = R.string.reconnected), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/components/screens/splash/DisconnectedScreen.kt b/app/src/main/java/chat/revolt/components/screens/splash/DisconnectedScreen.kt new file mode 100644 index 00000000..be1d68a1 --- /dev/null +++ b/app/src/main/java/chat/revolt/components/screens/splash/DisconnectedScreen.kt @@ -0,0 +1,57 @@ +package chat.revolt.components.screens.splash + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.revolt.R + +@Composable +fun DisconnectedScreen( + onRetry: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.no_connection), + style = MaterialTheme.typography.displaySmall.copy( + fontSize = 30.sp, + fontWeight = FontWeight.Black, + textAlign = TextAlign.Center + ), + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 10.dp) + .fillMaxWidth(), + ) + + Text( + text = stringResource(R.string.no_connection_message), + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + style = MaterialTheme.typography.titleMedium.copy( + textAlign = TextAlign.Center, + fontWeight = FontWeight.Normal, + ), + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 10.dp) + .fillMaxWidth() + ) + + Button(onClick = onRetry) { + Text(stringResource(R.string.tap_to_retry)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/SplashScreen.kt b/app/src/main/java/chat/revolt/screens/SplashScreen.kt index 5332e8cc..0cf20b46 100644 --- a/app/src/main/java/chat/revolt/screens/SplashScreen.kt +++ b/app/src/main/java/chat/revolt/screens/SplashScreen.kt @@ -1,5 +1,9 @@ package chat.revolt.screens +import android.annotation.SuppressLint +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -17,15 +21,20 @@ import chat.revolt.R import chat.revolt.api.RevoltAPI import chat.revolt.components.generic.RemoteImage import chat.revolt.components.generic.drawableResource +import chat.revolt.components.screens.splash.DisconnectedScreen import chat.revolt.persistence.KVStorage import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel +@SuppressLint("StaticFieldLeak") class SplashScreenViewModel @Inject constructor( - private val kvStorage: KVStorage + private val kvStorage: KVStorage, + @ApplicationContext private val context: Context ) : ViewModel() { + private var _navigateTo by mutableStateOf("") val navigateTo: String get() = _navigateTo @@ -34,8 +43,36 @@ class SplashScreenViewModel @Inject constructor( _navigateTo = value } - init { + private var _isConnected by mutableStateOf(false) + val isConnected: Boolean + get() = _isConnected + + fun setIsConnected(value: Boolean) { + _isConnected = value + } + + private fun hasInternetConnection(): Boolean { + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + val networkCapabilities = connectivityManager.activeNetwork ?: return false + val actNw = + connectivityManager.getNetworkCapabilities(networkCapabilities) ?: return false + + return when { + actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true + actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true + actNw.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true + else -> false + } + } + + fun checkLoggedInState() { viewModelScope.launch { + setIsConnected(hasInternetConnection()) + + if (!isConnected) return@launch + val token = kvStorage.get("sessionToken") ?: return@launch setNavigateTo("login") val valid = RevoltAPI.checkSessionToken(token) @@ -49,10 +86,23 @@ class SplashScreenViewModel @Inject constructor( } } } + + init { + checkLoggedInState() + } } @Composable fun SplashScreen(navController: NavController, viewModel: SplashScreenViewModel = hiltViewModel()) { + if (!viewModel.isConnected) { + DisconnectedScreen( + onRetry = { + viewModel.checkLoggedInState() + } + ) + return + } + Column( modifier = Modifier .fillMaxWidth() 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 0b503f4f..c1f8173b 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -1,5 +1,6 @@ package chat.revolt.screens.chat +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -33,7 +34,10 @@ import chat.revolt.api.RevoltAPI import chat.revolt.components.generic.RemoteImage import chat.revolt.screens.chat.views.HomeScreen import chat.revolt.R +import chat.revolt.api.realtime.DisconnectionState +import chat.revolt.api.realtime.RealtimeSocket import chat.revolt.api.schemas.ChannelType +import chat.revolt.components.chat.DisconnectedNotice import chat.revolt.components.screens.chat.DrawerChannel import chat.revolt.screens.chat.views.ChannelScreen import kotlinx.coroutines.launch @@ -60,120 +64,130 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie val navController = rememberNavController() val navBackStackEntry by navController.currentBackStackEntryAsState() - DismissibleNavigationDrawer( - drawerState = channelDrawerState, - drawerContent = { - ModalDrawerSheet(drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant) { - Column(Modifier.fillMaxWidth()) { - Row { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .background(MaterialTheme.colorScheme.surface) - ) { - IconButton( - onClick = { viewModel.goToHome() }, + Column() { + AnimatedVisibility(visible = RealtimeSocket.disconnectionState != DisconnectionState.Connected) { + DisconnectedNotice( + state = RealtimeSocket.disconnectionState, + onReconnect = { + RealtimeSocket.updateDisconnectionState(DisconnectionState.Reconnecting) + scope.launch { RevoltAPI.connectWS() } + }) + } + DismissibleNavigationDrawer( + drawerState = channelDrawerState, + drawerContent = { + ModalDrawerSheet(drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant) { + Column(Modifier.fillMaxWidth()) { + Row { + Column( modifier = Modifier - .padding(8.dp) - .size(48.dp) + .verticalScroll(rememberScrollState()) + .background(MaterialTheme.colorScheme.surface) ) { - Icon( - Icons.Default.Home, - contentDescription = stringResource(id = R.string.home), - modifier = Modifier.padding(4.dp) - ) - } - - RevoltAPI.serverCache.values.forEach { server -> - if (server.name == null) return@forEach - - if (server.icon != null) { - RemoteImage( - url = "$REVOLT_FILES/icons/${server.icon.id!!}/server.png?max_side=256", - modifier = Modifier - .padding(8.dp) - .size(48.dp) - .clip(CircleShape) - .clickable { viewModel.setCurrentServer(server.id!!) }, - description = "${server.name}" + IconButton( + onClick = { viewModel.goToHome() }, + modifier = Modifier + .padding(8.dp) + .size(48.dp) + ) { + Icon( + Icons.Default.Home, + contentDescription = stringResource(id = R.string.home), + modifier = Modifier.padding(4.dp) ) - } else { - // return a placeholder icon, currently the first letter of the server name in a circle - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .padding(8.dp) - .size(48.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable { viewModel.setCurrentServer(server.id!!) } - ) { - Text( - text = server.name.first().toString(), - fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + } + + RevoltAPI.serverCache.values.forEach { server -> + if (server.name == null) return@forEach + + if (server.icon != null) { + RemoteImage( + url = "$REVOLT_FILES/icons/${server.icon.id!!}/server.png?max_side=256", + modifier = Modifier + .padding(8.dp) + .size(48.dp) + .clip(CircleShape) + .clickable { viewModel.setCurrentServer(server.id!!) }, + description = "${server.name}" ) + } else { + // return a placeholder icon, currently the first letter of the server name in a circle + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(8.dp) + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { viewModel.setCurrentServer(server.id!!) } + ) { + Text( + text = server.name.first().toString(), + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } } } } - } - Crossfade(targetState = viewModel.currentServer) { - Column( - Modifier - .weight(1f) - ) { - if (it == "home") { - Column( - Modifier - .weight(1f) - .verticalScroll(rememberScrollState()) - ) { - RevoltAPI.channelCache.values.filter { it.channelType == ChannelType.DirectMessage } - .forEach { channel -> - DrawerChannel( - name = "DM #${channel.id}", // TODO get user or group name - channelType = ChannelType.DirectMessage, - selected = channel.id == (navBackStackEntry?.arguments?.getString( - "channelId" - ) ?: false), - onClick = { - navController.navigate("channel/${channel.id}") - scope.launch { - channelDrawerState.close() + Crossfade(targetState = viewModel.currentServer) { + Column( + Modifier + .weight(1f) + ) { + if (it == "home") { + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + RevoltAPI.channelCache.values.filter { it.channelType == ChannelType.DirectMessage } + .forEach { channel -> + DrawerChannel( + name = "DM #${channel.id}", // TODO get user or group name + channelType = ChannelType.DirectMessage, + selected = channel.id == (navBackStackEntry?.arguments?.getString( + "channelId" + ) ?: false), + onClick = { + navController.navigate("channel/${channel.id}") + scope.launch { + channelDrawerState.close() + } } - } - ) - } - } - } else { - val server = RevoltAPI.serverCache[it] + ) + } + } + } else { + val server = RevoltAPI.serverCache[it] - Text( - text = server?.name ?: stringResource(R.string.unknown), - fontWeight = FontWeight.Black, - fontSize = 24.sp, - modifier = Modifier.padding(16.dp) - ) + Text( + text = server?.name ?: stringResource(R.string.unknown), + fontWeight = FontWeight.Black, + fontSize = 24.sp, + modifier = Modifier.padding(16.dp) + ) - Column( - Modifier - .weight(1f) - .verticalScroll(rememberScrollState()) - ) { - server?.channels?.forEach { channelId -> - RevoltAPI.channelCache[channelId]?.let { ch -> - DrawerChannel( - name = ch.name!!, - channelType = ch.channelType!!, - selected = navBackStackEntry?.arguments?.getString( - "channelId" - ) == ch.id, - onClick = { - scope.launch { channelDrawerState.close() } - navController.navigate("channel/${ch.id}") - }) + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + server?.channels?.forEach { channelId -> + RevoltAPI.channelCache[channelId]?.let { ch -> + DrawerChannel( + name = ch.name!!, + channelType = ch.channelType!!, + selected = navBackStackEntry?.arguments?.getString( + "channelId" + ) == ch.id, + onClick = { + scope.launch { channelDrawerState.close() } + navController.navigate("channel/${ch.id}") + }) + } } } } @@ -182,18 +196,19 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie } } } - } - } - ) { - Column(Modifier.fillMaxSize()) { - NavHost(navController = navController, startDestination = "home") { - composable("home") { - HomeScreen(navController = navController) - } - composable("channel/{channelId}") { backStackEntry -> - val channelId = backStackEntry.arguments?.getString("channelId") - if (channelId != null) { - ChannelScreen(navController, channelId = channelId) + }, + modifier = Modifier.weight(1f), + ) { + Column(Modifier.fillMaxSize()) { + NavHost(navController = navController, startDestination = "home") { + composable("home") { + HomeScreen(navController = navController) + } + composable("channel/{channelId}") { backStackEntry -> + val channelId = backStackEntry.arguments?.getString("channelId") + if (channelId != null) { + ChannelScreen(navController, channelId = channelId) + } } } } diff --git a/app/src/main/java/chat/revolt/screens/chat/views/HomeScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/HomeScreen.kt index b9f1efba..0edbfa62 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/HomeScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/HomeScreen.kt @@ -1,21 +1,24 @@ package chat.revolt.screens.chat.views import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import androidx.navigation.NavController import chat.revolt.api.RevoltAPI +import chat.revolt.components.screens.home.LinkOnHome import chat.revolt.persistence.KVStorage import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch +import chat.revolt.R import kotlinx.coroutines.runBlocking import javax.inject.Inject @@ -23,37 +26,19 @@ import javax.inject.Inject class HomeScreenViewModel @Inject constructor( private val kvStorage: KVStorage ) : ViewModel() { - private var _messageContent by mutableStateOf("") - val messageContent: String - get() = _messageContent - - fun setMessageContent(value: String) { - _messageContent = value - } - fun logout() { runBlocking { kvStorage.remove("sessionToken") RevoltAPI.logout() } } - - fun sendMessage() { - viewModelScope.launch { - chat.revolt.api.routes.channel.sendMessage( - "01F7ZSBSFHCAAJQ92ZGTY67HMN", // revolt lounge #general (temporarily hardcoded) FIXME - messageContent - ) - } - setMessageContent("") - } } @Composable fun HomeScreen(navController: NavController, viewModel: HomeScreenViewModel = hiltViewModel()) { Column() { Text( - text = "Home (placeholder)", + text = stringResource(id = R.string.home), style = MaterialTheme.typography.displaySmall.copy( fontWeight = FontWeight.Bold, textAlign = TextAlign.Left, @@ -64,7 +49,9 @@ fun HomeScreen(navController: NavController, viewModel: HomeScreenViewModel = hi .fillMaxWidth(), ) - Button( + LinkOnHome( + heading = stringResource(id = R.string.logout), + icon = Icons.Default.Close, onClick = { viewModel.logout() navController.navigate("login/greeting") { @@ -72,12 +59,6 @@ fun HomeScreen(navController: NavController, viewModel: HomeScreenViewModel = hi inclusive = true } } - }, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 30.dp, top = 5.dp, start = 20.dp, end = 20.dp) - ) { - Text("Logout") - } + }) } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c37dc587..cc7be5f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -75,7 +75,9 @@ Select a server and channel by swiping from the left. Unknown + Home + Log out %1$s\'s avatar @@ -92,4 +94,13 @@ Add a note Unknown message, tap to jump + + Disconnected + Tap to reconnect + Reconnecting… + Reconnected + + No connection + You are not connected to the internet. Please check your connection and try again. + Tap to retry \ No newline at end of file