From 98b0cd71361e4c93dd36efe373c6c49b6b803b3f Mon Sep 17 00:00:00 2001
From: Janne Mareike Koschinski <janne@kuschku.de>
Date: Mon, 3 Apr 2023 21:56:46 +0200
Subject: [PATCH] deps: update dependencies

---
 app/build.gradle.kts                          |  32 +++++-
 app/src/debug/res/values/colors.xml           |  27 +++++
 app/src/main/AndroidManifest.xml              |   1 +
 .../de/justjanne/quasseldroid/MainActivity.kt |   5 +
 .../quasseldroid/messages/MessageBuffer.kt    |  12 ++
 .../quasseldroid/messages/MessageStore.kt     | 104 ++++++++++++++++++
 .../service/ClientSessionWrapper.kt           |   9 ++
 .../quasseldroid/service/QuasselBackend.kt    |   4 +-
 .../ui/components/ConnectedClientCard.kt      |   4 +-
 .../ui/components/CoreInfoView.kt             |   4 +-
 .../quasseldroid/ui/components/MessageList.kt |  97 ++++++++++++++++
 .../ui/components/PasswordTextField.kt        |   4 +
 .../quasseldroid/util/FlowExtensions.kt       |   4 +-
 app/src/main/res/values/strings.xml           |   4 +
 build.gradle.kts                              |  14 +++
 gradle.properties                             |   1 -
 gradle/convention/build.gradle.kts            |  29 ++++-
 gradle/convention/gradle.properties           |   3 +
 .../gradle/wrapper/gradle-wrapper.properties  |   4 +-
 gradle/convention/settings.gradle.kts         |  14 +++
 .../kotlin/AndroidApplicationConvention.kt    |  71 ++++++++++++
 .../main/kotlin/AndroidLibraryConvention.kt   |  35 ++++++
 .../main/kotlin/KotlinAndroidConvention.kt    |  49 +++++++++
 .../src/main/kotlin/KotlinConvention.kt       |  45 ++++++++
 ...roid.signing.gradle.kts => SigningData.kt} |  30 -----
 .../kotlin/justjanne.android.app.gradle.kts   |  95 ----------------
 .../justjanne.android.library.gradle.kts      |  24 ----
 .../src/main/kotlin/justjanne.java.gradle.kts |  23 ----
 .../justjanne.kotlin.android.gradle.kts       |  47 --------
 .../main/kotlin/justjanne.kotlin.gradle.kts   |  29 -----
 .../kotlin/justjanne.repositories.gradle.kts  |   5 -
 .../kotlin/util/BaseExtensionExtensions.kt    |   8 ++
 .../src/main/kotlin/util/ProjectExtensions.kt |  79 +++++++++++++
 gradle/init.gradle.kts                        |  55 +++++++++
 gradle/libs.versions.toml                     |  90 +++++++++++----
 gradle/spotless/copyright.kt                  |  18 +++
 gradle/spotless/copyright.kts                 |  18 +++
 gradle/spotless/copyright.xml                 |  19 ++++
 gradle/wrapper/gradle-wrapper.properties      |   4 +-
 settings.gradle.kts                           |  17 ++-
 40 files changed, 835 insertions(+), 302 deletions(-)
 create mode 100644 app/src/debug/res/values/colors.xml
 create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageBuffer.kt
 create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageStore.kt
 create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/service/ClientSessionWrapper.kt
 create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageList.kt
 create mode 100644 app/src/main/res/values/strings.xml
 create mode 100644 gradle/convention/gradle.properties
 create mode 100644 gradle/convention/src/main/kotlin/AndroidApplicationConvention.kt
 create mode 100644 gradle/convention/src/main/kotlin/AndroidLibraryConvention.kt
 create mode 100644 gradle/convention/src/main/kotlin/KotlinAndroidConvention.kt
 create mode 100644 gradle/convention/src/main/kotlin/KotlinConvention.kt
 rename gradle/convention/src/main/kotlin/{justjanne.android.signing.gradle.kts => SigningData.kt} (51%)
 delete mode 100644 gradle/convention/src/main/kotlin/justjanne.android.app.gradle.kts
 delete mode 100644 gradle/convention/src/main/kotlin/justjanne.android.library.gradle.kts
 delete mode 100644 gradle/convention/src/main/kotlin/justjanne.java.gradle.kts
 delete mode 100644 gradle/convention/src/main/kotlin/justjanne.kotlin.android.gradle.kts
 delete mode 100644 gradle/convention/src/main/kotlin/justjanne.kotlin.gradle.kts
 delete mode 100644 gradle/convention/src/main/kotlin/justjanne.repositories.gradle.kts
 create mode 100644 gradle/convention/src/main/kotlin/util/BaseExtensionExtensions.kt
 create mode 100644 gradle/convention/src/main/kotlin/util/ProjectExtensions.kt
 create mode 100644 gradle/init.gradle.kts
 create mode 100644 gradle/spotless/copyright.kt
 create mode 100644 gradle/spotless/copyright.kts
 create mode 100644 gradle/spotless/copyright.xml

diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f58f94ada..1ad4f5d11 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,3 +1,5 @@
+@file:Suppress("UnstableApiUsage")
+
 /*
  * Quasseldroid - Quassel client for Android
  *
@@ -17,14 +19,26 @@
  * with this program. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import util.buildConfigField
+import util.cmd
+
 plugins {
-  id("justjanne.android.signing")
   id("justjanne.android.app")
 }
 
+fun Project.fancyVersionName(): String? {
+  val name = cmd("git", "describe", "--always", "--tags", "HEAD") ?: return null
+  val commit = cmd("git", "rev-parse", "HEAD") ?: return name
+
+  return """<a href="https://git.kuschku.de/justJanne/QuasselDroid-ng/commit/$commit">$name</a>"""
+}
+
 android {
   defaultConfig {
+    buildConfigField("FANCY_VERSION_NAME", fancyVersionName())
+
     vectorDrawables.useSupportLibrary = true
+    testInstrumentationRunner = "de.justjanne.quasseldroid.util.TestRunner"
   }
 
   buildTypes {
@@ -52,11 +66,21 @@ android {
   }
 
   composeOptions {
-    kotlinCompilerExtensionVersion = libs.versions.androidx.compose.get()
+    kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get()
   }
 }
 
 dependencies {
+  implementation(libs.kotlin.stdlib)
+
+  implementation(libs.kotlinx.coroutines.android)
+  testImplementation(libs.kotlinx.coroutines.test)
+
+  testImplementation(libs.kotlin.test)
+  testImplementation(libs.junit.api)
+  testImplementation(libs.junit.params)
+  testRuntimeOnly(libs.junit.engine)
+
   implementation(libs.androidx.appcompat)
   implementation(libs.androidx.appcompat.resources)
 
@@ -69,7 +93,7 @@ dependencies {
   implementation(libs.androidx.compose.material)
   implementation(libs.androidx.compose.material.icons)
   implementation(libs.androidx.compose.runtime)
-  implementation(libs.androidx.compose.ui)
+  implementation(libs.androidx.compose.ui.tooling)
 
   implementation(libs.androidx.collection.ktx)
   implementation(libs.androidx.core.ktx)
@@ -88,7 +112,7 @@ dependencies {
   implementation(libs.libquassel.client)
   implementation(libs.libquassel.irc)
 
-  implementation(libs.compose.htmltext)
+  //implementation(libs.compose.htmltext)
 
   debugImplementation(libs.androidx.compose.ui.tooling)
   implementation(libs.androidx.compose.ui.preview)
diff --git a/app/src/debug/res/values/colors.xml b/app/src/debug/res/values/colors.xml
new file mode 100644
index 000000000..90c54e64f
--- /dev/null
+++ b/app/src/debug/res/values/colors.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  Quasseldroid - Quassel client for Android
+
+  Copyright (c) 2020 Janne Mareike Koschinski
+  Copyright (c) 2020 The Quassel Project
+
+  This program is free software: you can redistribute it and/or modify it
+  under the terms of the GNU General Public License version 3 as published
+  by the Free Software Foundation.
+
+  This program is distributed in the hope that it will be useful,
+  but WITHOUT ANY WARRANTY; without even the implied warranty of
+  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  GNU General Public License for more details.
+
+  You should have received a copy of the GNU General Public License along
+  with this program. If not, see <http://www.gnu.org/licenses/>.
+  -->
+
+<resources>
+  <color name="colorPrimary">#c0700a</color>
+  <color name="colorPrimaryDark">#945a10</color>
+  <color name="colorAccent">#ffaf3b</color>
+
+  <color name="colorIconLight">#e79102</color>
+  <color name="colorIconDark">#994e12</color>
+</resources>
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index fed896206..b7d1d488d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,6 +11,7 @@
     android:icon="@mipmap/ic_launcher"
     android:supportsRtl="true">
     <activity
+      android:label="@string/app_name"
       android:name=".MainActivity"
       android:exported="true"
       android:launchMode="singleTask"
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/MainActivity.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/MainActivity.kt
index e3fc55316..4112677fc 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/MainActivity.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/MainActivity.kt
@@ -34,6 +34,11 @@ class MainActivity : ComponentActivity() {
     backend.onResume(this)
   }
 
+  override fun onPause() {
+    super.onPause()
+    backend.onPause(this)
+  }
+
   override fun onStop() {
     super.onStop()
     backend.onStop(this)
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageBuffer.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageBuffer.kt
new file mode 100644
index 000000000..6df763cce
--- /dev/null
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageBuffer.kt
@@ -0,0 +1,12 @@
+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>
+)
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageStore.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageStore.kt
new file mode 100644
index 000000000..1ec026106
--- /dev/null
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageStore.kt
@@ -0,0 +1,104 @@
+package de.justjanne.quasseldroid.messages
+
+import de.justjanne.libquassel.client.syncables.ClientBacklogManager
+import de.justjanne.libquassel.protocol.models.Message
+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.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()
+    }
+  }
+}
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/service/ClientSessionWrapper.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/service/ClientSessionWrapper.kt
new file mode 100644
index 000000000..428e82c52
--- /dev/null
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/service/ClientSessionWrapper.kt
@@ -0,0 +1,9 @@
+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
+)
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBackend.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBackend.kt
index 5d8ebb5b0..870249176 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBackend.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBackend.kt
@@ -41,7 +41,9 @@ class QuasselBackend : DefaultContextualLifecycleObserver(), ServiceConnection,
   override fun onStop(owner: Context) {
     super.onStop(owner)
     Log.d("QuasselBackend", "Unbinding Quassel Service")
-    owner.unbindService(this)
+    if (state.value != null) {
+      owner.unbindService(this)
+    }
   }
 
   fun login(context: Context, connectionData: ConnectionData): Boolean {
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/ConnectedClientCard.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/ConnectedClientCard.kt
index ac44730d0..2f90b1afb 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/ConnectedClientCard.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/ConnectedClientCard.kt
@@ -16,7 +16,7 @@ import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.tooling.preview.PreviewParameter
 import androidx.compose.ui.unit.dp
-import de.charlex.compose.HtmlText
+//import de.charlex.compose.HtmlText
 import de.justjanne.libquassel.protocol.models.ConnectedClient
 import de.justjanne.quasseldroid.model.SecurityLevel
 import de.justjanne.quasseldroid.sample.SampleConnectedClientProvider
@@ -34,7 +34,7 @@ fun ConnectedClientCard(
   Card(modifier = modifier) {
     Row(modifier = Modifier.padding(16.dp)) {
       Column(modifier = Modifier.weight(1.0f)) {
-        HtmlText(
+        Text(
           text = client.version,
           style = Typography.body1,
           overflow = TextOverflow.Ellipsis
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/CoreInfoView.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/CoreInfoView.kt
index f0c494704..67bfe0336 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/CoreInfoView.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/CoreInfoView.kt
@@ -10,7 +10,7 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.tooling.preview.PreviewParameter
 import androidx.compose.ui.unit.dp
-import de.charlex.compose.HtmlText
+//import de.charlex.compose.HtmlText
 import de.justjanne.libquassel.protocol.models.ConnectedClient
 import de.justjanne.libquassel.protocol.syncables.state.CoreInfoState
 import de.justjanne.quasseldroid.sample.SampleCoreInfoProvider
@@ -26,7 +26,7 @@ fun CoreInfoView(
 ) {
   Column(modifier = Modifier.padding(8.dp)) {
     Column(modifier = Modifier.padding(8.dp)) {
-      HtmlText(
+      Text(
         text = coreInfo.version,
         style = Typography.body1
       )
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageList.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageList.kt
new file mode 100644
index 000000000..c5426e5ef
--- /dev/null
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageList.kt
@@ -0,0 +1,97 @@
+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)
+}
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/PasswordTextField.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/PasswordTextField.kt
index 3e4d10bec..a16592f4d 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/PasswordTextField.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/PasswordTextField.kt
@@ -54,6 +54,7 @@ fun PasswordTextField(
   keyboardActions: KeyboardActions = KeyboardActions(),
   singleLine: Boolean = false,
   maxLines: Int = Int.MAX_VALUE,
+  minLines: Int = 1,
   interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
   shape: Shape = MaterialTheme.shapes.small,
   colors: TextFieldColors = TextFieldDefaults.outlinedTextFieldColors()
@@ -86,6 +87,7 @@ fun PasswordTextField(
     keyboardActions,
     singleLine,
     maxLines,
+    minLines,
     interactionSource,
     shape,
     colors
@@ -111,6 +113,7 @@ fun PasswordTextField(
   keyboardActions: KeyboardActions = KeyboardActions(),
   singleLine: Boolean = false,
   maxLines: Int = Int.MAX_VALUE,
+  minLines: Int = 1,
   interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
   shape: Shape = MaterialTheme.shapes.small,
   colors: TextFieldColors = TextFieldDefaults.outlinedTextFieldColors()
@@ -143,6 +146,7 @@ fun PasswordTextField(
     keyboardActions,
     singleLine,
     maxLines,
+    minLines,
     interactionSource,
     shape,
     colors
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/util/FlowExtensions.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/util/FlowExtensions.kt
index 9463f4a9d..c1da7450c 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/util/FlowExtensions.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/util/FlowExtensions.kt
@@ -21,11 +21,11 @@ inline fun <T, R> Flow<T?>.flatMapLatestNullable(crossinline transform: suspend
   }
 
 @Composable
-inline fun <T> rememberFlow(initial: T, calculation: @DisallowComposableCalls () -> Flow<T>): T {
+inline fun <T> rememberFlow(initial: T, crossinline calculation: @DisallowComposableCalls () -> Flow<T>): T {
   return remember(calculation).collectAsState(initial).value
 }
 
 @Composable
-inline fun <T> rememberFlow(calculation: @DisallowComposableCalls () -> StateFlow<T>): T {
+inline fun <T> rememberFlow(crossinline calculation: @DisallowComposableCalls () -> StateFlow<T>): T {
   return remember(calculation).collectAsState().value
 }
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 000000000..3aaa4b660
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<resources xmlns:tools="http://schemas.android.com/tools">
+  <string name="app_name" tools:ignore="MissingTranslation">Quasseldroid</string>
+</resources>
diff --git a/build.gradle.kts b/build.gradle.kts
index a1699feb9..a66882dff 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -18,3 +18,17 @@
  */
 
 group = "com.iskrembilen"
+
+buildscript {
+  repositories {
+    google()
+    mavenCentral()
+  }
+}
+
+plugins {
+  alias(libs.plugins.android.application) apply false
+  alias(libs.plugins.kotlin.jvm) apply false
+  alias(libs.plugins.kotlin.serialization) apply false
+  alias(libs.plugins.ksp) apply false
+}
diff --git a/gradle.properties b/gradle.properties
index 661a00443..de22634d3 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -31,4 +31,3 @@ org.gradle.jvmargs=-Xmx2048m
 #org.gradle.caching=true
 # Enable AndroidX
 android.useAndroidX=true
-android.enableJetifier=true
diff --git a/gradle/convention/build.gradle.kts b/gradle/convention/build.gradle.kts
index a1449bfda..8b4b26985 100644
--- a/gradle/convention/build.gradle.kts
+++ b/gradle/convention/build.gradle.kts
@@ -9,13 +9,34 @@ repositories {
 }
 
 dependencies {
-  implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10")
-  implementation("com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:1.6.10-1.0.4")
-  implementation("com.android.tools.build:gradle:7.1.2")
+  compileOnly(libs.android.gradlePlugin)
+  compileOnly(libs.kotlin.gradlePlugin)
+  compileOnly(libs.ksp.gradlePlugin)
+}
+
+gradlePlugin {
+  plugins {
+    register("androidApplication") {
+      id = "justjanne.android.app"
+      implementationClass = "AndroidApplicationConvention"
+    }
+    register("androidLibrary") {
+      id = "justjanne.android.library"
+      implementationClass = "AndroidLibraryConvention"
+    }
+    register("kotlinAndroid") {
+      id = "justjanne.kotlin.android"
+      implementationClass = "KotlinAndroidConvention"
+    }
+    register("kotlin") {
+      id = "justjanne.kotlin"
+      implementationClass = "KotlinConvention"
+    }
+  }
 }
 
 configure<JavaPluginExtension> {
   toolchain {
-    languageVersion.set(JavaLanguageVersion.of(8))
+    languageVersion.set(JavaLanguageVersion.of(11))
   }
 }
diff --git a/gradle/convention/gradle.properties b/gradle/convention/gradle.properties
new file mode 100644
index 000000000..c6cd2a7e2
--- /dev/null
+++ b/gradle/convention/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.parallel=true
+org.gradle.caching=true
+org.gradle.configureondemand=true
diff --git a/gradle/convention/gradle/wrapper/gradle-wrapper.properties b/gradle/convention/gradle/wrapper/gradle-wrapper.properties
index aa5206271..1ffa60b52 100644
--- a/gradle/convention/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/convention/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
-distributionSha256Szm=8cc27038d5dbd815759851ba53e70cf62e481b87494cc97cfd97982ada5ba634
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip
+distributionSha256Szm=ff7bf6a86f09b9b2c40bb8f48b25fc19cf2b2664fd1d220cd7ab833ec758d0d7
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
diff --git a/gradle/convention/settings.gradle.kts b/gradle/convention/settings.gradle.kts
index 6ef6296c7..5bdbcb21e 100644
--- a/gradle/convention/settings.gradle.kts
+++ b/gradle/convention/settings.gradle.kts
@@ -1 +1,15 @@
+@file:Suppress("UnstableApiUsage")
+
 rootProject.name = "convention"
+
+dependencyResolutionManagement {
+  repositories {
+    google()
+    mavenCentral()
+  }
+  versionCatalogs {
+    create("libs") {
+      from(files("../libs.versions.toml"))
+    }
+  }
+}
diff --git a/gradle/convention/src/main/kotlin/AndroidApplicationConvention.kt b/gradle/convention/src/main/kotlin/AndroidApplicationConvention.kt
new file mode 100644
index 000000000..fd3b7f94a
--- /dev/null
+++ b/gradle/convention/src/main/kotlin/AndroidApplicationConvention.kt
@@ -0,0 +1,71 @@
+import com.android.build.api.dsl.ApplicationExtension
+import org.gradle.api.JavaVersion
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.configure
+import util.buildConfigField
+import util.cmd
+import util.properties
+import java.util.*
+
+@Suppress("UnstableApiUsage")
+class AndroidApplicationConvention : Plugin<Project> {
+  override fun apply(target: Project) {
+    with(target) {
+      with(pluginManager) {
+        apply("com.android.application")
+        apply("justjanne.kotlin.android")
+      }
+
+      extensions.configure<ApplicationExtension> {
+        compileSdk = 33
+
+        defaultConfig {
+          minSdk = 21
+          targetSdk = 33
+
+          applicationId = "${rootProject.group}.${rootProject.name.lowercase(Locale.ROOT)}"
+          versionCode = cmd("git", "rev-list", "--count", "HEAD")?.toIntOrNull() ?: 1
+          versionName = cmd("git", "describe", "--always", "--tags", "HEAD") ?: "1.0.0"
+
+          buildConfigField("GIT_HEAD",
+            cmd("git", "rev-parse", "HEAD") ?: "")
+          buildConfigField("GIT_COMMIT_DATE",
+            cmd("git", "show", "-s", "--format=%ct")?.toLongOrNull() ?: 0L)
+
+          signingConfig = signingConfigs.findByName("default")
+
+          setProperty("archivesBaseName", "${rootProject.name}-$versionName")
+
+          // Disable test runner analytics
+          testInstrumentationRunnerArguments["disableAnalytics"] = "true"
+        }
+
+        signingConfigs {
+          SigningData.of(project.rootProject.properties("signing.properties"))?.let {
+            create("default") {
+              storeFile = file(it.storeFile)
+              storePassword = it.storePassword
+              keyAlias = it.keyAlias
+              keyPassword = it.keyPassword
+            }
+          }
+        }
+
+        compileOptions {
+          sourceCompatibility = JavaVersion.VERSION_11
+          targetCompatibility = JavaVersion.VERSION_11
+        }
+
+        testOptions {
+          unitTests.isIncludeAndroidResources = true
+        }
+
+        lint {
+          warningsAsErrors = true
+          lintConfig = file("../lint.xml")
+        }
+      }
+    }
+  }
+}
diff --git a/gradle/convention/src/main/kotlin/AndroidLibraryConvention.kt b/gradle/convention/src/main/kotlin/AndroidLibraryConvention.kt
new file mode 100644
index 000000000..8aa23650e
--- /dev/null
+++ b/gradle/convention/src/main/kotlin/AndroidLibraryConvention.kt
@@ -0,0 +1,35 @@
+import com.android.build.api.dsl.LibraryExtension
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.configure
+import java.util.*
+
+@Suppress("UnstableApiUsage")
+class AndroidLibraryConvention : Plugin<Project> {
+  override fun apply(target: Project) {
+    with(target) {
+      with(pluginManager) {
+        apply("com.android.library")
+        apply("justjanne.kotlin.android")
+      }
+
+      extensions.configure<LibraryExtension> {
+        compileSdk = 33
+
+        defaultConfig {
+          minSdk = 21
+
+          consumerProguardFiles("proguard-rules.pro")
+
+          // Disable test runner analytics
+          testInstrumentationRunnerArguments["disableAnalytics"] = "true"
+        }
+
+        lint {
+          warningsAsErrors = true
+          lintConfig = file("../lint.xml")
+        }
+      }
+    }
+  }
+}
diff --git a/gradle/convention/src/main/kotlin/KotlinAndroidConvention.kt b/gradle/convention/src/main/kotlin/KotlinAndroidConvention.kt
new file mode 100644
index 000000000..c81117d81
--- /dev/null
+++ b/gradle/convention/src/main/kotlin/KotlinAndroidConvention.kt
@@ -0,0 +1,49 @@
+import com.android.build.gradle.BaseExtension
+import org.gradle.api.JavaVersion
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.plugins.JavaPluginExtension
+import org.gradle.api.tasks.testing.Test
+import org.gradle.jvm.toolchain.JavaLanguageVersion
+import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.withType
+import util.kotlinOptions
+
+@Suppress("UnstableApiUsage")
+class KotlinAndroidConvention : Plugin<Project> {
+  override fun apply(target: Project) {
+    with(target) {
+      with(pluginManager) {
+        apply("org.jetbrains.kotlin.android")
+        apply("org.jetbrains.kotlin.kapt")
+        apply("com.google.devtools.ksp")
+      }
+
+      extensions.configure<BaseExtension> {
+        kotlinOptions {
+          freeCompilerArgs = freeCompilerArgs + listOf(
+            "-opt-in=kotlin.RequiresOptIn",
+            "-opt-in=kotlin.ExperimentalUnsignedTypes",
+            "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
+            "-opt-in=kotlinx.coroutines.FlowPreview",
+            "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
+            "-opt-in=androidx.paging.ExperimentalPagingApi",
+            "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
+          )
+
+          jvmTarget = JavaVersion.VERSION_11.toString()
+        }
+      }
+
+      tasks.withType<Test> {
+        useJUnitPlatform()
+      }
+
+      configure<JavaPluginExtension> {
+        toolchain {
+          languageVersion.set(JavaLanguageVersion.of(11))
+        }
+      }
+    }
+  }
+}
diff --git a/gradle/convention/src/main/kotlin/KotlinConvention.kt b/gradle/convention/src/main/kotlin/KotlinConvention.kt
new file mode 100644
index 000000000..8e78d83d4
--- /dev/null
+++ b/gradle/convention/src/main/kotlin/KotlinConvention.kt
@@ -0,0 +1,45 @@
+import org.gradle.api.JavaVersion
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.plugins.JavaPluginExtension
+import org.gradle.api.tasks.testing.Test
+import org.gradle.jvm.toolchain.JavaLanguageVersion
+import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.dependencies
+import org.gradle.kotlin.dsl.kotlin
+import org.gradle.kotlin.dsl.withType
+import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions
+
+@Suppress("UnstableApiUsage")
+class KotlinConvention : Plugin<Project> {
+  override fun apply(target: Project) {
+    with(target) {
+      with(pluginManager) {
+        apply("org.jetbrains.kotlin.jvm")
+        apply("org.jetbrains.kotlin.kapt")
+        apply("com.google.devtools.ksp")
+      }
+
+      extensions.configure<KotlinJvmOptions> {
+        freeCompilerArgs = freeCompilerArgs + listOf(
+          "-opt-in=kotlin.RequiresOptIn",
+          "-opt-in=kotlin.ExperimentalUnsignedTypes",
+          "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
+          "-opt-in=kotlinx.coroutines.FlowPreview",
+        )
+
+        jvmTarget = JavaVersion.VERSION_11.toString()
+      }
+
+      tasks.withType<Test> {
+        useJUnitPlatform()
+      }
+
+      configure<JavaPluginExtension> {
+        toolchain {
+          languageVersion.set(JavaLanguageVersion.of(11))
+        }
+      }
+    }
+  }
+}
diff --git a/gradle/convention/src/main/kotlin/justjanne.android.signing.gradle.kts b/gradle/convention/src/main/kotlin/SigningData.kt
similarity index 51%
rename from gradle/convention/src/main/kotlin/justjanne.android.signing.gradle.kts
rename to gradle/convention/src/main/kotlin/SigningData.kt
index deb2731f9..606738eec 100644
--- a/gradle/convention/src/main/kotlin/justjanne.android.signing.gradle.kts
+++ b/gradle/convention/src/main/kotlin/SigningData.kt
@@ -1,35 +1,5 @@
 import java.util.*
 
-plugins {
-  id("com.android.application")
-}
-
-android {
-  signingConfigs {
-    SigningData.of(project.rootProject.properties("signing.properties"))?.let {
-      create("default") {
-        storeFile = file(it.storeFile)
-        storePassword = it.storePassword
-        keyAlias = it.keyAlias
-        keyPassword = it.keyPassword
-      }
-    }
-  }
-
-  defaultConfig {
-    signingConfig = signingConfigs.findByName("default")
-  }
-}
-
-fun Project.properties(fileName: String): Properties? {
-  val file = file(fileName)
-  if (!file.exists())
-    return null
-  val props = Properties()
-  props.load(file.inputStream())
-  return props
-}
-
 data class SigningData(
   val storeFile: String,
   val storePassword: String,
diff --git a/gradle/convention/src/main/kotlin/justjanne.android.app.gradle.kts b/gradle/convention/src/main/kotlin/justjanne.android.app.gradle.kts
deleted file mode 100644
index 08512a21a..000000000
--- a/gradle/convention/src/main/kotlin/justjanne.android.app.gradle.kts
+++ /dev/null
@@ -1,95 +0,0 @@
-import java.io.ByteArrayOutputStream
-import java.util.*
-
-plugins {
-  id("com.android.application")
-  id("justjanne.kotlin.android")
-}
-
-@Suppress("UnstableApiUsage")
-android {
-  compileSdk = 31
-
-  defaultConfig {
-    minSdk = 21
-    targetSdk = 31
-
-    applicationId = "${rootProject.group}.${rootProject.name.toLowerCase(Locale.ROOT)}"
-    versionCode = cmd("git", "rev-list", "--count", "HEAD")?.toIntOrNull() ?: 1
-    versionName = cmd("git", "describe", "--always", "--tags", "HEAD") ?: "1.0.0"
-
-    buildConfigField("String", "GIT_HEAD", "\"${cmd("git", "rev-parse", "HEAD") ?: ""}\"")
-    buildConfigField("String", "FANCY_VERSION_NAME", "\"${fancyVersionName() ?: ""}\"")
-    buildConfigField("long", "GIT_COMMIT_DATE", "${cmd("git", "show", "-s", "--format=%ct") ?: 0}L")
-
-    signingConfig = signingConfigs.findByName("default")
-
-    setProperty("archivesBaseName", "${rootProject.name}-$versionName")
-
-    // Disable test runner analytics
-    testInstrumentationRunnerArguments["disableAnalytics"] = "true"
-  }
-
-  compileOptions {
-    sourceCompatibility = JavaVersion.VERSION_1_8
-    targetCompatibility = JavaVersion.VERSION_1_8
-  }
-
-  testOptions {
-    unitTests.isIncludeAndroidResources = true
-  }
-
-  lint {
-    warningsAsErrors = true
-    lintConfig = file("../lint.xml")
-  }
-}
-
-fun Project.fancyVersionName(): String? {
-  val commit = cmd("git", "rev-parse", "HEAD")
-  val name = cmd("git", "describe", "--always", "--tags", "HEAD")
-
-  return if (commit != null && name != null) "<a href=\\\"https://git.kuschku.de/justJanne/QuasselDroid-ng/commit/$commit\\\">$name</a>"
-  else name
-}
-
-fun Project.cmd(vararg command: String) = try {
-  val stdOut = ByteArrayOutputStream()
-  exec {
-    commandLine(*command)
-    standardOutput = stdOut
-  }
-  stdOut.toString(Charsets.UTF_8.name()).trim()
-} catch (e: Throwable) {
-  e.printStackTrace()
-  null
-}
-
-fun Project.properties(fileName: String): Properties? {
-  val file = file(fileName)
-  if (!file.exists())
-    return null
-  val props = Properties()
-  props.load(file.inputStream())
-  return props
-}
-
-data class SigningData(
-  val storeFile: String,
-  val storePassword: String,
-  val keyAlias: String,
-  val keyPassword: String
-) {
-  companion object {
-    fun of(properties: Properties?): SigningData? {
-      if (properties == null) return null
-
-      val storeFile = properties.getProperty("storeFile") ?: return null
-      val storePassword = properties.getProperty("storePassword") ?: return null
-      val keyAlias = properties.getProperty("keyAlias") ?: return null
-      val keyPassword = properties.getProperty("keyPassword") ?: return null
-
-      return SigningData(storeFile, storePassword, keyAlias, keyPassword)
-    }
-  }
-}
diff --git a/gradle/convention/src/main/kotlin/justjanne.android.library.gradle.kts b/gradle/convention/src/main/kotlin/justjanne.android.library.gradle.kts
deleted file mode 100644
index 77d3ce9a5..000000000
--- a/gradle/convention/src/main/kotlin/justjanne.android.library.gradle.kts
+++ /dev/null
@@ -1,24 +0,0 @@
-plugins {
-  id("com.android.library")
-  id("justjanne.kotlin.android")
-}
-
-@Suppress("UnstableApiUsage")
-android {
-  compileSdk = 31
-
-  defaultConfig {
-    minSdk = 21
-    targetSdk = 31
-
-    consumerProguardFiles("proguard-rules.pro")
-
-    // Disable test runner analytics
-    testInstrumentationRunnerArguments["disableAnalytics"] = "true"
-  }
-
-  lint {
-    warningsAsErrors = true
-    lintConfig = file("../lint.xml")
-  }
-}
diff --git a/gradle/convention/src/main/kotlin/justjanne.java.gradle.kts b/gradle/convention/src/main/kotlin/justjanne.java.gradle.kts
deleted file mode 100644
index ac5f5d66c..000000000
--- a/gradle/convention/src/main/kotlin/justjanne.java.gradle.kts
+++ /dev/null
@@ -1,23 +0,0 @@
-import gradle.kotlin.dsl.accessors._9f9f63157b527b37420ecbe9e569524a.testImplementation
-import gradle.kotlin.dsl.accessors._9f9f63157b527b37420ecbe9e569524a.testRuntimeOnly
-
-plugins {
-  java
-  id("justjanne.repositories")
-}
-
-dependencies {
-  testImplementation("org.junit.jupiter", "junit-jupiter-api", "5.8.2")
-  testImplementation("org.junit.jupiter", "junit-jupiter-params", "5.8.2")
-  testRuntimeOnly("org.junit.jupiter", "junit-jupiter-engine")
-}
-
-configure<JavaPluginExtension> {
-  toolchain {
-    languageVersion.set(JavaLanguageVersion.of(8))
-  }
-}
-
-tasks.withType<Test> {
-  useJUnitPlatform()
-}
diff --git a/gradle/convention/src/main/kotlin/justjanne.kotlin.android.gradle.kts b/gradle/convention/src/main/kotlin/justjanne.kotlin.android.gradle.kts
deleted file mode 100644
index 3f663fe8b..000000000
--- a/gradle/convention/src/main/kotlin/justjanne.kotlin.android.gradle.kts
+++ /dev/null
@@ -1,47 +0,0 @@
-import gradle.kotlin.dsl.accessors._9f9f63157b527b37420ecbe9e569524a.implementation
-import gradle.kotlin.dsl.accessors._9f9f63157b527b37420ecbe9e569524a.testImplementation
-import gradle.kotlin.dsl.accessors._9f9f63157b527b37420ecbe9e569524a.testRuntimeOnly
-import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
-
-plugins {
-  id("justjanne.repositories")
-  id("com.google.devtools.ksp")
-  kotlin("android")
-  kotlin("kapt")
-}
-
-dependencies {
-  implementation("org.jetbrains.kotlin:kotlin-stdlib:1.6.10")
-
-  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0")
-  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0")
-  testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0")
-
-  testImplementation("org.junit.jupiter", "junit-jupiter-api", "5.8.2")
-  testImplementation("org.junit.jupiter", "junit-jupiter-params", "5.8.2")
-  testRuntimeOnly("org.junit.jupiter", "junit-jupiter-engine")
-
-  testImplementation("org.jetbrains.kotlin:kotlin-test-junit5:1.6.10")
-}
-
-tasks.withType<KotlinCompile> {
-  kotlinOptions {
-    freeCompilerArgs = listOf(
-      "-opt-in=kotlin.ExperimentalUnsignedTypes",
-      "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
-      "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
-      "-opt-in=androidx.paging.ExperimentalPagingApi",
-    )
-    jvmTarget = "1.8"
-  }
-}
-
-tasks.withType<Test> {
-  useJUnitPlatform()
-}
-
-configure<JavaPluginExtension> {
-  toolchain {
-    languageVersion.set(JavaLanguageVersion.of(8))
-  }
-}
diff --git a/gradle/convention/src/main/kotlin/justjanne.kotlin.gradle.kts b/gradle/convention/src/main/kotlin/justjanne.kotlin.gradle.kts
deleted file mode 100644
index 2e1834c8f..000000000
--- a/gradle/convention/src/main/kotlin/justjanne.kotlin.gradle.kts
+++ /dev/null
@@ -1,29 +0,0 @@
-import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
-
-plugins {
-  id("justjanne.java")
-  id("com.google.devtools.ksp")
-  kotlin("jvm")
-  kotlin("kapt")
-}
-
-dependencies {
-  implementation("org.jetbrains.kotlin:kotlin-stdlib:1.6.10")
-
-  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0")
-  testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0")
-
-  testImplementation("org.jetbrains.kotlin:kotlin-test-junit5:1.6.10")
-}
-
-tasks.withType<KotlinCompile> {
-  kotlinOptions {
-    freeCompilerArgs = listOf(
-      "-opt-in=kotlin.ExperimentalUnsignedTypes",
-      "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
-      "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
-      "-opt-in=androidx.paging.ExperimentalPagingApi",
-    )
-    jvmTarget = "1.8"
-  }
-}
diff --git a/gradle/convention/src/main/kotlin/justjanne.repositories.gradle.kts b/gradle/convention/src/main/kotlin/justjanne.repositories.gradle.kts
deleted file mode 100644
index 01861dc99..000000000
--- a/gradle/convention/src/main/kotlin/justjanne.repositories.gradle.kts
+++ /dev/null
@@ -1,5 +0,0 @@
-repositories {
-  mavenCentral()
-  google()
-  maven(url = "https://jitpack.io")
-}
diff --git a/gradle/convention/src/main/kotlin/util/BaseExtensionExtensions.kt b/gradle/convention/src/main/kotlin/util/BaseExtensionExtensions.kt
new file mode 100644
index 000000000..667f8dac0
--- /dev/null
+++ b/gradle/convention/src/main/kotlin/util/BaseExtensionExtensions.kt
@@ -0,0 +1,8 @@
+package util
+
+import com.android.build.gradle.BaseExtension
+import org.gradle.api.plugins.ExtensionAware
+import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions
+
+fun BaseExtension.kotlinOptions(configure: KotlinJvmOptions.() -> Unit): Unit =
+  (this as ExtensionAware).extensions.configure("kotlinOptions", configure)
diff --git a/gradle/convention/src/main/kotlin/util/ProjectExtensions.kt b/gradle/convention/src/main/kotlin/util/ProjectExtensions.kt
new file mode 100644
index 000000000..1f0f0309e
--- /dev/null
+++ b/gradle/convention/src/main/kotlin/util/ProjectExtensions.kt
@@ -0,0 +1,79 @@
+package util
+
+import com.android.build.api.dsl.VariantDimension
+import org.gradle.api.Incubating
+import org.gradle.api.Project
+import java.io.ByteArrayOutputStream
+import java.util.*
+
+fun Project.cmd(vararg command: String) = try {
+  val stdOut = ByteArrayOutputStream()
+  exec {
+    commandLine(*command)
+    standardOutput = stdOut
+  }
+  stdOut.toString(Charsets.UTF_8.name()).trim()
+} catch (e: Throwable) {
+  e.printStackTrace()
+  null
+}
+
+@Suppress("UnstableApiUsage")
+@Incubating
+inline fun <reified T> VariantDimension.buildConfigField(key: String, value: T) {
+  when (value) {
+    is String -> this.buildConfigField(
+      "String",
+      key,
+      "\"%s\"".format(value.replace("""\""", """\\""")
+        .replace(""""""", """\""""))
+    )
+    is Long -> this.buildConfigField(
+      "long",
+      key,
+      "%dL".format(value)
+    )
+    is Int -> this.buildConfigField(
+      "int",
+      key,
+      "%d".format(value)
+    )
+    is Short -> this.buildConfigField(
+      "short",
+      key,
+      "%d".format(value)
+    )
+    is Byte -> this.buildConfigField(
+      "byte",
+      key,
+      "%d".format(value)
+    )
+    is Char -> this.buildConfigField(
+      "char",
+      key,
+      "'%s'".format(value)
+    )
+    is Double -> this.buildConfigField(
+      "double",
+      key,
+      "%.f".format(value)
+    )
+    is Float -> this.buildConfigField(
+      "float",
+      key,
+      "%.ff".format(value)
+    )
+    else -> throw IllegalArgumentException(
+      "build config cannot contain values of type " + T::class.java.canonicalName
+    )
+  }
+}
+
+fun Project.properties(fileName: String): Properties? {
+  val file = file(fileName)
+  if (!file.exists())
+    return null
+  val props = Properties()
+  props.load(file.inputStream())
+  return props
+}
diff --git a/gradle/init.gradle.kts b/gradle/init.gradle.kts
new file mode 100644
index 000000000..7f161bac4
--- /dev/null
+++ b/gradle/init.gradle.kts
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ *   Licensed under the Apache License, Version 2.0 (the "License");
+ *   you may not use this file except in compliance with the License.
+ *   You may obtain a copy of the License at
+ *
+ *       https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *   Unless required by applicable law or agreed to in writing, software
+ *   distributed under the License is distributed on an "AS IS" BASIS,
+ *   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *   See the License for the specific language governing permissions and
+ *   limitations under the License.
+ */
+
+val ktlintVersion = "0.48.1"
+
+initscript {
+  val spotlessVersion = "6.13.0"
+
+  repositories {
+    mavenCentral()
+  }
+
+  dependencies {
+    classpath("com.diffplug.spotless:spotless-plugin-gradle:$spotlessVersion")
+  }
+}
+
+rootProject {
+  subprojects {
+    apply<com.diffplug.gradle.spotless.SpotlessPlugin>()
+    extensions.configure<com.diffplug.gradle.spotless.SpotlessExtension> {
+      kotlin {
+        target("**/*.kt")
+        targetExclude("**/build/**/*.kt")
+        ktlint(ktlintVersion).userData(mapOf("android" to "true"))
+        licenseHeaderFile(rootProject.file("gradle/spotless/copyright.kt"))
+      }
+      format("kts") {
+        target("**/*.kts")
+        targetExclude("**/build/**/*.kts")
+        // Look for the first line that doesn't have a block comment (assumed to be the license)
+        licenseHeaderFile(rootProject.file("gradle/spotless/copyright.kts"), "(^(?![\\/ ]\\*).*$)")
+      }
+      format("xml") {
+        target("**/*.xml")
+        targetExclude("**/build/**/*.xml")
+        // Look for the first XML tag that isn't a comment (<!--) or the xml declaration (<?xml)
+        licenseHeaderFile(rootProject.file("gradle/spotless/copyright.xml"), "(<[^!?])")
+      }
+    }
+  }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index aacaa480a..08701b927 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,14 +1,25 @@
 [versions]
-libquassel = "0.10.1"
-androidx-collection = "1.2.0"
-androidx-core = "1.7.0"
+libquassel = "0.10.2"
+androidGradlePlugin = "7.4.2"
 androidx-activity = "1.4.0"
 androidx-appcompat = "1.4.1"
-androidx-compose = "1.1.1"
-androidx-material3 = "1.0.0-alpha05"
-androidx-navigation = "2.4.1"
-androidx-paging = "3.1.0"
-androidx-room = "2.5.0-alpha01"
+androidx-compose-bom = "2023.03.00"
+androidx-compose-compiler = "1.4.1"
+androidx-compose-material = "1.4.0"
+androidx-compose-material3 = "1.1.0-alpha06"
+androidx-compose-runtimetracing = "1.0.0-alpha01"
+androidx-compose-tooling = "1.4.0"
+androidx-collection = "1.2.0"
+androidx-navigation = "2.5.3"
+androidx-paging = "3.1.1"
+androidx-room = "2.5.1"
+kotlin = "1.8.0"
+kotlinxCoroutines = "1.6.4"
+kotlinxDatetime = "0.4.0"
+kotlinxSerializationJson = "1.4.1"
+ksp = "1.8.0-1.0.9"
+junit = "5.8.2"
+spotless = "6.7.0"
 
 [libraries]
 libquassel-protocol = { module = "de.justjanne.libquassel:libquassel-protocol", version.ref = "libquassel" }
@@ -21,30 +32,61 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver
 androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
 androidx-appcompat-resources = { module = "androidx.appcompat:appcompat-resources", version.ref = "androidx-appcompat" }
 
-androidx-compose-animation = { module = "androidx.compose.animation:animation", version.ref = "androidx-compose" }
-androidx-compose-compiler = { module = "androidx.compose.compiler:compiler", version.ref = "androidx-compose" }
-androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "androidx-compose" }
-#androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "androidx-compose" }
-androidx-compose-material = { module = "androidx.compose.material3:material3", version.ref = "androidx-material3" }
-androidx-compose-material-icons = { module = "androidx.compose.material:material-icons-extended", version.ref = "androidx-compose" }
-androidx-compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "androidx-compose" }
-androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidx-compose" }
-androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "androidx-compose" }
-androidx-compose-ui-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidx-compose" }
-androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test", version.ref = "androidx-compose" }
+androidx-compose-compiler = { module = "androidx.compose.compiler:compiler", version.ref = "androidx-compose-compiler" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidx-compose-bom" }
+androidx-compose-animation = { group = "androidx.compose.animation", name = "animation" }
+androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
+androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout" }
+androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "androidx-compose-material3" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidx-compose-material3" }
+androidx-compose-material = { group = "androidx.compose.material", name = "material", version.ref = "androidx-compose-material" }
+androidx-compose-material-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class", version.ref = "androidx-compose-material3" }
+androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
+androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" }
+androidx-compose-runtime-tracing = { group = "androidx.compose.runtime", name = "runtime-tracing", version.ref = "androidx-compose-runtimetracing" }
+androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test" }
+androidx-compose-ui-testManifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "androidx-compose-tooling" }
+androidx-compose-ui-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "androidx-compose-tooling" }
+androidx-compose-ui-util = { group = "androidx.compose.ui", name = "ui-util" }
 
-androidx-collection-ktx = { module = "androidx.collection:collection-ktx", version.ref = "androidx-collection" }
-androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidx-navigation" }
 
-androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
+androidx-collection-ktx = { group = "androidx.collection", name = "collection-ktx", version.ref = "androidx-collection" }
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx" }
 
 androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "androidx-paging" }
 androidx-paging-test = { module = "androidx.paging:paging-common", version.ref = "androidx-paging" }
-androidx-paging-compose = { module = "androidx.paging:paging-compose", version = "1.0.0-alpha14" }
+androidx-paging-compose = { module = "androidx.paging:paging-compose", version = "1.0.0-alpha18" }
 
 androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" }
 androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" }
 androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" }
 androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "androidx-room" }
 
-compose-htmltext = { module = "de.charlex.compose:html-text", version = "1.1.0" }
+#compose-htmltext = { module = "de.charlex.compose:html-text", version = "1.1.0" }
+
+junit-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" }
+junit-params = { group = "org.junit.jupiter", name = "junit-jupiter-params", version.ref = "junit" }
+junit-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine" }
+
+kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib-jdk8", version.ref = "kotlin" }
+kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit5", version.ref = "kotlin" }
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
+kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
+kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
+
+# Dependencies of the included build-logic
+android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }
+kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
+ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
+android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }
+android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" }
+kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
diff --git a/gradle/spotless/copyright.kt b/gradle/spotless/copyright.kt
new file mode 100644
index 000000000..c4b5f2b20
--- /dev/null
+++ b/gradle/spotless/copyright.kt
@@ -0,0 +1,18 @@
+/*
+ * Quasseldroid - Quassel client for Android
+ *
+ * Copyright (c) $YEAR Janne Mareike Koschinski
+ * Copyright (c) $YEAR The Quassel Project
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 3 as published
+ * by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
diff --git a/gradle/spotless/copyright.kts b/gradle/spotless/copyright.kts
new file mode 100644
index 000000000..c4b5f2b20
--- /dev/null
+++ b/gradle/spotless/copyright.kts
@@ -0,0 +1,18 @@
+/*
+ * Quasseldroid - Quassel client for Android
+ *
+ * Copyright (c) $YEAR Janne Mareike Koschinski
+ * Copyright (c) $YEAR The Quassel Project
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 3 as published
+ * by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
diff --git a/gradle/spotless/copyright.xml b/gradle/spotless/copyright.xml
new file mode 100644
index 000000000..d786382b5
--- /dev/null
+++ b/gradle/spotless/copyright.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Quasseldroid - Quassel client for Android
+
+  Copyright (c) $YEAR Janne Mareike Koschinski
+  Copyright (c) $YEAR The Quassel Project
+
+  This program is free software: you can redistribute it and/or modify it
+  under the terms of the GNU General Public License version 3 as published
+  by the Free Software Foundation.
+
+  This program is distributed in the hope that it will be useful,
+  but WITHOUT ANY WARRANTY; without even the implied warranty of
+  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  GNU General Public License for more details.
+
+  You should have received a copy of the GNU General Public License along
+  with this program. If not, see <http://www.gnu.org/licenses/>.
+-->
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index aa5206271..1ffa60b52 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
-distributionSha256Szm=8cc27038d5dbd815759851ba53e70cf62e481b87494cc97cfd97982ada5ba634
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip
+distributionSha256Szm=ff7bf6a86f09b9b2c40bb8f48b25fc19cf2b2664fd1d220cd7ab833ec758d0d7
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 81fea8fd3..368d018e5 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -16,18 +16,25 @@
  * You should have received a copy of the GNU General Public License along
  * with this program. If not, see <http://www.gnu.org/licenses/>.
  */
-enableFeaturePreview("VERSION_CATALOGS")
+@file:Suppress("UnstableApiUsage")
 
 rootProject.name = "Quasseldroid"
 
-includeBuild("gradle/convention")
-
-include(":app")
-
 pluginManagement {
+  includeBuild("gradle/convention")
   repositories {
+    google()
+    mavenCentral()
     gradlePluginPortal()
+  }
+}
+
+dependencyResolutionManagement {
+  repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+  repositories {
     google()
     mavenCentral()
   }
 }
+
+include(":app")
-- 
GitLab