Private
Public Access
1
0

Better error handling

This commit is contained in:
2023-12-10 19:37:53 -08:00
parent a8886279c6
commit 7db8c39042
6 changed files with 134 additions and 49 deletions

View File

@@ -1,16 +1,13 @@
package net.buzzert.kordophonedroid package net.buzzert.kordophonedroid
import android.app.Application import android.app.Application
import androidx.hilt.navigation.compose.hiltViewModel
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import net.buzzert.kordophonedroid.data.AppContainer import net.buzzert.kordophonedroid.data.AppContainer
import net.buzzert.kordophonedroid.data.AppContainerImpl
@HiltAndroidApp @HiltAndroidApp
class KordophoneApplication : Application() { class KordophoneApplication : Application() {
lateinit var container: AppContainer
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
container = AppContainerImpl()
} }
} }

View File

@@ -4,7 +4,6 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import net.buzzert.kordophonedroid.data.AppContainerImpl
import net.buzzert.kordophonedroid.ui.KordophoneApp import net.buzzert.kordophonedroid.ui.KordophoneApp
@AndroidEntryPoint @AndroidEntryPoint
@@ -12,9 +11,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val container = (application as KordophoneApplication).container
setContent { setContent {
KordophoneApp(appContainer = container) KordophoneApp()
} }
} }
} }

View File

@@ -1,18 +1,15 @@
package net.buzzert.kordophonedroid.data package net.buzzert.kordophonedroid.data
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.ViewModelStoreOwner
import dagger.hilt.android.lifecycle.HiltViewModel
import net.buzzert.kordophone.backend.server.ChatRepository import net.buzzert.kordophone.backend.server.ChatRepository
import javax.inject.Inject
interface AppContainer: ViewModelStoreOwner { @HiltViewModel
class AppContainer @Inject constructor(
val repository: ChatRepository val repository: ChatRepository
} ) : ViewModel() {
class AppContainerImpl() : AppContainer {
override val repository: ChatRepository
get() = TODO("Not yet implemented")
override val viewModelStore: ViewModelStore
get() = TODO("Not yet implemented")
}
}

View File

