Better error handling
This commit is contained in:
@@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user