Backend: UI: Implements authentication.
This commit is contained in:
@@ -5,8 +5,10 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import net.buzzert.kordophone.backend.db.CachedChatDatabase
|
||||
import net.buzzert.kordophone.backend.server.Authentication
|
||||
import net.buzzert.kordophone.backend.server.ChatRepository
|
||||
import net.buzzert.kordophone.backend.server.RetrofitAPIClient
|
||||
import net.buzzert.kordophonedroid.ui.shared.ServerConfigRepository
|
||||
import java.net.URL
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -15,9 +17,21 @@ import javax.inject.Singleton
|
||||
object AppModule {
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideChatRepository(): ChatRepository {
|
||||
val host = "http://192.168.1.123:5738"
|
||||
val client = RetrofitAPIClient(URL(host))
|
||||
fun provideChatRepository(configRepository: ServerConfigRepository): ChatRepository {
|
||||
val serverConfig = configRepository.serverConfig.value
|
||||
val authentication = serverConfig.authentication?.let {
|
||||
Authentication(it.username, it.password)
|
||||
}
|
||||
|
||||
// TODO: This is really bad error handling...
|
||||
val baseURL: URL = try { URL(serverConfig.serverName) }
|
||||
catch (e: java.net.MalformedURLException) { URL("http://localhost") }
|
||||
|
||||
val client = RetrofitAPIClient(
|
||||
baseURL = baseURL,
|
||||
authentication = authentication ?: Authentication("", "")
|
||||
)
|
||||
|
||||
val database = CachedChatDatabase.liveDatabase()
|
||||
return ChatRepository(client, database)
|
||||
}
|
||||
|
||||
@@ -128,7 +128,9 @@ fun ConversationListItem(
|
||||
) {
|
||||
Text(
|
||||
name,
|
||||
style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold)
|
||||
style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
@@ -140,7 +142,8 @@ fun ConversationListItem(
|
||||
.toLocalDateTime()
|
||||
),
|
||||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
color = MaterialTheme.colors.onBackground.copy(alpha = 0.4f)
|
||||
color = MaterialTheme.colors.onBackground.copy(alpha = 0.4f),
|
||||
maxLines = 1,
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(horizontalPadding))
|
||||
@@ -172,7 +175,7 @@ fun UnreadIndicator(size: Dp, modifier: Modifier = Modifier) {
|
||||
@Composable
|
||||
fun ConversationListItemPreview() {
|
||||
Column(modifier = Modifier.background(MaterialTheme.colors.background)) {
|
||||
ConversationListItem(name = "James Magahern", id = "asdf", lastMessagePreview = "This is a test", date = Date(), isUnread = true) {}
|
||||
ConversationListItem(name = "James MagahernMagahernMagahernMagahernMagahernMagahernMagahern", id = "asdf", lastMessagePreview = "This is a test", date = Date(), isUnread = true) {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,12 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.buzzert.kordophone.backend.model.Conversation
|
||||
import net.buzzert.kordophone.backend.server.Authentication
|
||||
import net.buzzert.kordophone.backend.server.ChatRepository
|
||||
import net.buzzert.kordophone.backend.server.REPO_LOG
|
||||
import net.buzzert.kordophone.backend.server.RetrofitAPIClient
|
||||
import net.buzzert.kordophonedroid.ui.shared.ServerConfigRepository
|
||||
import java.net.URL
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
@@ -33,23 +36,31 @@ class ConversationListViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
init {
|
||||
// TODO: Is this the best place to put these?
|
||||
// TODO: Need error handling (exceptions thrown below)
|
||||
|
||||
// Perform initial sync
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
chatRepository.synchronize()
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for config changes
|
||||
viewModelScope.launch {
|
||||
serverConfigRepository.serverConfig.collect {
|
||||
serverConfigRepository.serverConfig.collect { config ->
|
||||
Log.d(CL_VM_LOG, "Got settings change.")
|
||||
|
||||
// TODO: Respond to this change.
|
||||
// Should probably just forward this directly to ChatRepository.
|
||||
// Check settings
|
||||
try {
|
||||
val baseURL = URL(config.serverName)
|
||||
val authentication = config.authentication?.let { serverAuth ->
|
||||
Authentication(serverAuth.username, serverAuth.password)
|
||||
} ?: throw Error("No authentication data.")
|
||||
|
||||
// Make new APIClient
|
||||
val apiClient = RetrofitAPIClient(baseURL, authentication)
|
||||
chatRepository.updateAPIClient(apiClient)
|
||||
} catch (e: Error) {
|
||||
Log.e(CL_VM_LOG, "Error re-creating API client for settings change: $e")
|
||||
} catch (e: java.net.MalformedURLException) {
|
||||
Log.e(CL_VM_LOG, "Malformed server URL")
|
||||
}
|
||||
|
||||
// Perform db synchronization
|
||||
withContext(Dispatchers.IO) {
|
||||
chatRepository.synchronize()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ class MessageListViewModel @Inject constructor(
|
||||
private val pendingMessages: MutableStateFlow<List<OutgoingMessage>> = MutableStateFlow(listOf())
|
||||
|
||||
init {
|
||||
// TODO: Need to handle settings changes here!!
|
||||
|
||||
viewModelScope.launch {
|
||||
// Remove pending message after message is delivered.
|
||||
// By now, the repository should've committed this to the store.
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.lightColors
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
private val DarkColorPalette = darkColors(
|
||||
primary = Purple200,
|
||||
@@ -56,7 +57,13 @@ fun KordophoneTheme(
|
||||
@Composable
|
||||
fun KordophoneTopAppBar(title: String, backAction: () -> Unit) {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
title = {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = backAction) {
|
||||
Icon(Icons.Filled.ArrowBack, null)
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">192.168.1.123</domain>
|
||||
<domain includeSubdomains="true">tesseract.localdomain</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -46,6 +46,7 @@ dependencies {
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
implementation 'com.google.code.gson:gson:2.9.0'
|
||||
implementation 'com.auth0.android:jwtdecode:2.0.2'
|
||||
|
||||
// Realm
|
||||
implementation "io.realm.kotlin:library-base:${realm_version}"
|
||||
|
||||
@@ -62,7 +62,7 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
|
||||
|
||||
// Flow for watching for message changes for a given conversation
|
||||
fun messagesChanged(conversation: ModelConversation): Flow<List<ModelMessage>> {
|
||||
return realm.query(Conversation::class, "guid == '${conversation.guid}'")
|
||||
return realm.query(Conversation::class, "guid == $0", conversation.guid)
|
||||
.find()
|
||||
.first()
|
||||
.messages
|
||||
@@ -142,7 +142,7 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
|
||||
}
|
||||
|
||||
private fun getManagedConversationByGuid(guid: GUID): Conversation {
|
||||
return realm.query(Conversation::class, "guid == '$guid'")
|
||||
return realm.query(Conversation::class, "guid == $0", guid)
|
||||
.find()
|
||||
.first()
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
package net.buzzert.kordophone.backend.server
|
||||
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import retrofit2.create
|
||||
import java.net.URL
|
||||
|
||||
interface APIClient {
|
||||
@@ -18,12 +25,86 @@ interface APIClient {
|
||||
): WebSocket
|
||||
}
|
||||
|
||||
class RetrofitAPIClient(private val baseURL: URL): APIClient {
|
||||
data class Authentication (
|
||||
val username: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
class TokenStore(val authentication: Authentication) {
|
||||
var authenticationToken: String? = null
|
||||
}
|
||||
|
||||
class AuthenticationInterceptor(
|
||||
val tokenStore: TokenStore
|
||||
): Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
// If empty, allow the 401 to occur so we renew our token.
|
||||
val token = tokenStore.authenticationToken ?:
|
||||
return chain.proceed(chain.request())
|
||||
|
||||
val newRequest = chain.request().newBuilder()
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
|
||||
return chain.proceed(newRequest)
|
||||
}
|
||||
}
|
||||
|
||||
class TokenAuthenticator(
|
||||
private val tokenStore: TokenStore,
|
||||
private val baseURL: URL
|
||||
) : Authenticator {
|
||||
private val retrofit: Retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseURL)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
|
||||
private val apiInterface: APIInterface
|
||||
get() = retrofit.create(APIInterface::class.java)
|
||||
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
// Fetch new token
|
||||
val request = AuthenticationRequest(
|
||||
username = tokenStore.authentication.username,
|
||||
password = tokenStore.authentication.password
|
||||
)
|
||||
|
||||
val token = runBlocking {
|
||||
apiInterface.authenticate(request).body()
|
||||
}
|
||||
|
||||
if (token == null) {
|
||||
// Auth failure.
|
||||
// TODO: How to bubble this up?
|
||||
return null
|
||||
}
|
||||
|
||||
// Update token store
|
||||
tokenStore.authenticationToken = token.serializedToken
|
||||
|
||||
return response.request().newBuilder()
|
||||
.header("Authorization", "Bearer ${token.serializedToken}")
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
class RetrofitAPIClient(
|
||||
private val baseURL: URL,
|
||||
private val authentication: Authentication,
|
||||
): APIClient {
|
||||
private val tokenStore: TokenStore = TokenStore(authentication)
|
||||
|
||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor(AuthenticationInterceptor(tokenStore))
|
||||
.authenticator(TokenAuthenticator(tokenStore, baseURL))
|
||||
.build()
|
||||
|
||||
private val retrofit: Retrofit = Retrofit.Builder()
|
||||
.client(client)
|
||||
.baseUrl(baseURL)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
|
||||
override fun getAPIInterface(): APIInterface {
|
||||
return retrofit.create(APIInterface::class.java)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package net.buzzert.kordophone.backend.server
|
||||
|
||||
import com.auth0.android.jwt.JWT
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import net.buzzert.kordophone.backend.model.Conversation
|
||||
import net.buzzert.kordophone.backend.model.GUID
|
||||
@@ -30,6 +31,23 @@ data class SendMessageResponse(
|
||||
val sentMessageGUID: String,
|
||||
)
|
||||
|
||||
data class AuthenticationRequest(
|
||||
@SerializedName("username")
|
||||
val username: String,
|
||||
|
||||
@SerializedName("password")
|
||||
val password: String,
|
||||
)
|
||||
|
||||
data class AuthenticationResponse(
|
||||
@SerializedName("jwt")
|
||||
val serializedToken: String,
|
||||
) {
|
||||
fun decodeToken(): JWT {
|
||||
return JWT(serializedToken)
|
||||
}
|
||||
}
|
||||
|
||||
interface APIInterface {
|
||||
@GET("/version")
|
||||
suspend fun getVersion(): ResponseBody
|
||||
@@ -50,6 +68,9 @@ interface APIInterface {
|
||||
|
||||
@POST("/markConversation")
|
||||
suspend fun markConversation(@Query("guid") conversationGUID: String): Response<Void>
|
||||
|
||||
@POST("/authenticate")
|
||||
suspend fun authenticate(@Body request: AuthenticationRequest): Response<AuthenticationResponse>
|
||||
}
|
||||
|
||||
class ResponseDecodeError(val response: ResponseBody): Exception(response.string())
|
||||
|
||||
@@ -32,7 +32,7 @@ const val REPO_LOG: String = "ChatRepository"
|
||||
const val CONVERSATION_MESSAGE_SYNC_COUNT = 10
|
||||
|
||||
class ChatRepository(
|
||||
private val apiClient: APIClient,
|
||||
apiClient: APIClient,
|
||||
private val database: CachedChatDatabase,
|
||||
) {
|
||||
sealed class Error {
|
||||
@@ -84,14 +84,26 @@ class ChatRepository(
|
||||
val requestGuid: GUID,
|
||||
)
|
||||
|
||||
private val apiInterface = apiClient.getAPIInterface()
|
||||
private var apiInterface = apiClient.getAPIInterface()
|
||||
private val outgoingMessageQueue: ArrayBlockingQueue<OutgoingMessageInfo> = ArrayBlockingQueue(16)
|
||||
private var outgoingMessageThread: Thread? = null
|
||||
private val _messageDeliveredChannel = MutableSharedFlow<MessageDeliveredEvent>()
|
||||
private val _errorEncounteredChannel = MutableSharedFlow<Error>()
|
||||
|
||||
private val updateMonitor = UpdateMonitor(apiClient)
|
||||
private var updateMonitor = UpdateMonitor(apiClient)
|
||||
private var updateWatchJob: Job? = null
|
||||
private var updateWatchScope: CoroutineScope? = null
|
||||
|
||||
fun updateAPIClient(client: APIClient) {
|
||||
this.apiInterface = client.getAPIInterface()
|
||||
this.updateMonitor = UpdateMonitor(client)
|
||||
|
||||
// Restart update watch job, if necessary.
|
||||
if (this.updateWatchJob != null) {
|
||||
stopWatchingForUpdates()
|
||||
beginWatchingForUpdates(updateWatchScope!!)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getVersion(): String {
|
||||
return apiInterface.getVersion().string()
|
||||
@@ -111,6 +123,7 @@ class ChatRepository(
|
||||
}
|
||||
}
|
||||
|
||||
updateWatchScope = scope
|
||||
updateMonitor.beginMonitoringUpdates()
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import net.buzzert.kordophone.backend.model.Message
|
||||
import net.buzzert.kordophone.backend.model.UpdateItem
|
||||
import net.buzzert.kordophone.backend.server.APIClient
|
||||
import net.buzzert.kordophone.backend.server.APIInterface
|
||||
import net.buzzert.kordophone.backend.server.AuthenticationRequest
|
||||
import net.buzzert.kordophone.backend.server.AuthenticationResponse
|
||||
import net.buzzert.kordophone.backend.server.SendMessageRequest
|
||||
import net.buzzert.kordophone.backend.server.SendMessageResponse
|
||||
import net.buzzert.kordophone.backend.server.authenticatedWebSocketURL
|
||||
@@ -257,4 +259,15 @@ class MockServerInterface(private val server: MockServer): APIInterface {
|
||||
server.markConversationAsRead(conversationGUID)
|
||||
return Response.success(null)
|
||||
}
|
||||
|
||||
override suspend fun authenticate(request: AuthenticationRequest): Response<AuthenticationResponse> {
|
||||
// Anything goes!
|
||||
val response = AuthenticationResponse(
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
|
||||
"eyJ1c2VybmFtZSI6InRlc3QiLCJleHAiOjE3MDk3OTQ5NjB9." +
|
||||
"82UcI1gB4eARmgrKwAY6JnbEdWLXou1GWp29scnUhi8"
|
||||
)
|
||||
|
||||
return Response.success(response)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user