Private
Public Access
1
0

More pleasant OOTB experience

This commit is contained in:
2024-04-10 23:35:54 -07:00
parent d474ce1c10
commit 6ed8e88bf0
7 changed files with 159 additions and 67 deletions

View File

@@ -2,12 +2,29 @@ package net.buzzert.kordophonedroid.ui.conversationlist
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.* import androidx.compose.material.Divider
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.PullRefreshIndicator
@@ -27,10 +44,11 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.buzzert.kordophone.backend.model.Conversation import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophonedroid.R
import net.buzzert.kordophonedroid.ui.Destination import net.buzzert.kordophonedroid.ui.Destination
import net.buzzert.kordophonedroid.ui.LocalNavController import net.buzzert.kordophonedroid.ui.LocalNavController
import java.time.LocalDate import java.time.LocalDate
@@ -54,8 +72,12 @@ fun ConversationListScreen(
viewModel: ConversationListViewModel = hiltViewModel(), viewModel: ConversationListViewModel = hiltViewModel(),
) { ) {
val conversations by viewModel.conversations.collectAsStateWithLifecycle(initialValue = emptyList()) val conversations by viewModel.conversations.collectAsStateWithLifecycle(initialValue = emptyList())
val encounteredError by viewModel.encounteredConnectionError
ConversationListView( ConversationListView(
conversations = conversations, conversations = conversations,
isConfigured = viewModel.isServerConfigured,
encounteredError = encounteredError,
onRefresh = suspend { viewModel.refresh() } onRefresh = suspend { viewModel.refresh() }
) )
} }
@@ -64,6 +86,8 @@ fun ConversationListScreen(
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
fun ConversationListView( fun ConversationListView(
conversations: List<Conversation>, conversations: List<Conversation>,
isConfigured: Boolean = true,
encounteredError: Boolean = false,
onRefresh: suspend () -> Unit, onRefresh: suspend () -> Unit,
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -77,6 +101,8 @@ fun ConversationListView(
refreshing = false refreshing = false
} }
val showErrorScreen = conversations.isEmpty() && encounteredError
val refreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = ::refresh) val refreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = ::refresh)
val navController = LocalNavController.current val navController = LocalNavController.current
@@ -90,35 +116,49 @@ fun ConversationListView(
}) })
} }
) { ) {
Box(Modifier.pullRefresh(refreshState)) { if (showErrorScreen) {
LazyColumn( NoContentView(
state = listState, icon = R.drawable.error,
modifier = Modifier text = "Connection error",
.padding(it) onSettings = onSettingsInvoked
.fillMaxSize()
) {
items(conversations) { conversation ->
val clickHandler = {
val route = Destination.MessageList.createRoute(conversation.guid)
navController.navigate(route)
}
ConversationListItem(
name = conversation.formattedDisplayName(),
id = conversation.guid,
isUnread = conversation.unreadCount > 0,
lastMessagePreview = conversation.lastMessagePreview ?: "",
date = conversation.date,
onClick = clickHandler
)
}
}
PullRefreshIndicator(
refreshing = refreshing,
state = refreshState,
modifier = Modifier.align(Alignment.TopCenter),
) )
} else if (!isConfigured) {
NoContentView(
icon = R.drawable.storage,
text = "Server not configured",
onSettings = onSettingsInvoked
)
} else {
Box(Modifier.pullRefresh(refreshState)) {
LazyColumn(
state = listState,
modifier = Modifier
.padding(it)
.fillMaxSize()
) {
items(conversations) { conversation ->
val clickHandler = {
val route = Destination.MessageList.createRoute(conversation.guid)
navController.navigate(route)
}
ConversationListItem(
name = conversation.formattedDisplayName(),
id = conversation.guid,
isUnread = conversation.unreadCount > 0,
lastMessagePreview = conversation.lastMessagePreview ?: "",
date = conversation.date,
onClick = clickHandler
)
}
}
PullRefreshIndicator(
refreshing = refreshing,
state = refreshState,
modifier = Modifier.align(Alignment.TopCenter),
)
}
} }
} }
} }

View File

@@ -1,26 +1,22 @@
package net.buzzert.kordophonedroid.ui.conversationlist package net.buzzert.kordophonedroid.ui.conversationlist
import android.util.Log import android.util.Log
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.cache
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.buzzert.kordophone.backend.model.Conversation import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.server.APIClientFactory import net.buzzert.kordophone.backend.server.APIClientFactory
import net.buzzert.kordophone.backend.server.Authentication
import net.buzzert.kordophone.backend.server.ChatRepository import net.buzzert.kordophone.backend.server.ChatRepository
import net.buzzert.kordophone.backend.server.RetrofitAPIClient
import net.buzzert.kordophonedroid.ui.shared.ServerConfigRepository import net.buzzert.kordophonedroid.ui.shared.ServerConfigRepository
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.net.URL
import javax.inject.Inject import javax.inject.Inject
const val CL_VM_LOG: String = "ConversationListViewModel" const val CL_VM_LOG: String = "ConversationListViewModel"
@@ -38,6 +34,14 @@ class ConversationListViewModel @Inject constructor(
.reversed() .reversed()
} }
val isServerConfigured: Boolean
get() = chatRepository.isConfigured
val encounteredConnectionError: State<Boolean>
get() = _encounteredConnectionError
private val _encounteredConnectionError = mutableStateOf(false)
init { init {
// Watch for config changes // Watch for config changes
viewModelScope.launch { viewModelScope.launch {
@@ -56,6 +60,12 @@ class ConversationListViewModel @Inject constructor(
} }
} }
} }
viewModelScope.launch {
chatRepository.errorEncounteredChannel.collect {
_encounteredConnectionError.value = true
}
}
} }
suspend fun refresh() { suspend fun refresh() {

View File

@@ -24,7 +24,7 @@ fun NoContentView(
@DrawableRes icon: Int, @DrawableRes icon: Int,
text: String, text: String,
onSettings: () -> Unit, onSettings: () -> Unit,
modifier: Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
modifier = modifier modifier = modifier

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,680Q497,680 508.5,668.5Q520,657 520,640Q520,623 508.5,611.5Q497,600 480,600Q463,600 451.5,611.5Q440,623 440,640Q440,657 451.5,668.5Q463,680 480,680ZM440,520L520,520L520,280L440,280L440,520ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

View File

@@ -20,6 +20,8 @@ import retrofit2.converter.gson.GsonConverterFactory
import java.net.URL import java.net.URL
interface APIClient { interface APIClient {
val isConfigured: Boolean
fun getAPIInterface(): APIInterface fun getAPIInterface(): APIInterface
fun getWebSocketClient( fun getWebSocketClient(
serverPath: String, serverPath: String,
@@ -99,11 +101,12 @@ class APIClientFactory {
companion object { companion object {
fun createClient(serverString: String?, authentication: Authentication?): APIClient { fun createClient(serverString: String?, authentication: Authentication?): APIClient {
if (serverString == null || authentication == null) { if (serverString == null || authentication == null) {
return InvalidConfigurationAPIClient() return InvalidConfigurationAPIClient(InvalidConfigurationAPIClient.Issue.NOT_CONFIGURED)
} }
// Try to parse server string // Try to parse server string
val serverURL = HttpUrl.parse(serverString) ?: return InvalidConfigurationAPIClient() val serverURL = HttpUrl.parse(serverString)
?: return InvalidConfigurationAPIClient(InvalidConfigurationAPIClient.Issue.INVALID_HOST_URL)
return RetrofitAPIClient(serverURL.url(), authentication) return RetrofitAPIClient(serverURL.url(), authentication)
} }
@@ -111,10 +114,23 @@ class APIClientFactory {
} }
// TODO: Is this a dumb idea? // TODO: Is this a dumb idea?
class InvalidConfigurationAPIClient: APIClient { class InvalidConfigurationAPIClient(val issue: Issue): APIClient {
private class InvalidConfigurationAPIInterface: APIInterface { enum class Issue {
NOT_CONFIGURED,
INVALID_CONFIGURATION,
INVALID_HOST_URL,
}
class NotConfiguredError: Throwable(message = "Not configured.")
class InvalidConfigurationError(submessage: String): Throwable(message = "Invalid configuration: $submessage")
private class InvalidConfigurationAPIInterface(val issue: Issue): APIInterface {
private fun throwError(): Nothing { private fun throwError(): Nothing {
throw Error("Invalid Configuration.") when (issue) {
Issue.NOT_CONFIGURED -> throw NotConfiguredError()
Issue.INVALID_CONFIGURATION -> throw InvalidConfigurationError("Unknown.")
Issue.INVALID_HOST_URL -> throw InvalidConfigurationError("Invalid host URL.")
}
} }
override suspend fun getVersion(): ResponseBody = throwError() override suspend fun getVersion(): ResponseBody = throwError()
@@ -124,16 +140,14 @@ class InvalidConfigurationAPIClient: APIClient {
override suspend fun fetchAttachment(guid: String, preview: Boolean): ResponseBody = throwError() override suspend fun fetchAttachment(guid: String, preview: Boolean): ResponseBody = throwError()
override suspend fun uploadAttachment(filename: String, body: RequestBody): retrofit2.Response<UploadAttachmentResponse> = throwError() override suspend fun uploadAttachment(filename: String, body: RequestBody): retrofit2.Response<UploadAttachmentResponse> = throwError()
override suspend fun authenticate(request: AuthenticationRequest): retrofit2.Response<AuthenticationResponse> = throwError() override suspend fun authenticate(request: AuthenticationRequest): retrofit2.Response<AuthenticationResponse> = throwError()
override suspend fun getMessages( override suspend fun getMessages(conversationGUID: String, limit: Int?, beforeMessageGUID: GUID?, afterMessageGUID: GUID?): retrofit2.Response<List<Message>> = throwError()
conversationGUID: String,
limit: Int?,
beforeMessageGUID: GUID?,
afterMessageGUID: GUID?
): retrofit2.Response<List<Message>> = throwError()
} }
override val isConfigured: Boolean
get() { return issue != Issue.NOT_CONFIGURED }
override fun getAPIInterface(): APIInterface { override fun getAPIInterface(): APIInterface {
return InvalidConfigurationAPIInterface() return InvalidConfigurationAPIInterface(issue)
} }
override fun getWebSocketClient( override fun getWebSocketClient(
@@ -162,6 +176,9 @@ class RetrofitAPIClient(
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.build() .build()
override val isConfigured: Boolean
get() = true
override fun getAPIInterface(): APIInterface { override fun getAPIInterface(): APIInterface {
return retrofit.create(APIInterface::class.java) return retrofit.create(APIInterface::class.java)
} }

View File

@@ -28,16 +28,12 @@ import java.io.InputStream
import java.util.Date import java.util.Date
import java.util.UUID import java.util.UUID
import java.util.concurrent.ArrayBlockingQueue import java.util.concurrent.ArrayBlockingQueue
import kotlin.Boolean
import kotlin.Int
import kotlin.String
import kotlin.let
const val REPO_LOG: String = "ChatRepository" const val REPO_LOG: String = "ChatRepository"
const val CONVERSATION_MESSAGE_SYNC_COUNT = 10 const val CONVERSATION_MESSAGE_SYNC_COUNT = 10
class ChatRepository( class ChatRepository(
apiClient: APIClient, private var apiClient: APIClient,
private val database: CachedChatDatabase, private val database: CachedChatDatabase,
) { ) {
sealed class Error { sealed class Error {
@@ -48,6 +44,11 @@ class ChatRepository(
override val title: String = "Connection Error" override val title: String = "Connection Error"
override val description: String = message ?: "???" override val description: String = message ?: "???"
} }
data class AttachmentError(val message: String): Error() {
override val title: String = "Attachment Error"
override val description: String = message
}
} }
// All (Cached) Conversations // All (Cached) Conversations
@@ -71,6 +72,10 @@ class ChatRepository(
val errorEncounteredChannel: SharedFlow<Error> val errorEncounteredChannel: SharedFlow<Error>
get() = _errorEncounteredChannel.asSharedFlow() get() = _errorEncounteredChannel.asSharedFlow()
val isConfigured: Boolean
get() = apiClient.isConfigured
// New messages for a particular conversation
fun messagesChanged(conversation: Conversation): Flow<List<Message>> = fun messagesChanged(conversation: Conversation): Flow<List<Message>> =
database.messagesChanged(conversation) database.messagesChanged(conversation)
@@ -99,6 +104,7 @@ class ChatRepository(
private var updateWatchScope: CoroutineScope? = null private var updateWatchScope: CoroutineScope? = null
fun updateAPIClient(client: APIClient) { fun updateAPIClient(client: APIClient) {
this.apiClient = client
this.apiInterface = client.getAPIInterface() this.apiInterface = client.getAPIInterface()
this.updateMonitor = UpdateMonitor(client) this.updateMonitor = UpdateMonitor(client)
@@ -160,7 +166,7 @@ class ChatRepository(
return database.fetchMessages(conversation) return database.fetchMessages(conversation)
} }
suspend fun synchronize() = try { suspend fun synchronize() = withErrorChannelHandling {
Log.d(REPO_LOG, "Synchronizing conversations") Log.d(REPO_LOG, "Synchronizing conversations")
// Sync conversations // Sync conversations
@@ -173,26 +179,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.message))
} catch (e: java.lang.Error) {
_errorEncounteredChannel.emit(Error.ConnectionError(e.message))
} }
suspend fun synchronizeConversation(conversation: Conversation, limit: Int = 15) = try { suspend fun synchronizeConversation(conversation: Conversation, limit: Int = 15) = withErrorChannelHandling {
// 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 = limit) val messages = fetchMessages(conversation, limit = limit)
database.writeMessages(messages, conversation) database.writeMessages(messages, conversation)
} catch (e: java.lang.Exception) {
_errorEncounteredChannel.emit(Error.ConnectionError(e.message))
} }
suspend fun markConversationAsRead(conversation: Conversation) = try { suspend fun markConversationAsRead(conversation: Conversation) = withErrorChannelHandling(silent = true) {
apiInterface.markConversation(conversation.guid) apiInterface.markConversation(conversation.guid)
} catch (e: java.lang.Exception) {
// Don't report via the channel, but log it.
Log.e(REPO_LOG, "Error marking conversation as read: ${e.message}")
} }
suspend fun fetchAttachmentDataSource(guid: String, preview: Boolean): BufferedSource { suspend fun fetchAttachmentDataSource(guid: String, preview: Boolean): BufferedSource {
@@ -217,6 +214,18 @@ class ChatRepository(
// - private // - private
private suspend fun withErrorChannelHandling(silent: Boolean = false, body: suspend () -> Unit) {
try {
body()
} catch (e: InvalidConfigurationAPIClient.NotConfiguredError) {
// Not configured yet: ignore.
} catch (e: java.lang.Exception) {
if (!silent) _errorEncounteredChannel.emit(Error.ConnectionError(e.message))
} catch (e: java.lang.Error) {
if (!silent) _errorEncounteredChannel.emit(Error.ConnectionError(e.message))
}
}
private suspend fun fetchConversations(): List<Conversation> { private suspend fun fetchConversations(): List<Conversation> {
return apiInterface.getConversations().bodyOnSuccessOrThrow() return apiInterface.getConversations().bodyOnSuccessOrThrow()
} }
@@ -279,6 +288,7 @@ class ChatRepository(
} }
} catch (e: java.lang.Exception) { } catch (e: java.lang.Exception) {
Log.e(REPO_LOG, "Error uploading attachment (${e.message}). Dropping...") Log.e(REPO_LOG, "Error uploading attachment (${e.message}). Dropping...")
_errorEncounteredChannel.emit(Error.AttachmentError("Upload error: ${e.message}"))
} }
try { try {

View File

@@ -40,6 +40,11 @@ class UpdateMonitor(private val client: APIClient) : WebSocketListener() {
private val _messageAdded: MutableSharedFlow<Message> = MutableSharedFlow() private val _messageAdded: MutableSharedFlow<Message> = MutableSharedFlow()
fun beginMonitoringUpdates() { fun beginMonitoringUpdates() {
if (!client.isConfigured) {
Log.e(UPMON_LOG, "Closing websocket connection because client is not configured.")
return
}
Log.d(UPMON_LOG, "Opening websocket connection") Log.d(UPMON_LOG, "Opening websocket connection")
try { try {
this.webSocket = client.getWebSocketClient( this.webSocket = client.getWebSocketClient(