@@ -1,11 +1,20 @@
package net.buzzert.kordophonedroid.ui package net.buzzert.kordophonedroid.ui
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.flow.collectLatest
import net.buzzert.kordophone.backend.server.ChatRepository
import net.buzzert.kordophonedroid.data.AppContainer import net.buzzert.kordophonedroid.data.AppContainer
import net.buzzert.kordophonedroid.ui.theme.KordophoneTheme import net.buzzert.kordophonedroid.ui.theme.KordophoneTheme
@@ -19,12 +28,35 @@ sealed class Destination(val route: String) {
} }
} }
@Composable
fun ErrorDialog(title: String, body: String, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = { onDismiss() },
title = { Text(title) },
text = { Text(body) },
confirmButton = {
Button(onClick = { onDismiss() }) {
Text("OK")
}
}
)
}
@Composable @Composable
fun KordophoneApp( fun KordophoneApp(
appContainer: AppContainer, appContainer: AppContainer = hiltViewModel(),
) { ) {
KordophoneTheme { KordophoneTheme {
val navController = rememberNavController() val navController = rememberNavController()
val errorVisible = remember { mutableStateOf<ChatRepository.Error?>(null) }
val error = appContainer.repository.errorEncounteredChannel.collectAsStateWithLifecycle(
initialValue = null
)
LaunchedEffect(key1 = error.value) {
errorVisible.value = error.value
}
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Destination.ConversationList.route, startDestination = Destination.ConversationList.route,
@@ -42,5 +74,11 @@ fun KordophoneApp(
}) })
} }
} }
errorVisible.value?.let {
ErrorDialog(title = it.title, body = it.description) {
errorVisible.value = null
}
}
} }
} }

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@@ -20,6 +21,7 @@ import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.model.GUID import net.buzzert.kordophone.backend.model.GUID
import net.buzzert.kordophone.backend.model.Message import net.buzzert.kordophone.backend.model.Message
import net.buzzert.kordophone.backend.model.UpdateItem import net.buzzert.kordophone.backend.model.UpdateItem
import java.lang.Error
import java.net.URL import java.net.URL
import java.util.Queue import java.util.Queue
import java.util.UUID import java.util.UUID
@@ -33,6 +35,16 @@ class ChatRepository(
private val apiClient: APIClient, private val apiClient: APIClient,
private val database: CachedChatDatabase, private val database: CachedChatDatabase,
) { ) {
sealed class Error {
open val title: String = "Error"
open val description: String = "Generic Error"
data class ConnectionError(val exception: java.lang.Exception): Error() {
override val title: String = "Connection Error"
override val description: String = exception.message ?: "???"
}
}
// All (Cached) Conversations // All (Cached) Conversations
val conversations: List<Conversation> val conversations: List<Conversation>
get() = database.fetchConversations() get() = database.fetchConversations()
@@ -46,6 +58,10 @@ class ChatRepository(
get() = database.conversationChanges get() = database.conversationChanges
.onEach { Log.d(REPO_LOG, "Got database conversations changed") } .onEach { Log.d(REPO_LOG, "Got database conversations changed") }
// Errors channel
val errorEncounteredChannel: SharedFlow<Error>
get() = _errorEncounteredChannel
fun messagesChanged(conversation: Conversation): Flow<List<Message>> = fun messagesChanged(conversation: Conversation): Flow<List<Message>> =
database.messagesChanged(conversation) database.messagesChanged(conversation)
@@ -72,6 +88,7 @@ class ChatRepository(
private val outgoingMessageQueue: ArrayBlockingQueue<OutgoingMessageInfo> = ArrayBlockingQueue(16) private val outgoingMessageQueue: ArrayBlockingQueue<OutgoingMessageInfo> = ArrayBlockingQueue(16)
private var outgoingMessageThread: Thread? = null private var outgoingMessageThread: Thread? = null
private val _messageDeliveredChannel = MutableSharedFlow<MessageDeliveredEvent>() private val _messageDeliveredChannel = MutableSharedFlow<MessageDeliveredEvent>()
private val _errorEncounteredChannel = MutableSharedFlow<Error>()
private val updateMonitor = UpdateMonitor(apiClient) private val updateMonitor = UpdateMonitor(apiClient)
private var updateWatchJob: Job? = null private var updateWatchJob: Job? = null
@@ -126,7 +143,7 @@ class ChatRepository(
return database.fetchMessages(conversation) return database.fetchMessages(conversation)
} }
suspend fun synchronize() { suspend fun synchronize() = try {
Log.d(REPO_LOG, "Synchronizing conversations") Log.d(REPO_LOG, "Synchronizing conversations")
// Sync conversations // Sync conversations
@@ -139,13 +156,17 @@ class ChatRepository(
for (conversation in sortedConversations.take(CONVERSATION_MESSAGE_SYNC_COUNT)) { for (conversation in sortedConversations.take(CONVERSATION_MESSAGE_SYNC_COUNT)) {
synchronizeConversation(conversation) synchronizeConversation(conversation)
} }
} catch (e: java.lang.Exception) {
_errorEncounteredChannel.emit(Error.ConnectionError(e))
} }
suspend fun synchronizeConversation(conversation: Conversation) { suspend fun synchronizeConversation(conversation: Conversation) = try {
// TODO: Should only fetch messages after the last GUID we know about. // TODO: Should only fetch messages after the last GUID we know about.
// But keep in mind that outgoing message GUIDs are fake... // But keep in mind that outgoing message GUIDs are fake...
val messages = fetchMessages(conversation, limit = 15) val messages = fetchMessages(conversation, limit = 15)
database.writeMessages(messages, conversation) database.writeMessages(messages, conversation)
} catch (e: java.lang.Exception) {
_errorEncounteredChannel.emit(Error.ConnectionError(e))
} }
fun close() { fun close() {
@@ -187,6 +208,11 @@ class ChatRepository(
database.writeMessages(listOf(event.message), event.conversation, outgoing = true) database.writeMessages(listOf(event.message), event.conversation, outgoing = true)
} }
private suspend fun retryMessageSend(info: OutgoingMessageInfo) {
delay(5000L)
outgoingMessageQueue.add(info)
}
private fun outgoingMessageQueueMain() { private fun outgoingMessageQueueMain() {
Log.d(REPO_LOG, "Outgoing Message Queue Main") Log.d(REPO_LOG, "Outgoing Message Queue Main")
while (true) { while (true) {
@@ -198,36 +224,41 @@ class ChatRepository(
Log.d(REPO_LOG, "Sending message to $conversation: $outgoingMessage") Log.d(REPO_LOG, "Sending message to $conversation: $outgoingMessage")
val result = apiInterface.sendMessage( try {
SendMessageRequest( val result = apiInterface.sendMessage(
conversationGUID = conversation.guid, SendMessageRequest(
body = outgoingMessage.text, conversationGUID = conversation.guid,
transferGUIDs = null, body = outgoingMessage.text,
) transferGUIDs = null,
)
if (result.isSuccessful) {
val messageGuid = result.body()?.sentMessageGUID ?: outgoingMessage.guid
Log.d(REPO_LOG, "Successfully sent message: $messageGuid")
val newMessage = Message(
guid = messageGuid,
text = outgoingMessage.text,
sender = null,
conversation = it.conversation,
date = outgoingMessage.date
)
_messageDeliveredChannel.emit(
MessageDeliveredEvent(
newMessage,
conversation,
requestGuid
) )
) )
} else {
Log.e(REPO_LOG, "Error sending message. Enqueuing for retry.") if (result.isSuccessful) {
outgoingMessageQueue.add(it) val messageGuid = result.body()?.sentMessageGUID ?: outgoingMessage.guid
Log.d(REPO_LOG, "Successfully sent message: $messageGuid")
val newMessage = Message(
guid = messageGuid,
text = outgoingMessage.text,
sender = null,
conversation = it.conversation,
date = outgoingMessage.date
)
_messageDeliveredChannel.emit(
MessageDeliveredEvent(
newMessage,
conversation,
requestGuid
)
)
} else {
Log.e(REPO_LOG, "Error sending message. Enqueuing for retry.")
retryMessageSend(it)
}
} catch (e: java.lang.Exception) {
Log.e(REPO_LOG, "Error sending message: (${e.message}). Enqueuing for retry in 5 sec.")
retryMessageSend(it)
} }
} }
} }

View File

@@ -3,8 +3,12 @@ package net.buzzert.kordophone.backend.server
import android.util.Log import android.util.Log
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import net.buzzert.kordophone.backend.model.Conversation import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.model.Message import net.buzzert.kordophone.backend.model.Message
@@ -15,6 +19,7 @@ import okhttp3.WebSocketListener
import okio.ByteString import okio.ByteString
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import java.lang.reflect.Type import java.lang.reflect.Type
import kotlin.time.Duration
const val UPMON_LOG: String = "UpdateMonitor" const val UPMON_LOG: String = "UpdateMonitor"
@@ -30,6 +35,7 @@ class UpdateMonitor(private val client: APIClient) : WebSocketListener() {
private val gson: Gson = Gson() private val gson: Gson = Gson()
private val updateItemsType: Type = object : TypeToken<ArrayList<UpdateItem>>() {}.type private val updateItemsType: Type = object : TypeToken<ArrayList<UpdateItem>>() {}.type
private var webSocket: WebSocket? = null private var webSocket: WebSocket? = null
private var needsSocketReconnect: Boolean = false
private val _conversationChanged: MutableSharedFlow<Conversation> = MutableSharedFlow() private val _conversationChanged: MutableSharedFlow<Conversation> = MutableSharedFlow()
private val _messageAdded: MutableSharedFlow<Message> = MutableSharedFlow() private val _messageAdded: MutableSharedFlow<Message> = MutableSharedFlow()
@@ -62,6 +68,22 @@ class UpdateMonitor(private val client: APIClient) : WebSocketListener() {
} }
} }
@OptIn(DelicateCoroutinesApi::class)
private fun setNeedsSocketReconnect() {
if (!needsSocketReconnect) {
needsSocketReconnect = true
GlobalScope.launch {
needsSocketReconnect = false
// Delay 5 seconds
delay(5000L)
beginMonitoringUpdates()
}
}
}
// <WebSocketListener> // <WebSocketListener>
override fun onOpen(webSocket: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
@@ -72,11 +94,13 @@ class UpdateMonitor(private val client: APIClient) : WebSocketListener() {
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
super.onClosed(webSocket, code, reason) super.onClosed(webSocket, code, reason)
Log.d(UPMON_LOG, "Update monitor socket closed") Log.d(UPMON_LOG, "Update monitor socket closed")
setNeedsSocketReconnect()
} }
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
super.onFailure(webSocket, t, response) super.onFailure(webSocket, t, response)
Log.d(UPMON_LOG, "Update monitor socket failure: ${t.message} :: Response: ${response?.body()}") Log.d(UPMON_LOG, "Update monitor socket failure: ${t.message} :: Response: ${response?.body()}. Reconnecting in 5 seconds.")
setNeedsSocketReconnect()
} }
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {