Skip to content
Snippets Groups Projects
Verified Commit 7e2ea85d authored by Janne Mareike Koschinski's avatar Janne Mareike Koschinski
Browse files

feat: switch storage to room

parent df1219c7
Branches
No related tags found
No related merge requests found
Showing
with 508 additions and 383 deletions
...@@ -25,7 +25,6 @@ plugins { ...@@ -25,7 +25,6 @@ plugins {
android { android {
defaultConfig { defaultConfig {
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
testInstrumentationRunner = "de.justjanne.quasseldroid.util.TestRunner"
} }
buildTypes { buildTypes {
...@@ -72,8 +71,20 @@ dependencies { ...@@ -72,8 +71,20 @@ dependencies {
implementation(libs.androidx.compose.runtime) implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.collection.ktx)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.paging.runtime)
testImplementation(libs.androidx.paging.test)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.room.paging)
implementation(libs.libquassel.client) implementation(libs.libquassel.client)
implementation(libs.libquassel.irc) implementation(libs.libquassel.irc)
......
package de.justjanne.quasseldroid.messages
import de.justjanne.libquassel.protocol.models.Message
data class MessageBuffer(
/**
* Whether the chronologically latest message for a given buffer id is in the buffer.
* If yes, new messages that arrive for this buffer should be appened to the end.
*/
val atEnd: Boolean,
val messages: List<Message>
)
package de.justjanne.quasseldroid.messages
import androidx.collection.LruCache
import de.justjanne.bitflags.none
import de.justjanne.libquassel.client.syncables.ClientBacklogManager
import de.justjanne.libquassel.protocol.models.Message
import de.justjanne.libquassel.protocol.models.flags.MessageFlag
import de.justjanne.libquassel.protocol.models.flags.MessageType
import de.justjanne.libquassel.protocol.models.ids.BufferId
import de.justjanne.libquassel.protocol.models.ids.MsgId
import de.justjanne.libquassel.protocol.util.StateHolder
import de.justjanne.libquassel.protocol.variant.into
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.io.Closeable
class MessageStore(
incoming: Flow<Message>,
private val backlogManager: ClientBacklogManager
) : Closeable, StateHolder<Map<BufferId, MessageBuffer>> {
private val state = MutableStateFlow(mapOf<BufferId, MessageBuffer>())
override fun state() = state.value
override fun flow() = state
private val scope = CoroutineScope(Dispatchers.IO)
private val disposable = incoming.onEach { message ->
val bufferId = message.bufferInfo.bufferId
state.update { messages ->
val buffer = messages[bufferId] ?: MessageBuffer(true, emptyList())
if (buffer.atEnd) {
messages + Pair(bufferId, buffer.copy(messages = buffer.messages + message))
} else {
messages
}
}
}.launchIn(scope)
fun loadAround(bufferId: BufferId, messageId: MsgId, limit: Int) {
scope.launch {
state.update { messages ->
val (before, after) = listOf(
backlogManager.backlog(bufferId, last = messageId, limit = limit)
.mapNotNull { it.into<Message>() },
backlogManager.backlogForward(bufferId, first = messageId, limit = limit - 1)
.mapNotNull { it.into<Message>() },
)
val updated = MessageBuffer(
atEnd = false,
messages = (before + after).distinct().sortedBy { it.messageId }
)
messages + Pair(bufferId, updated)
}
}
}
fun loadBefore(bufferId: BufferId, limit: Int) {
scope.launch {
state.update { messages ->
val buffer = messages[bufferId] ?: MessageBuffer(true, emptyList())
val messageId = buffer.messages.firstOrNull()?.messageId ?: MsgId(-1)
val data = backlogManager.backlog(bufferId, last = messageId, limit = limit)
.mapNotNull { it.into<Message>() }
val updated = buffer.copy(
messages = (buffer.messages + data).distinct().sortedBy { it.messageId }
)
messages + Pair(bufferId, updated)
}
}
}
fun loadAfter(bufferId: BufferId, limit: Int) {
scope.launch {
state.update { messages ->
val buffer = messages[bufferId] ?: MessageBuffer(true, emptyList())
val messageId = buffer.messages.lastOrNull()?.messageId ?: MsgId(-1)
val data = backlogManager.backlogForward(bufferId, first = messageId, limit = limit)
.mapNotNull { it.into<Message>() }
val updated = buffer.copy(
messages = (buffer.messages + data).distinct().sortedBy { it.messageId }
)
messages + Pair(bufferId, updated)
}
}
}
fun clear(bufferId: BufferId) {
scope.launch {
state.update { messages ->
messages - bufferId
}
}
}
override fun close() {
runBlocking {
disposable.cancelAndJoin()
}
}
}
package de.justjanne.quasseldroid.persistence
import androidx.paging.LoadType
import androidx.paging.PagingSource
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import androidx.room.withTransaction
import de.justjanne.bitflags.of
import de.justjanne.bitflags.toBits
import de.justjanne.libquassel.client.syncables.ClientBacklogManager
import de.justjanne.libquassel.protocol.models.BufferInfo
import de.justjanne.libquassel.protocol.models.Message
import de.justjanne.libquassel.protocol.models.flags.MessageFlag
import de.justjanne.libquassel.protocol.models.flags.MessageType
import de.justjanne.libquassel.protocol.models.ids.BufferId
import de.justjanne.libquassel.protocol.models.ids.MsgId
import de.justjanne.libquassel.protocol.models.ids.SignedId64Type
import de.justjanne.libquassel.protocol.models.ids.SignedIdType
import de.justjanne.libquassel.protocol.models.ids.isValid
import de.justjanne.libquassel.protocol.variant.QVariant_
import de.justjanne.libquassel.protocol.variant.into
import org.intellij.lang.annotations.Language
import org.threeten.bp.Instant
class QuasselRemoteMediator<Key : Any>(
private val bufferId: BufferId,
private val database: AppDatabase,
private val backlogManager: ClientBacklogManager,
private val pageSize: Int = 50
) : RemoteMediator<Key, MessageModel>() {
private suspend fun loadAround(bufferId: BufferId, messageId: MsgId): List<Message> =
loadBefore(bufferId, messageId) + loadAfter(bufferId, messageId)
private suspend fun loadBefore(bufferId: BufferId, messageId: MsgId) =
backlogManager.backlog(bufferId, last = messageId, limit = pageSize)
.mapNotNull<QVariant_, Message>(QVariant_::into)
private suspend fun loadAfter(bufferId: BufferId, messageId: MsgId) =
backlogManager.backlogForward(bufferId, first = messageId, limit = pageSize)
.mapNotNull<QVariant_, Message>(QVariant_::into)
override suspend fun load(
loadType: LoadType,
state: PagingState<Key, MessageModel>
): MediatorResult {
val loadKey: MsgId = when (loadType) {
LoadType.REFRESH ->
state.anchorPosition?.let { anchorPosition ->
state.closestItemToPosition(anchorPosition)?.messageId?.let(::MsgId)
} ?: MsgId(-1)
LoadType.PREPEND ->
state.firstItemOrNull()?.messageId?.let(::MsgId)
?: return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND ->
state.lastItemOrNull()?.messageId?.let(::MsgId)
?: return MediatorResult.Success(endOfPaginationReached = true)
}
val newMessages: List<Message> = when (loadType) {
LoadType.REFRESH ->
if (loadKey.isValid()) loadAround(bufferId, loadKey)
else loadBefore(bufferId, loadKey)
LoadType.PREPEND -> loadBefore(bufferId, loadKey)
LoadType.APPEND -> loadAfter(bufferId, loadKey)
}
database.withTransaction {
if (loadType == LoadType.REFRESH) {
database.messageDao().delete(bufferId.id)
}
database.messageDao().insert(newMessages.map(::MessageModel))
}
return MediatorResult.Success(
endOfPaginationReached = newMessages.isEmpty()
)
}
}
@Database(entities = [MessageModel::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
}
object Converters {
@TypeConverter
fun fromInstant(value: Instant): Long = value.toEpochMilli()
@TypeConverter
fun toInstant(value: Long): Instant = Instant.ofEpochMilli(value)
}
@Dao
interface MessageDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg models: MessageModel)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(models: Collection<MessageModel>)
@Query("SELECT * FROM message WHERE bufferId = :bufferId")
fun pagingSource(bufferId: SignedIdType): PagingSource<Int, MessageModel>
@Language("RoomSql")
@Query("DELETE FROM message WHERE bufferId = :bufferId")
suspend fun delete(bufferId: SignedIdType)
@Query("DELETE FROM message")
suspend fun delete()
}
@Entity(tableName = "message")
data class MessageModel(
/**
* Id of the message
*/
@PrimaryKey
val messageId: SignedId64Type,
/**
* Timestamp at which the message was sent
*/
val time: Instant,
/**
* Message type
*/
val type: Int,
/**
* Set flags on the message
*/
val flag: Int,
/**
* Metadata of the buffer the message was received in
*/
@ColumnInfo(index = true)
val bufferId: SignedIdType,
/**
* `nick!ident@host` of the sender
*/
val sender: String,
/**
* Channel role prefixes of the sender
*/
val senderPrefixes: String,
/**
* Realname of the sender
*/
val realName: String,
/**
* Avatar of the sender
*/
val avatarUrl: String,
/**
* Message content
*/
val content: String
) {
constructor(message: Message) : this(
messageId = message.messageId.id,
time = message.time,
type = message.type.toBits().toInt(),
flag = message.flag.toBits().toInt(),
bufferId = message.bufferInfo.bufferId.id,
sender = message.sender,
senderPrefixes = message.senderPrefixes,
realName = message.realName,
avatarUrl = message.avatarUrl,
content = message.content
)
fun toMessage() = Message(
messageId = MsgId(messageId),
time = time,
type = MessageType.of(type.toUInt()),
flag = MessageFlag.of(flag.toUInt()),
bufferInfo = BufferInfo(
bufferId = BufferId(bufferId)
),
sender = sender,
senderPrefixes = senderPrefixes,
realName = realName,
avatarUrl = avatarUrl,
content = content
)
}
package de.justjanne.quasseldroid.service
import de.justjanne.libquassel.client.session.ClientSession
import de.justjanne.quasseldroid.messages.MessageStore
data class ClientSessionWrapper(
val session: ClientSession,
val messages: MessageStore
)
...@@ -5,6 +5,7 @@ import android.content.Context ...@@ -5,6 +5,7 @@ import android.content.Context
import android.content.ServiceConnection import android.content.ServiceConnection
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
import de.justjanne.libquassel.client.session.ClientSession
import de.justjanne.libquassel.protocol.util.StateHolder import de.justjanne.libquassel.protocol.util.StateHolder
import de.justjanne.libquassel.protocol.util.flatMap import de.justjanne.libquassel.protocol.util.flatMap
import de.justjanne.quasseldroid.BuildConfig import de.justjanne.quasseldroid.BuildConfig
...@@ -14,7 +15,7 @@ import de.justjanne.quasseldroid.util.lifecycle.LifecycleStatus ...@@ -14,7 +15,7 @@ import de.justjanne.quasseldroid.util.lifecycle.LifecycleStatus
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
class QuasselBackend : DefaultContextualLifecycleObserver(), ServiceConnection, class QuasselBackend : DefaultContextualLifecycleObserver(), ServiceConnection,
StateHolder<ClientSessionWrapper?> { StateHolder<ClientSession?> {
private var connectionData: ConnectionData? = null private var connectionData: ConnectionData? = null
override fun flow() = state.flatMap() override fun flow() = state.flatMap()
......
package de.justjanne.quasseldroid.service package de.justjanne.quasseldroid.service
import android.os.Binder import android.os.Binder
import de.justjanne.libquassel.client.session.ClientSession
import de.justjanne.libquassel.protocol.util.StateHolder import de.justjanne.libquassel.protocol.util.StateHolder
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
class QuasselBinder( class QuasselBinder(
private val state: StateFlow<ClientSessionWrapper?> private val state: StateFlow<ClientSession?>
) : Binder(), StateHolder<ClientSessionWrapper?> { ) : Binder(), StateHolder<ClientSession?> {
constructor(runner: QuasselRunner) : this(runner.flow()) constructor(runner: QuasselRunner) : this(runner.flow())
override fun flow() = state override fun flow() = state
......
...@@ -8,11 +8,20 @@ import de.justjanne.libquassel.protocol.connection.ProtocolVersion ...@@ -8,11 +8,20 @@ import de.justjanne.libquassel.protocol.connection.ProtocolVersion
import de.justjanne.libquassel.protocol.features.FeatureSet import de.justjanne.libquassel.protocol.features.FeatureSet
import de.justjanne.libquassel.protocol.io.CoroutineChannel import de.justjanne.libquassel.protocol.io.CoroutineChannel
import de.justjanne.libquassel.protocol.util.StateHolder import de.justjanne.libquassel.protocol.util.StateHolder
import de.justjanne.quasseldroid.messages.MessageStore import de.justjanne.quasseldroid.persistence.AppDatabase
import de.justjanne.quasseldroid.persistence.MessageModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.launch
import kotlinx.coroutines.newSingleThreadContext
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import java.io.Closeable import java.io.Closeable
import java.net.InetSocketAddress import java.net.InetSocketAddress
...@@ -20,21 +29,19 @@ import javax.net.ssl.SSLContext ...@@ -20,21 +29,19 @@ import javax.net.ssl.SSLContext
class QuasselRunner( class QuasselRunner(
private val address: InetSocketAddress, private val address: InetSocketAddress,
private val auth: Pair<String, String> private val auth: Pair<String, String>,
) : Thread("Quassel Runner"), Closeable, StateHolder<ClientSessionWrapper?> { private val database: AppDatabase
) : Closeable, StateHolder<ClientSession?>, CoroutineScope {
private val channel = CoroutineChannel() private val channel = CoroutineChannel()
override fun state(): ClientSessionWrapper? = state.value override fun state(): ClientSession? = state.value
override fun flow(): StateFlow<ClientSessionWrapper?> = state override fun flow(): StateFlow<ClientSession?> = state
private val state = MutableStateFlow<ClientSessionWrapper?>(null) private val state = MutableStateFlow<ClientSession?>(null)
init { override val coroutineContext = newSingleThreadContext("Quassel Runner")
start()
}
override fun run() { private val job = launch {
runBlocking(Dispatchers.IO) {
Log.d("QuasselRunner", "Resolving URL") Log.d("QuasselRunner", "Resolving URL")
val address = InetSocketAddress(address.hostString, address.port) val address = InetSocketAddress(address.hostString, address.port)
Log.d("QuasselRunner", "Connecting") Log.d("QuasselRunner", "Connecting")
...@@ -51,24 +58,23 @@ class QuasselRunner( ...@@ -51,24 +58,23 @@ class QuasselRunner(
), ),
SSLContext.getDefault() SSLContext.getDefault()
) )
state.value = ClientSessionWrapper( state.value = session
session,
messages = MessageStore( with(session) {
session.rpcHandler.messages(), handshakeHandler.init(
session.backlogManager
)
)
session.handshakeHandler.init(
"Quasseltest v0.1", "Quasseltest v0.1",
"2022-02-24", "2022-02-24",
FeatureSet.all() FeatureSet.all()
) )
val (username, password) = auth val (username, password) = auth
Log.d("QuasselRunner", "Authenticating") Log.d("QuasselRunner", "Authenticating")
session.handshakeHandler.login(username, password) handshakeHandler.login(username, password)
Log.d("QuasselRunner", "Waiting for init") Log.d("QuasselRunner", "Waiting for init")
session.baseInitHandler.waitForInitDone() baseInitHandler.waitForInitDone()
Log.d("QuasselRunner", "Init Done") Log.d("QuasselRunner", "Init Done")
rpcHandler.messages().collectIndexed { _, message ->
database.messageDao().insert(MessageModel(message))
}
} }
} }
...@@ -76,8 +82,12 @@ class QuasselRunner( ...@@ -76,8 +82,12 @@ class QuasselRunner(
Log.d("QuasselRunner", "Stopping Quassel Runner") Log.d("QuasselRunner", "Stopping Quassel Runner")
runBlocking(Dispatchers.IO) { runBlocking(Dispatchers.IO) {
withTimeout(2000L) { withTimeout(2000L) {
job.cancelAndJoin()
runInterruptible {
coroutineContext.cancel()
channel.close() channel.close()
} }
} }
} }
} }
}
...@@ -5,11 +5,21 @@ import android.content.Context ...@@ -5,11 +5,21 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
import androidx.room.Room
import de.justjanne.quasseldroid.persistence.AppDatabase
import java.net.InetSocketAddress import java.net.InetSocketAddress
class QuasselService : Service() { class QuasselService : Service() {
private var runner: QuasselRunner? = null private var runner: QuasselRunner? = null
private val database: AppDatabase by lazy {
Room.databaseBuilder(
this.applicationContext,
AppDatabase::class.java,
"app"
).build()
}
private fun newRunner(intent: Intent): QuasselRunner { private fun newRunner(intent: Intent): QuasselRunner {
Log.w("QuasselService", "Creating new quassel runner") Log.w("QuasselService", "Creating new quassel runner")
val address = InetSocketAddress.createUnresolved( val address = InetSocketAddress.createUnresolved(
...@@ -26,7 +36,7 @@ class QuasselService : Service() { ...@@ -26,7 +36,7 @@ class QuasselService : Service() {
"Required argument 'password' missing" "Required argument 'password' missing"
}, },
) )
return QuasselRunner(address, auth) return QuasselRunner(address, auth, database)
} }
override fun onCreate() { override fun onCreate() {
......
package de.justjanne.quasseldroid.ui
import org.threeten.bp.format.DateTimeFormatter
import org.threeten.bp.format.FormatStyle
object Constants {
val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
val dateTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
}
package de.justjanne.quasseldroid.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import de.justjanne.quasseldroid.ui.theme.QuasselTheme
import de.justjanne.quasseldroid.util.irc.SenderColorUtil
@Composable
fun buildNick(nick: String, senderPrefixes: String): AnnotatedString {
val senderColor = QuasselTheme.sender.colors[SenderColorUtil.senderColor(nick)]
return buildAnnotatedString {
if (senderPrefixes.isNotEmpty()) {
append(senderPrefixes)
}
pushStyle(SpanStyle(color = senderColor, fontWeight = FontWeight.Bold))
append(nick)
pop()
}
}
...@@ -20,12 +20,9 @@ import de.charlex.compose.HtmlText ...@@ -20,12 +20,9 @@ import de.charlex.compose.HtmlText
import de.justjanne.libquassel.protocol.models.ConnectedClient import de.justjanne.libquassel.protocol.models.ConnectedClient
import de.justjanne.quasseldroid.model.SecurityLevel import de.justjanne.quasseldroid.model.SecurityLevel
import de.justjanne.quasseldroid.sample.SampleConnectedClientProvider import de.justjanne.quasseldroid.sample.SampleConnectedClientProvider
import de.justjanne.quasseldroid.ui.Constants
import de.justjanne.quasseldroid.ui.theme.Typography import de.justjanne.quasseldroid.ui.theme.Typography
import org.threeten.bp.ZoneId import org.threeten.bp.ZoneId
import org.threeten.bp.format.DateTimeFormatter
import org.threeten.bp.format.FormatStyle
private val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
@Preview(name = "Connected Client") @Preview(name = "Connected Client")
@Composable @Composable
...@@ -51,7 +48,7 @@ fun ConnectedClientCard( ...@@ -51,7 +48,7 @@ fun ConnectedClientCard(
Text( Text(
client.connectedSince client.connectedSince
.atZone(ZoneId.systemDefault()) .atZone(ZoneId.systemDefault())
.format(formatter), .format(Constants.dateTimeFormatter),
style = Typography.body2 style = Typography.body2
) )
} }
......
...@@ -14,12 +14,9 @@ import de.charlex.compose.HtmlText ...@@ -14,12 +14,9 @@ import de.charlex.compose.HtmlText
import de.justjanne.libquassel.protocol.models.ConnectedClient import de.justjanne.libquassel.protocol.models.ConnectedClient
import de.justjanne.libquassel.protocol.syncables.state.CoreInfoState import de.justjanne.libquassel.protocol.syncables.state.CoreInfoState
import de.justjanne.quasseldroid.sample.SampleCoreInfoProvider import de.justjanne.quasseldroid.sample.SampleCoreInfoProvider
import de.justjanne.quasseldroid.ui.Constants
import de.justjanne.quasseldroid.ui.theme.Typography import de.justjanne.quasseldroid.ui.theme.Typography
import org.threeten.bp.ZoneId import org.threeten.bp.ZoneId
import org.threeten.bp.format.DateTimeFormatter
import org.threeten.bp.format.FormatStyle
private val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
@Preview(name = "Core Info", showBackground = true) @Preview(name = "Core Info", showBackground = true)
@Composable @Composable
...@@ -36,14 +33,14 @@ fun CoreInfoView( ...@@ -36,14 +33,14 @@ fun CoreInfoView(
Text( Text(
text = coreInfo.versionDate text = coreInfo.versionDate
?.atZone(ZoneId.systemDefault()) ?.atZone(ZoneId.systemDefault())
?.format(formatter) ?.format(Constants.dateTimeFormatter)
?: "Unknown", ?: "Unknown",
style = Typography.body2 style = Typography.body2
) )
Text( Text(
coreInfo.startTime coreInfo.startTime
.atZone(ZoneId.systemDefault()) .atZone(ZoneId.systemDefault())
.format(formatter), .format(Constants.dateTimeFormatter),
style = Typography.body2 style = Typography.body2
) )
} }
......
package de.justjanne.quasseldroid.ui.components package de.justjanne.quasseldroid.ui.components
import androidx.compose.foundation.layout.* 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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.paddingFromBaseline
import androidx.compose.foundation.layout.width
import androidx.compose.material.ContentAlpha import androidx.compose.material.ContentAlpha
import androidx.compose.material.LocalContentAlpha import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
...@@ -9,12 +16,8 @@ import androidx.compose.runtime.Composable ...@@ -9,12 +16,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
...@@ -22,29 +25,10 @@ import androidx.compose.ui.unit.sp ...@@ -22,29 +25,10 @@ import androidx.compose.ui.unit.sp
import de.justjanne.libquassel.irc.HostmaskHelper import de.justjanne.libquassel.irc.HostmaskHelper
import de.justjanne.libquassel.protocol.models.Message import de.justjanne.libquassel.protocol.models.Message
import de.justjanne.quasseldroid.sample.SampleMessageProvider import de.justjanne.quasseldroid.sample.SampleMessageProvider
import de.justjanne.quasseldroid.ui.Constants
import de.justjanne.quasseldroid.ui.icons.AvatarIcon import de.justjanne.quasseldroid.ui.icons.AvatarIcon
import de.justjanne.quasseldroid.ui.theme.QuasselTheme
import de.justjanne.quasseldroid.ui.theme.Typography import de.justjanne.quasseldroid.ui.theme.Typography
import de.justjanne.quasseldroid.util.irc.SenderColorUtil
import org.threeten.bp.ZoneId import org.threeten.bp.ZoneId
import org.threeten.bp.format.DateTimeFormatter
import org.threeten.bp.format.FormatStyle
private val formatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
@Composable
fun buildNick(nick: String, senderPrefixes: String): AnnotatedString {
val senderColor = QuasselTheme.sender.colors[SenderColorUtil.senderColor(nick)]
return buildAnnotatedString {
if (senderPrefixes.isNotEmpty()) {
append(senderPrefixes)
}
pushStyle(SpanStyle(color = senderColor, fontWeight = FontWeight.Bold))
append(nick)
pop()
}
}
@Preview(name = "Message Base", showBackground = true) @Preview(name = "Message Base", showBackground = true)
@Composable @Composable
...@@ -53,7 +37,6 @@ fun MessageBase( ...@@ -53,7 +37,6 @@ fun MessageBase(
message: Message, message: Message,
followUp: Boolean = false, followUp: Boolean = false,
// avatarSize: Dp = 32.dp // avatarSize: Dp = 32.dp
backgroundColor: Color = MaterialTheme.colors.surface,
content: @Composable () -> Unit = { Text(message.content, style = Typography.body2) } content: @Composable () -> Unit = { Text(message.content, style = Typography.body2) }
) { ) {
val avatarSize = 32.dp val avatarSize = 32.dp
...@@ -79,7 +62,6 @@ fun MessageBase( ...@@ -79,7 +62,6 @@ fun MessageBase(
} }
Column(modifier = Modifier.align(Alignment.CenterVertically)) { Column(modifier = Modifier.align(Alignment.CenterVertically)) {
if (!followUp) { if (!followUp) {
Row {
Text( Text(
buildAnnotatedString { buildAnnotatedString {
append(buildNick(nick, message.senderPrefixes)) append(buildNick(nick, message.senderPrefixes))
...@@ -88,12 +70,9 @@ fun MessageBase( ...@@ -88,12 +70,9 @@ fun MessageBase(
append(message.realName) append(message.realName)
pop() pop()
}, },
style = Typography.body2, style = Typography.body2
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
} }
}
Row { Row {
Box(modifier = Modifier.weight(1.0f)) { Box(modifier = Modifier.weight(1.0f)) {
content() content()
...@@ -102,7 +81,7 @@ fun MessageBase( ...@@ -102,7 +81,7 @@ fun MessageBase(
Text( Text(
message.time message.time
.atZone(ZoneId.systemDefault()) .atZone(ZoneId.systemDefault())
.format(formatter), .format(Constants.timeFormatter),
style = Typography.body2, style = Typography.body2,
fontSize = 12.sp, fontSize = 12.sp,
modifier = Modifier.align(Alignment.Bottom) modifier = Modifier.align(Alignment.Bottom)
...@@ -112,56 +91,3 @@ fun MessageBase( ...@@ -112,56 +91,3 @@ fun MessageBase(
} }
} }
} }
@Preview(name = "Message Small", showBackground = true)
@Composable
fun MessageBaseSmall(
@PreviewParameter(SampleMessageProvider::class)
message: Message,
followUp: Boolean = false,
// avatarSize: Dp = 32.dp,
backgroundColor: Color = MaterialTheme.colors.surface,
content: @Composable () -> Unit = {
val nick = HostmaskHelper.nick(message.sender)
Text(buildAnnotatedString {
append("— ")
append(buildNick(nick, message.senderPrefixes))
append(" ")
append(message.content)
}, style = Typography.body2)
}
) {
val avatarSize = 16.dp
val nick = HostmaskHelper.nick(message.sender)
Row(
modifier = Modifier
.padding(2.dp)
.fillMaxWidth()
) {
Spacer(Modifier.width(20.dp))
AvatarIcon(
nick,
modifier = Modifier
.align(Alignment.Top)
.paddingFromBaseline(top = 14.sp),
size = avatarSize
)
Spacer(Modifier.width(4.dp))
Box(modifier = Modifier.weight(1.0f)) {
content()
}
Spacer(Modifier.width(4.dp))
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text(
message.time
.atZone(ZoneId.systemDefault())
.format(formatter),
style = Typography.body2,
fontSize = 12.sp,
modifier = Modifier.align(Alignment.Bottom)
)
}
}
}
package de.justjanne.quasseldroid.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.paddingFromBaseline
import androidx.compose.foundation.layout.width
import androidx.compose.material.ContentAlpha
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.justjanne.libquassel.protocol.models.Message
import de.justjanne.libquassel.irc.HostmaskHelper
import de.justjanne.quasseldroid.sample.SampleMessageProvider
import de.justjanne.quasseldroid.ui.Constants
import de.justjanne.quasseldroid.ui.icons.AvatarIcon
import de.justjanne.quasseldroid.ui.theme.Typography
import org.threeten.bp.ZoneId
@Preview(name = "Message Small", showBackground = true)
@Composable
fun MessageBaseSmall(
@PreviewParameter(SampleMessageProvider::class)
message: Message,
content: @Composable () -> Unit = {
val nick = HostmaskHelper.nick(message.sender)
Text(buildAnnotatedString {
append("— ")
append(buildNick(nick, message.senderPrefixes))
append(" ")
append(message.content)
}, style = Typography.body2)
}
) {
val avatarSize = 16.dp
val nick = HostmaskHelper.nick(message.sender)
Row(
modifier = Modifier
.padding(2.dp)
.fillMaxWidth()
) {
Spacer(Modifier.width(20.dp))
AvatarIcon(
nick,
modifier = Modifier
.align(Alignment.Top)
.paddingFromBaseline(top = 14.sp),
size = avatarSize
)
Spacer(Modifier.width(4.dp))
Box(modifier = Modifier.weight(1.0f)) {
content()
}
Spacer(Modifier.width(4.dp))
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text(
message.time
.atZone(ZoneId.systemDefault())
.format(Constants.timeFormatter),
style = Typography.body2,
fontSize = 12.sp,
modifier = Modifier.align(Alignment.Bottom)
)
}
}
}
...@@ -17,13 +17,10 @@ import androidx.compose.ui.text.font.FontWeight ...@@ -17,13 +17,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
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 de.justjanne.quasseldroid.ui.Constants
import de.justjanne.quasseldroid.ui.theme.QuasselTheme import de.justjanne.quasseldroid.ui.theme.QuasselTheme
import de.justjanne.quasseldroid.ui.theme.Typography import de.justjanne.quasseldroid.ui.theme.Typography
import org.threeten.bp.LocalDate import org.threeten.bp.LocalDate
import org.threeten.bp.format.DateTimeFormatter
import org.threeten.bp.format.FormatStyle
private val formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
@Preview(name = "Day Change", showBackground = true) @Preview(name = "Day Change", showBackground = true)
@Composable @Composable
...@@ -54,7 +51,7 @@ fun MessageDayChangeView( ...@@ -54,7 +51,7 @@ fun MessageDayChangeView(
) )
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text( Text(
date.format(formatter), date.format(Constants.dateFormatter),
modifier = Modifier modifier = Modifier
.align(Alignment.CenterVertically), .align(Alignment.CenterVertically),
style = Typography.body2, style = Typography.body2,
......
package de.justjanne.quasseldroid.ui.components
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import de.justjanne.bitflags.of
import de.justjanne.libquassel.irc.HostmaskHelper
import de.justjanne.libquassel.irc.IrcFormat
import de.justjanne.libquassel.irc.IrcFormatDeserializer
import de.justjanne.libquassel.protocol.models.Message
import de.justjanne.libquassel.protocol.models.flags.MessageType
import de.justjanne.libquassel.protocol.models.ids.MsgId
import de.justjanne.quasseldroid.R
import de.justjanne.quasseldroid.sample.SampleMessagesProvider
import de.justjanne.quasseldroid.ui.theme.QuasselTheme
import de.justjanne.quasseldroid.ui.theme.Typography
import de.justjanne.quasseldroid.util.extensions.OnBottomReached
import de.justjanne.quasseldroid.util.extensions.OnTopReached
import de.justjanne.quasseldroid.util.extensions.format
import de.justjanne.quasseldroid.util.extensions.getPrevious
import de.justjanne.quasseldroid.util.format.IrcFormatRenderer
import de.justjanne.quasseldroid.util.format.TextFormatter
import org.threeten.bp.ZoneId
@Preview(name = "Messages", showBackground = true)
@Composable
fun MessageList(
@PreviewParameter(SampleMessagesProvider::class)
messages: List<Message>,
listState: LazyListState = rememberLazyListState(),
markerLine: MsgId = MsgId(-1),
buffer: Int = 0,
onLoadAtStart: () -> Unit = { },
onLoadAtEnd: () -> Unit = { },
) {
LazyColumn(state = listState) {
itemsIndexed(messages, key = { _, item -> item.messageId }) { index, message ->
val prev = messages.getPrevious(index)
val prevDate = prev?.time?.atZone(ZoneId.systemDefault())?.toLocalDate()
val messageDate = message.time.atZone(ZoneId.systemDefault()).toLocalDate()
val followUp = prev != null &&
message.sender == prev.sender &&
message.senderPrefixes == prev.senderPrefixes &&
message.realName == prev.realName &&
message.avatarUrl == prev.avatarUrl
val isNew = (prev == null || prev.messageId <= markerLine) &&
message.messageId > markerLine
val parsed = IrcFormatDeserializer.parse(message.content)
if (prevDate == null || !messageDate.isEqual(prevDate)) {
MessageDayChangeView(messageDate, isNew)
} else if (isNew) {
NewMessageView()
}
when (message.type) {
MessageType.of(MessageType.Plain) -> {
MessageBase(message, followUp) {
Text(IrcFormatRenderer.render(parsed), style = Typography.body2)
}
}
MessageType.of(MessageType.Action) -> {
MessageBaseSmall(message, backgroundColor = QuasselTheme.chat.action) {
val nick = HostmaskHelper.nick(message.sender)
Text(
TextFormatter.format(
AnnotatedString(stringResource(R.string.message_format_action)),
buildNick(nick, message.senderPrefixes),
IrcFormatRenderer.render(
data = parsed.map { it.copy(style = it.style.flipFlag(IrcFormat.Flag.ITALIC)) },
textColor = QuasselTheme.chat.onAction,
backgroundColor = QuasselTheme.chat.action
)
),
style = Typography.body2,
color = QuasselTheme.chat.onAction
)
}
}
}
}
}
listState.OnTopReached(buffer = buffer, onLoadMore = onLoadAtStart)
listState.OnBottomReached(buffer = buffer, onLoadMore = onLoadAtEnd)
}
package de.justjanne.quasseldroid.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.shape.RoundedCornerShape
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Preview(name = "Message Placeholder", showBackground = true)
@Composable
fun MessagePlaceholder() {
val density = LocalDensity.current
fun TextUnit.toDp() = with(density) { toPx().toDp() }
Row(
modifier = Modifier
.padding(2.dp)
.fillMaxWidth()
) {
Spacer(Modifier.width(4.dp))
Surface(
shape = RoundedCornerShape(2.dp),
color = Color.Gray,
modifier = Modifier
.padding(2.dp)
.size(32.dp)
) {}
Spacer(Modifier.width(4.dp))
Column(
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(vertical = 2.dp)
) {
Row(
modifier = Modifier
.height(12.sp.toDp())
.fillMaxWidth()
) {
Surface(
modifier = Modifier
.width(62.sp.toDp())
.fillMaxHeight(),
color = Color.Gray,
) {}
Spacer(modifier = Modifier.width(7.dp))
Surface(
modifier = Modifier
.width(163.sp.toDp())
.fillMaxHeight(),
color = Color.LightGray,
) {}
}
Spacer(modifier = Modifier.height(4.dp))
Row {
Column {
Surface(
modifier = Modifier
.width(280.sp.toDp())
.height(14.sp.toDp()),
color = Color.Gray,
) {}
Spacer(modifier = Modifier.height(2.dp))
Surface(
modifier = Modifier
.width(160.sp.toDp())
.height(14.sp.toDp()),
color = Color.Gray,
) {}
}
Spacer(modifier = Modifier.weight(1.0f))
Surface(
modifier = Modifier
.size(width = 34.sp.toDp(), height = 12.sp.toDp())
.padding(end = 2.dp)
.align(Alignment.Bottom),
color = Color.LightGray
) {}
}
}
}
}
package de.justjanne.quasseldroid.ui.icons package de.justjanne.quasseldroid.ui.icons
import android.graphics.Bitmap
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
...@@ -29,7 +28,7 @@ fun AvatarIcon( ...@@ -29,7 +28,7 @@ fun AvatarIcon(
@PreviewParameter(SampleNickProvider::class) @PreviewParameter(SampleNickProvider::class)
nick: String, nick: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
avatar: Bitmap? = null, //avatar: Bitmap? = null,
size: Dp = 32.dp size: Dp = 32.dp
) { ) {
val senderColor = QuasselTheme.sender.colors[SenderColorUtil.senderColor(nick)] val senderColor = QuasselTheme.sender.colors[SenderColorUtil.senderColor(nick)]
......
...@@ -18,7 +18,6 @@ import de.justjanne.quasseldroid.util.rememberFlow ...@@ -18,7 +18,6 @@ import de.justjanne.quasseldroid.util.rememberFlow
fun CoreInfoRoute(backend: QuasselBackend, navController: NavController) { fun CoreInfoRoute(backend: QuasselBackend, navController: NavController) {
val coreInfo = rememberFlow(null) { val coreInfo = rememberFlow(null) {
backend.flow() backend.flow()
.mapNullable { it.session }
.flatMap() .flatMap()
.mapNullable { it.coreInfo } .mapNullable { it.coreInfo }
.flatMap() .flatMap()
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment