feat: connection handling

- also adds an internet check on launch
This commit is contained in:
Infi 2023-01-03 05:37:42 +01:00
parent 8f068edeec
commit d93b9f1bcb
8 changed files with 361 additions and 156 deletions

View File

@ -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()

View File

@ -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))

View File

@ -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),
)
}
}

View File

@ -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))
}
}
}

View File

@ -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()

View File

@ -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)
}
}
}
}

View File

@ -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")
}
})
}
}

View File

@ -75,7 +75,9 @@
<string name="select_channel">Select a server and channel by swiping from the left.</string>
<string name="unknown">Unknown</string>
<string name="home">Home</string>
<string name="logout">Log out</string>
<string name="avatar_alt">%1$s\'s avatar</string>
@ -92,4 +94,13 @@
<string name="message_field_placeholder_notes">Add a note</string>
<string name="reply_message_not_cached">Unknown message, tap to jump</string>
<string name="disconnected">Disconnected</string>
<string name="tap_to_reconnect">Tap to reconnect</string>
<string name="reconnecting">Reconnecting…</string>
<string name="reconnected">Reconnected</string>
<string name="no_connection">No connection</string>
<string name="no_connection_message">You are not connected to the internet. Please check your connection and try again.</string>
<string name="tap_to_retry">Tap to retry</string>
</resources>