diff --git a/app/src/main/java/net/buzzert/kordophonedroid/KordophoneApplication.kt b/app/src/main/java/net/buzzert/kordophonedroid/KordophoneApplication.kt index 2aa00ce..caa2729 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/KordophoneApplication.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/KordophoneApplication.kt @@ -1,16 +1,13 @@ package net.buzzert.kordophonedroid import android.app.Application +import androidx.hilt.navigation.compose.hiltViewModel import dagger.hilt.android.HiltAndroidApp import net.buzzert.kordophonedroid.data.AppContainer -import net.buzzert.kordophonedroid.data.AppContainerImpl @HiltAndroidApp class KordophoneApplication : Application() { - lateinit var container: AppContainer - override fun onCreate() { super.onCreate() - container = AppContainerImpl() } } \ No newline at end of file diff --git a/app/src/main/java/net/buzzert/kordophonedroid/MainActivity.kt b/app/src/main/java/net/buzzert/kordophonedroid/MainActivity.kt index 1a68574..f92b989 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/MainActivity.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/MainActivity.kt @@ -4,7 +4,6 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dagger.hilt.android.AndroidEntryPoint -import net.buzzert.kordophonedroid.data.AppContainerImpl import net.buzzert.kordophonedroid.ui.KordophoneApp @AndroidEntryPoint @@ -12,9 +11,8 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val container = (application as KordophoneApplication).container setContent { - KordophoneApp(appContainer = container) + KordophoneApp() } } } diff --git a/app/src/main/java/net/buzzert/kordophonedroid/data/AppContainerImpl.kt b/app/src/main/java/net/buzzert/kordophonedroid/data/AppContainerImpl.kt index 76760ed..a95e826 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/data/AppContainerImpl.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/data/AppContainerImpl.kt @@ -1,18 +1,15 @@ package net.buzzert.kordophonedroid.data +import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner +import dagger.hilt.android.lifecycle.HiltViewModel import net.buzzert.kordophone.backend.server.ChatRepository +import javax.inject.Inject -interface AppContainer: ViewModelStoreOwner { +@HiltViewModel +class AppContainer @Inject constructor( val repository: ChatRepository -} - -class AppContainerImpl() : AppContainer { - override val repository: ChatRepository - get() = TODO("Not yet implemented") - - override val viewModelStore: ViewModelStore - get() = TODO("Not yet implemented") -} +) : ViewModel() { +} \ No newline at end of file diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/KordophoneApp.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/KordophoneApp.kt index 368ba41..48510e1 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/KordophoneApp.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/KordophoneApp.kt @@ -1,11 +1,20 @@ package net.buzzert.kordophonedroid.ui +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button import androidx.compose.material.Text 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.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable 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.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 fun KordophoneApp( - appContainer: AppContainer, + appContainer: AppContainer = hiltViewModel(), ) { KordophoneTheme { val navController = rememberNavController() + val errorVisible = remember { mutableStateOf(null) } + val error = appContainer.repository.errorEncounteredChannel.collectAsStateWithLifecycle( + initialValue = null + ) + + LaunchedEffect(key1 = error.value) { + errorVisible.value = error.value + } + NavHost( navController = navController, startDestination = Destination.ConversationList.route, @@ -42,5 +74,11 @@ fun KordophoneApp( }) } } + + errorVisible.value?.let { + ErrorDialog(title = it.title, body = it.description) { + errorVisible.value = null + } + } } } \ No newline at end of file diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt b/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt index b1f8e98..c5d7da7 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow 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.Message import net.buzzert.kordophone.backend.model.UpdateItem +import java.lang.Error import java.net.URL import java.util.Queue import java.util.UUID @@ -33,6 +35,16 @@ class ChatRepository( private val apiClient: APIClient, 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 val conversations: List get() = database.fetchConversations() @@ -46,6 +58,10 @@ class ChatRepository( get() = database.conversationChanges .onEach { Log.d(REPO_LOG, "Got database conversations changed") } + // Errors channel + val errorEncounteredChannel: SharedFlow + get() = _errorEncounteredChannel + fun messagesChanged(conversation: Conversation): Flow> = database.messagesChanged(conversation) @@ -72,6 +88,7 @@ class ChatRepository( private val outgoingMessageQueue: ArrayBlockingQueue = ArrayBlockingQueue(16) private var outgoingMessageThread: Thread? = null private val _messageDeliveredChannel = MutableSharedFlow() + private val _errorEncounteredChannel = MutableSharedFlow() private val updateMonitor = UpdateMonitor(apiClient) private var updateWatchJob: Job? = null @@ -126,7 +143,7 @@ class ChatRepository( return database.fetchMessages(conversation) } - suspend fun synchronize() { + suspend fun synchronize() = try { Log.d(REPO_LOG, "Synchronizing conversations") // Sync conversations @@ -139,13 +156,17 @@ class ChatRepository( for (conversation in sortedConversations.take(CONVERSATION_MESSAGE_SYNC_COUNT)) { 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. // But keep in mind that outgoing message GUIDs are fake... val messages = fetchMessages(conversation, limit = 15) database.writeMessages(messages, conversation) + } catch (e: java.lang.Exception) { + _errorEncounteredChannel.emit(Error.ConnectionError(e)) } fun close() { @@ -187,6 +208,11 @@ class ChatRepository( database.writeMessages(listOf(event.message), event.conversation, outgoing = true) } + private suspend fun retryMessageSend(info: OutgoingMessageInfo) { + delay(5000L) + outgoingMessageQueue.add(info) + } + private fun outgoingMessageQueueMain() { Log.d(REPO_LOG, "Outgoing Message Queue Main") while (true) { @@ -198,36 +224,41 @@ class ChatRepository( Log.d(REPO_LOG, "Sending message to $conversation: $outgoingMessage") - val result = apiInterface.sendMessage( - SendMessageRequest( - conversationGUID = conversation.guid, - 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 + try { + val result = apiInterface.sendMessage( + SendMessageRequest( + conversationGUID = conversation.guid, + body = outgoingMessage.text, + transferGUIDs = null, ) ) - } else { - Log.e(REPO_LOG, "Error sending message. Enqueuing for retry.") - outgoingMessageQueue.add(it) + + 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.") + retryMessageSend(it) + } + } catch (e: java.lang.Exception) { + Log.e(REPO_LOG, "Error sending message: (${e.message}). Enqueuing for retry in 5 sec.") + retryMessageSend(it) } } } diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/server/UpdateMonitor.kt b/backend/src/main/java/net/buzzert/kordophone/backend/server/UpdateMonitor.kt index 7ad2519..ac418c5 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/server/UpdateMonitor.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/server/UpdateMonitor.kt @@ -3,8 +3,12 @@ package net.buzzert.kordophone.backend.server import android.util.Log import com.google.gson.Gson 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.MutableSharedFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import net.buzzert.kordophone.backend.model.Conversation import net.buzzert.kordophone.backend.model.Message @@ -15,6 +19,7 @@ import okhttp3.WebSocketListener import okio.ByteString import retrofit2.converter.gson.GsonConverterFactory import java.lang.reflect.Type +import kotlin.time.Duration const val UPMON_LOG: String = "UpdateMonitor" @@ -30,6 +35,7 @@ class UpdateMonitor(private val client: APIClient) : WebSocketListener() { private val gson: Gson = Gson() private val updateItemsType: Type = object : TypeToken>() {}.type private var webSocket: WebSocket? = null + private var needsSocketReconnect: Boolean = false private val _conversationChanged: MutableSharedFlow = MutableSharedFlow() private val _messageAdded: MutableSharedFlow = 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() + } + } + } + // 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) { super.onClosed(webSocket, code, reason) Log.d(UPMON_LOG, "Update monitor socket closed") + setNeedsSocketReconnect() } override fun onFailure(webSocket: WebSocket, t: Throwable, response: 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) {