Compare commits

26 Commits

Author SHA1 Message Date
Sweetbread e10e47d6c0 fixup! refactor: Avatar function 2026-04-23 20:37:09 +03:00
Sweetbread 8f6adf0746 fix: close message field after closing a keyboard,
change design
2026-04-23 20:35:29 +03:00
Sweetbread eae2d4f388 refactor: Avatar function 2026-04-23 20:20:20 +03:00
Sweetbread b312cc50f1 fixup! fixup! Display and send messages 2026-04-23 04:25:07 +03:00
Sweetbread 8b8926f931 fixup! fixup! New TopBar 2026-04-23 03:36:29 +03:00
Sweetbread a35f5196b1 fixup! Sticky avatar 2026-04-21 19:20:07 +03:00
Sweetbread cab1ff93a4 Fix message loading 2026-04-19 17:41:08 +03:00
Sweetbread 769319f308 fixup! New TopBar 2026-04-19 17:40:51 +03:00
Sweetbread d28baa70d4 fixup! fixup! Message bubbles 2026-04-19 02:56:25 +03:00
Sweetbread f29672d1bd fixup! fixup! Sticky date 2026-04-19 02:49:37 +03:00
Sweetbread dd30db2130 New TopBar 2026-04-19 02:48:23 +03:00
Sweetbread 0711ce41d3 fixup! Message bubbles 2026-04-19 01:06:20 +03:00
Sweetbread b1357bd4e7 fixup! Sticky date 2026-04-19 00:42:20 +03:00
Sweetbread 0e107eca0b Sticky avatar 2026-04-18 23:25:19 +03:00
Sweetbread f90a8e3472 Sticky date 2026-04-18 22:53:43 +03:00
Sweetbread aa18e5b0a6 Message bubbles 2026-04-14 01:01:15 +03:00
Sweetbread a21860c21d fixup! Display and send messages 2026-04-14 00:21:30 +03:00
Sweetbread d7d14389fc Display and send messages 2026-04-10 14:26:33 +03:00
Sweetbread 902af5e7b5 wip: show messages 2026-03-26 23:57:28 +03:00
Sweetbread 64de39f0ca update 2026-03-20 01:22:30 +03:00
Sweetbread f8b10ebb34 wip 2026-03-13 17:35:34 +03:00
Sweetbread a090ff614a wip: Migrate to Trixnity 2026-02-22 00:30:11 +03:00
Sweetbread dd3b31d0b2 wip: Migrate to Trixnity 2026-02-21 21:21:57 +03:00
Sweetbread c7b5f20c06 wip: Migrate to Trixnity 2026-02-21 16:48:31 +03:00
Sweetbread 8d6a76ccb5 wip 2026-02-20 17:28:12 +03:00
Sweetbread 1cfad2ca4e wip 2026-02-20 09:41:06 +03:00
27 changed files with 2349 additions and 960 deletions
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+3
View File
@@ -49,6 +49,9 @@
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />
<option name="previewFile" value="true" /> <option name="previewFile" value="true" />
</inspection_tool> </inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true"> <inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />
<option name="previewFile" value="true" /> <option name="previewFile" value="true" />
+4
View File
@@ -5,8 +5,12 @@
<set> <set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" /> <option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" /> <option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" /> <option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" /> <option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set> </set>
</option> </option>
</component> </component>
Generated Executable
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>
Generated
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="" vcs="Git" />
</component> </component>
</project> </project>
+27 -9
View File
@@ -1,22 +1,28 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
*/
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/ */
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
id("com.google.devtools.ksp") // alias(libs.plugins.ksp)
} }
android { android {
namespace = "ru.risdeveau.pixeldragon" namespace = "ru.risdeveau.pixeldragon"
compileSdk = 35 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "ru.risdeveau.pixeldragon" applicationId = "ru.risdeveau.pixeldragon"
minSdk = 26 minSdk = 28
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
@@ -37,14 +43,17 @@ android {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions {
jvmTarget = "17"
}
buildFeatures { buildFeatures {
compose = true compose = true
} }
} }
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -54,6 +63,7 @@ dependencies {
implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.compose.ui.unit)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
@@ -62,6 +72,13 @@ dependencies {
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)
// Trixnity - Matrix wrapper
implementation(libs.trixnity.client)
implementation(libs.trixnity.client.media.okio)
implementation(libs.trixnity.client.repository.room)
implementation(libs.trixnity.client.cryptodriver.vodozemac)
// implementation(libs.trixnity.messenger)
// Ktor - web client // Ktor - web client
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
@@ -75,7 +92,7 @@ dependencies {
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler) // ksp(libs.androidx.room.compiler)
// Navigation Compose // Navigation Compose
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
@@ -87,4 +104,5 @@ dependencies {
// Others // Others
implementation(libs.splitties.base) // Syntax sugar implementation(libs.splitties.base) // Syntax sugar
implementation(libs.jsoup) // HTML parser implementation(libs.jsoup) // HTML parser
implementation(libs.iconsax.compose) // Material icons
} }
@@ -1,20 +1,19 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
*/ */
package ru.risdeveau.pixeldragon package ru.risdeveau.pixeldragon
import android.util.Log import android.util.Log
import de.connect2x.trixnity.client.MatrixClient
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.plugins.cache.HttpCache import io.ktor.client.plugins.cache.HttpCache
import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.logging.Logging
import ru.risdeveau.pixeldragon.api.getMe
import splitties.preferences.Preferences
val client = HttpClient { val webClient = HttpClient {
install(Logging) { install(Logging) {
logger = object : Logger { logger = object : Logger {
override fun log(message: String) { override fun log(message: String) {
@@ -27,24 +26,4 @@ val client = HttpClient {
install(HttpCache) install(HttpCache)
} }
lateinit var homeserver: String var client: MatrixClient? = null
lateinit var baseUrl: String
lateinit var token: String
object AccountData : Preferences("settings") {
var token by stringPref("token", "")
var homeserver by stringPref("homeserver", "")
}
suspend fun initCheck(): Boolean {
Log.d("initCheck", "checking...")
token = AccountData.token
homeserver = AccountData.homeserver
if (token.isEmpty() or homeserver.isEmpty()) return false
baseUrl = "$homeserver/_matrix/client/v3"
return getMe() != null
}
@@ -1,56 +1,50 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
* Last modified 03.03.2025, 20:21
*/ */
package ru.risdeveau.pixeldragon.api package ru.risdeveau.pixeldragon.api
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.statement.bodyAsText
import org.json.JSONObject import org.json.JSONObject
import ru.risdeveau.pixeldragon.baseUrl import java.time.Instant
import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.token
class Event ( class Event (
val id: String, val id: String,
val rid: String, val rid: String,
val sender: String,
val type: String, val type: String,
val content: JSONObject val content: JSONObject,
val time: Instant,
val sender: String
) { ) {
constructor(json: JSONObject) : this( // constructor(json: JSONObject) : this(
json.getString("event_id"), // json.getString("event_id"),
json.getString("room_id"), // json.getString("room_id"),
json.getString("sender"), // json.getString("sender"),
json.getString("type"), // json.getString("type"),
json.getJSONObject("content") // json.getJSONObject("content")
) // )
} }
data class EventsAround ( //data class EventsAround (
val base: Event, // val base: Event,
val before: List<Event>, // val before: List<Event>,
val after: List<Event> // val after: List<Event>
) //)
//
suspend fun getEventsAround(room: String, event: String): EventsAround { //suspend fun getEventsAround(room: String, event: String): EventsAround {
val r = client.get("$baseUrl/rooms/$room/context/$event") { // val r = client.get("$baseUrl/rooms/$room/context/$event") {
bearerAuth(token) // bearerAuth(token)
parameter("limit", "50") // parameter("limit", "50")
} // }
val json = JSONObject(r.bodyAsText()) // val json = JSONObject(r.bodyAsText())
//
return EventsAround( // return EventsAround(
Event(json.getJSONObject("event")), // Event(json.getJSONObject("event")),
if (json.has("events_before")) json.getJSONArray("events_before").let { // if (json.has("events_before")) json.getJSONArray("events_before").let {
List<Event>(it.length()) { i -> Event(it.getJSONObject(i))}.reversed() // List<Event>(it.length()) { i -> Event(it.getJSONObject(i))}.reversed()
} else listOf(), // } else listOf(),
if (json.has("events_after")) json.getJSONArray("events_after").let { // if (json.has("events_after")) json.getJSONArray("events_after").let {
List<Event>(it.length()) { i -> Event(it.getJSONObject(i))} // List<Event>(it.length()) { i -> Event(it.getJSONObject(i))}
} else listOf() // } else listOf()
) // )
} //}
@@ -1,97 +1,4 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
*/ */
package ru.risdeveau.pixeldragon.api
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.json.JSONObject
import ru.risdeveau.pixeldragon.baseUrl
import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.repo.Room
import ru.risdeveau.pixeldragon.repo.User
import ru.risdeveau.pixeldragon.token
//fun getRooms(): List<Room> {
// return db.roomDoa().getAllJoined()
//}
//
//fun updateRooms(): List<Room> {
//
//}
suspend fun getJoinedRooms(): List<Room> {
val r = client.get("$baseUrl/joined_rooms")
{ bearerAuth(token) }
val rooms = JSONObject(r.bodyAsText()).getJSONArray("joined_rooms")
return List(
rooms.length()
) { i -> getRoom(rooms.getString(i), true) }
}
suspend fun isJoined(id: String): Boolean {
val r = client.get("$baseUrl/joined_rooms")
{ bearerAuth(token) }
val rooms = JSONObject(r.bodyAsText()).getJSONArray("joined_rooms")
return List<String>(
rooms.length()
) { i -> rooms.getString(i) }.contains(id)
}
suspend fun getRoom(rid: String, joined: Boolean? = null): Room {
val direct = getAccountData(getMe()!!.userId, "m.direct")
var directWith = ""
direct?.let {
for (user in direct.keys()) {
val roomsWithUser = direct.getJSONArray(user)
for (i in 0 until roomsWithUser.length()) {
if (rid == roomsWithUser.getString(i)) {
directWith = user
break
}
}
if (directWith.isNotEmpty()) break
}
}
return coroutineScope {
val name = async { getState(rid, "m.room.name", "name") }
val type = async { getState(rid, "m.room.create", "type") ?: "m.room" }
val creator = async { getState(rid, "m.room.create", "creator") }
val avatar = async { getState(rid, "m.room.avatar", "url") }
val joined = async { joined ?: isJoined(rid) }
val direct = async { if (directWith.isNotEmpty()) User.getById(directWith) else null }
Room(
rid,
name.await(),
type.await(),
creator.await(),
null,
avatar.await(),
null,
joined.await(),
direct.await()
)
}
}
private suspend fun getState(rid: String, state: String, key: String): String? {
val r = client.get("$baseUrl/rooms/$rid/state/$state") { bearerAuth(token) }
if (r.status != HttpStatusCode.OK) return null
val json = JSONObject(r.bodyAsText())
if (!json.has(key)) return null
return json.getString(key)
}
suspend fun getAccountData(user: String, room: String, state: String): JSONObject? {
val r = client.get("$baseUrl/user/$user/rooms/$room/account_data/$state") { bearerAuth(token) }
if (r.status != HttpStatusCode.OK) return null
return JSONObject(r.bodyAsText())
}
@@ -1,23 +1,30 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
*/ */
package ru.risdeveau.pixeldragon.api package ru.risdeveau.pixeldragon.api
import android.util.Log
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import ru.risdeveau.pixeldragon.client import ru.risdeveau.pixeldragon.webClient
import ru.risdeveau.pixeldragon.homeserver //import ru.risdeveau.pixeldragon.homeserver
suspend fun getHomeserver(url: String): String? { suspend fun getHomeserver(url: String): String? {
val r = try { client.get("https://$url/.well-known/matrix/client") } val r = try { webClient.get("https://$url/.well-known/matrix/client") }
catch (_: Exception) { return null } catch (e: Exception) {
Log.w("getHomeserver", "Fail sending the request", e)
return null
}
val json = try { JSONObject(r.bodyAsText()) } val json = try { JSONObject(r.bodyAsText()) }
catch (_: JSONException) { return null } catch (e: JSONException) {
Log.w("getHomeserver", "Fail parsing the JSON", e)
return null
}
if (!json.has("m.homeserver")) return null if (!json.has("m.homeserver")) return null
@@ -27,11 +34,11 @@ suspend fun getHomeserver(url: String): String? {
return homeserver return homeserver
} }
fun mxcToUrl(mxc: String): String? { //fun mxcToUrl(mxc: String): String? {
val pattern = Regex("mxc://([-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6})/([^#?]+)") // val pattern = Regex("mxc://([-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6})/([^#?]+)")
val match = pattern.find(mxc) // val match = pattern.find(mxc)
//
if ((match?.groupValues[1] == null) or (match?.groupValues[2] == null)) return null // if ((match?.groupValues[1] == null) or (match?.groupValues[2] == null)) return null
//
return "$homeserver/_matrix/client/v1/media/download/${match?.groupValues[1]}/${match?.groupValues[2]}" // return "$homeserver/_matrix/client/v1/media/download/${match?.groupValues[1]}/${match?.groupValues[2]}"
} //}
@@ -1,100 +1,49 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
*/ */
package ru.risdeveau.pixeldragon.api package ru.risdeveau.pixeldragon.api
import android.util.Log import android.util.Log
import io.ktor.client.request.bearerAuth import de.connect2x.trixnity.client.CryptoDriverModule
import io.ktor.client.request.get import io.ktor.http.Url
import io.ktor.client.request.post import de.connect2x.trixnity.client.MatrixClient
import io.ktor.client.request.setBody import de.connect2x.trixnity.client.create
import io.ktor.client.statement.bodyAsText import de.connect2x.trixnity.client.cryptodriver.vodozemac.vodozemac
import io.ktor.http.ContentType import de.connect2x.trixnity.clientserverapi.client.MatrixClientAuthProviderData
import io.ktor.http.HttpStatusCode import de.connect2x.trixnity.clientserverapi.client.classicLogin
import io.ktor.http.contentType import de.connect2x.trixnity.clientserverapi.model.authentication.IdentifierType
import org.json.JSONObject
import ru.risdeveau.pixeldragon.AccountData
import ru.risdeveau.pixeldragon.baseUrl
import ru.risdeveau.pixeldragon.client import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.initCheck import ru.risdeveau.pixeldragon.util.getMediaStore
import ru.risdeveau.pixeldragon.token import ru.risdeveau.pixeldragon.util.getRoomStore
import splitties.experimental.ExperimentalSplittiesApi import splitties.experimental.ExperimentalSplittiesApi
import splitties.init.appCtx import splitties.init.appCtx
import splitties.preferences.edit
data class Me (val userId: String, val deviceId: String)
data class UserProfile (val displayName: String, val avatarUrl: String, val other: JSONObject)
/**
* This func is to validate the token
*/
suspend fun getMe(): Me? {
val r = client.get("$baseUrl/account/whoami") { bearerAuth(token) }
if (r.status != HttpStatusCode.OK) {
Log.e("getMe", r.bodyAsText())
return null
}
val json = JSONObject(r.bodyAsText())
return Me(json.getString("user_id"), json.getString("device_id"))
}
@OptIn(ExperimentalSplittiesApi::class) @OptIn(ExperimentalSplittiesApi::class)
suspend fun login(server: String, login: String, pass: String): Boolean { suspend fun login(server: String, login: String, pass: String): Boolean {
val hs = getHomeserver(server)!! val hs = Url(getHomeserver(server)!!)
val pinfo = appCtx.packageManager.getPackageInfo(appCtx.packageName, 0) val pinfo = appCtx.packageManager.getPackageInfo(appCtx.packageName, 0)
val pattern = """ try {
{ client = MatrixClient.create(
"type": "m.login.password", repositoriesModule = getRoomStore(appCtx),
"identifier": { mediaStoreModule = getMediaStore(),
"type": "m.id.user" cryptoDriverModule = CryptoDriverModule.vodozemac(),
}, authProviderData = MatrixClientAuthProviderData.classicLogin(
"initial_device_display_name": "PixelDragon Android v${pinfo.versionName}" baseUrl = hs,
} identifier = IdentifierType.User(login),
""".trimIndent() password = pass,
val json = JSONObject(pattern) initialDeviceDisplayName = "PixelDragon Android v${pinfo.versionName}",
json.getJSONObject("identifier").put("user", login) ).getOrThrow()
json.put("password", pass) ).getOrThrow()
val r = try { return true
client.post("$hs/_matrix/client/v3/login") {
setBody(json.toString())
contentType(ContentType.Application.Json)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e("login", e.toString()) Log.i("Login", "Failed to login", e)
return false
} }
if (r.status != HttpStatusCode.OK) { return false
Log.e("login", r.bodyAsText())
return false // TODO: Inform a user of error code
}
val res = JSONObject(r.bodyAsText())
AccountData.edit {
token = res.getString("access_token")
homeserver = hs
}
return initCheck()
}
suspend fun getAccountData(user: String, state: String): JSONObject? {
val r = client.get("$baseUrl/user/$user/account_data/$state") { bearerAuth(token) }
if (r.status != HttpStatusCode.OK) return null
return JSONObject(r.bodyAsText())
}
suspend fun getUserProfile(userId: String): UserProfile? {
val r = client.get("$baseUrl/profile/$userId") { bearerAuth(token) }
if (r.status != HttpStatusCode.OK) return null
val json = JSONObject(r.bodyAsText())
val name = json.optString("displayname", ""); json.remove("displayname")
val avatar = json.optString("avatar_url", ""); json.remove("avatar_url")
return UserProfile(name, avatar, json)
} }
+4
View File
@@ -0,0 +1,4 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
@@ -1,41 +1,4 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
*/ */
package ru.risdeveau.pixeldragon.db
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import splitties.init.appCtx
import splitties.systemservices.connectivityManager
import java.time.Instant
@Database(entities = [RoomDB::class, SpaceToRoom::class, UserDB::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun roomDoa(): RoomDao
abstract fun userDoa(): UserDao
}
class Converters {
@TypeConverter
fun fromInstant(value: Instant?): String? {
return value?.toString()
}
@TypeConverter
fun toInstant(value: String?): Instant? {
return value?.let { Instant.parse(it) }
}
}
fun isConnected(): Boolean = connectivityManager.isDefaultNetworkActive
val cacheDb = Room.databaseBuilder(
appCtx,
AppDatabase::class.java, "cache"
)
.fallbackToDestructiveMigration()
.build()
@@ -1,101 +1,4 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
*/ */
package ru.risdeveau.pixeldragon.db
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.Junction
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Relation
import androidx.room.TypeConverters
import ru.risdeveau.pixeldragon.repo.Room
import ru.risdeveau.pixeldragon.repo.User
import java.time.Instant
@Entity(tableName = "room")
@TypeConverters(Converters::class)
data class RoomDB (
@PrimaryKey val id: String,
val updatedAt: Instant,
val name: String?,
val type: String,
val creatorId: String?,
val createTime: Long?,
val avatarUrl: String?,
val members: Int?,
val joined: Boolean,
val direct: String?
)
fun RoomDB.isExpired(cacheDuration: Long = 60 * 60): Boolean {
return Instant.now().minusSeconds(cacheDuration) > updatedAt
}
suspend fun RoomDB.toDomain(): Room = Room(
id = id,
name = name,
type = type,
creatorId = creatorId,
createTime = createTime,
avatarUrl = avatarUrl,
members = members,
joined = joined,
direct = direct?.let { User.getById(it) }
)
fun Room.toEntity(): RoomDB = RoomDB(
id = id,
updatedAt = Instant.now(),
name = name,
type = type,
creatorId = creatorId,
createTime = createTime,
avatarUrl = avatarUrl,
members = members,
joined = joined,
direct = direct?.id
)
@Dao
interface RoomDao {
@Query("SELECT * FROM room WHERE id LIKE :id LIMIT 1")
fun getById(id: String): RoomDB?
// @Transaction
// @Query("SELECT * FROM room WHERE ")
// fun getSpace(rid: String): Space
@Query("SELECT * FROM room WHERE joined = 1 AND id NOT IN (SELECT id FROM SpaceToRoom)")
fun getAllJoined(): List<RoomDB>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg rooms: RoomDB)
@Delete
fun delete(room: RoomDB)
}
@Entity(primaryKeys = ["spaceId", "roomId"])
data class SpaceToRoom(
val spaceId: String,
val roomId: String
)
data class Space(
@Embedded val space: RoomDB,
@Relation(
parentColumn = "spaceId",
entityColumn = "roomId",
associateBy = Junction(SpaceToRoom::class)
)
val children: List<RoomDB>
)
@@ -1,60 +1,4 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
*/ */
package ru.risdeveau.pixeldragon.db
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.TypeConverters
import org.json.JSONObject
import ru.risdeveau.pixeldragon.repo.User
import java.time.Instant
@Entity(tableName = "user")
@TypeConverters(Converters::class)
data class UserDB (
@PrimaryKey val id: String,
val updatedAt: Instant,
val name: String?,
val avatar: String?,
val attrs: String,
)
fun UserDB.isExpired(cacheDuration: Long = 60 * 60): Boolean {
return Instant.now().minusSeconds(cacheDuration) > updatedAt
}
fun UserDB.toDomain(): User = User(
id = id,
name = name,
avatarUrl = avatar,
attrs = JSONObject(attrs),
)
fun User.toEntity(): UserDB = UserDB(
id = id,
updatedAt = Instant.now(),
name = name,
avatar = avatarUrl,
attrs = attrs.toString().trim(),
)
@Dao
interface UserDao {
@Query("SELECT * FROM user WHERE id LIKE :id LIMIT 1")
fun getById(id: String): UserDB?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg users: UserDB)
@Delete
fun delete(user: UserDB)
}
@@ -1,62 +1,4 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
*/ */
package ru.risdeveau.pixeldragon.repo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import ru.risdeveau.pixeldragon.api.getJoinedRooms
import ru.risdeveau.pixeldragon.api.getRoom
import ru.risdeveau.pixeldragon.db.cacheDb
import ru.risdeveau.pixeldragon.db.isConnected
import ru.risdeveau.pixeldragon.db.isExpired
import ru.risdeveau.pixeldragon.db.toDomain
import ru.risdeveau.pixeldragon.db.toEntity
class Room (
val id: String,
val name: String?,
val type: String,
val creatorId: String?,
val createTime: Long?,
val avatarUrl: String?,
val members: Int?,
val joined: Boolean,
val direct: User?,
) {
companion object {
suspend fun getById(id: String, cached: Boolean = true): Room {
val cachedRoom = cacheDb.roomDoa().getById(id)
if (!isConnected() and
(!cached or (cachedRoom == null) or (cachedRoom?.isExpired() == true))
) {
val room = getRoom(id)
CoroutineScope(Dispatchers.IO).launch {
val cacheRoom = room.toEntity()
cacheDb.roomDoa().insert(cacheRoom)
}
return room
}
return cachedRoom!!.toDomain()
}
suspend fun getJoined(cached: Boolean = true): List<Room> {
val cacheJoined = cacheDb.roomDoa().getAllJoined()
if (isConnected() and
(!cached or cacheJoined.isEmpty() or (cacheJoined.any { it.isExpired() }))
) {
val rooms = getJoinedRooms()
CoroutineScope(Dispatchers.IO).launch {
val roomsDb = List(rooms.size) { i -> rooms[i].toEntity() }
cacheDb.roomDoa().insert(*roomsDb.toTypedArray())
}
return rooms
}
return List(cacheJoined.size) { i -> cacheJoined[i].toDomain() }
}
}
}
@@ -1,51 +1,4 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
*/ */
package ru.risdeveau.pixeldragon.repo
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONObject
import ru.risdeveau.pixeldragon.api.getUserProfile
import ru.risdeveau.pixeldragon.db.cacheDb
import ru.risdeveau.pixeldragon.db.isConnected
import ru.risdeveau.pixeldragon.db.isExpired
import ru.risdeveau.pixeldragon.db.toDomain
import ru.risdeveau.pixeldragon.db.toEntity
class User (
val id: String,
val name: String?,
val avatarUrl: String?,
val attrs: JSONObject
) {
companion object {
suspend fun getById(id: String, cached: Boolean = true): User? {
val cachedUser = cacheDb.userDoa().getById(id)
if (isConnected() and
(!cached or (cachedUser == null) or (cachedUser?.isExpired() == true))
) {
val userProfile = getUserProfile(id)
if (userProfile == null) {
Log.i("User.getById", "User $id not found")
return null
}
val user = User(
id,
userProfile.displayName,
userProfile.avatarUrl,
userProfile.other
)
CoroutineScope(Dispatchers.IO).launch {
cacheDb.userDoa().insert(user.toEntity())
}
return user
}
return cachedUser!!.toDomain()
}
}
}
@@ -1,99 +1,319 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
*/ */
package ru.risdeveau.pixeldragon.ui.activity package ru.risdeveau.pixeldragon.ui.activity
import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import de.connect2x.trixnity.client.CryptoDriverModule
import de.connect2x.trixnity.client.MatrixClient
import de.connect2x.trixnity.client.create
import de.connect2x.trixnity.client.cryptodriver.vodozemac.vodozemac
import de.connect2x.trixnity.client.flattenValues
import de.connect2x.trixnity.client.room
import de.connect2x.trixnity.client.store.type
import de.connect2x.trixnity.clientserverapi.client.SyncState
import de.connect2x.trixnity.clientserverapi.model.user.avatarUrl
import de.connect2x.trixnity.clientserverapi.model.user.displayName
import de.connect2x.trixnity.core.model.events.m.room.CreateEventContent
import io.github.rabehx.iconsax.Iconsax
import io.github.rabehx.iconsax.automirrored.outline.ArrowLeft2
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.map
import ru.risdeveau.pixeldragon.api.Me import kotlinx.coroutines.launch
import ru.risdeveau.pixeldragon.api.getMe import ru.risdeveau.pixeldragon.R
import ru.risdeveau.pixeldragon.initCheck import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.ui.item.Avatar
import ru.risdeveau.pixeldragon.ui.layout.Room import ru.risdeveau.pixeldragon.ui.layout.Room
import ru.risdeveau.pixeldragon.ui.layout.RoomList import ru.risdeveau.pixeldragon.ui.layout.RoomList
import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme
import ru.risdeveau.pixeldragon.util.getMediaStore
import ru.risdeveau.pixeldragon.util.getRoomStore
import splitties.activities.start import splitties.activities.start
import splitties.init.appCtx
import de.connect2x.trixnity.client.store.Room as TrixnityRoom
var ME: Me? = null
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
PixelDragonTheme { PixelDragonTheme {
Scaffold( var isClientReady by remember { mutableStateOf(false) }
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
colors = topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
),
title = {
Text("Top app bar")
}
)
},
) { innerPadding ->
val navController = rememberNavController()
LaunchedEffect(Unit) { if (!isClientReady || client == null) {
if (initCheck()) { Box(
ME = withContext(Dispatchers.IO) { getMe() } modifier = Modifier.fillMaxSize(),
if (ME != null) { contentAlignment = Alignment.Center,
navController.navigate("rooms") ) {
} else { CircularProgressIndicator()
start<Login>() }
finish() } else {
val navController = rememberNavController()
val syncState by client!!.api.sync.currentSyncState
.collectAsState(initial = SyncState.STOPPED)
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
PixelDragonTopBar(
navController = navController,
syncState = syncState,
)
},
) { innerPadding ->
NavHost(navController = navController, startDestination = "rooms") {
composable("rooms") {
RoomList(Modifier.padding(innerPadding), navController)
} }
} else { composable(
"room/{rid}",
arguments = listOf(navArgument("rid") { type = NavType.StringType })
) { navBackStackEntry ->
Room(Modifier
.padding(innerPadding)
.fillMaxSize(), navBackStackEntry.arguments!!.getString("rid")!!)
}
composable(
"space/{rid}",
arguments = listOf(navArgument("rid") { type = NavType.StringType })
) {
Text(
modifier = Modifier.padding(innerPadding),
text = "Not implemented"
)
}
}
}
}
LaunchedEffect(Unit) {
if (client == null) {
client = MatrixClient.create(
repositoriesModule = getRoomStore(appCtx),
mediaStoreModule = getMediaStore(),
cryptoDriverModule = CryptoDriverModule.vodozemac()
).getOrNull()
if (client == null) {
start<Login>() start<Login>()
finish() finish()
return@LaunchedEffect
} }
} }
NavHost(navController = navController, startDestination = "none") { Log.i("MainActivity", "Log in as ${client!!.userId}")
composable("none") { } client!!.startSync()
composable("rooms") { RoomList(Modifier.padding(innerPadding), navController) } isClientReady = true
composable(
"room/{rid}",
arguments = listOf(navArgument("rid") { type = NavType.StringType })
) { navBackStackEntry ->
Room(Modifier
.padding(innerPadding)
.fillMaxSize(), navBackStackEntry.arguments!!.getString("rid")!!)
}
composable(
"space/{rid}",
arguments = listOf(navArgument("rid") { type = NavType.StringType })
) { navBackStackEntry ->
Text(modifier = Modifier.padding(innerPadding), text = "Not implemented") }
}
} }
} }
} }
} }
override fun onDestroy() {
super.onDestroy()
CoroutineScope(Dispatchers.Main).launch {
client?.stopSync()
}
}
}
@SuppressLint("FlowOperatorInvokedInComposition")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PixelDragonTopBar(
navController: NavHostController,
syncState: SyncState,
) {
val backStackEntry by navController.currentBackStackEntryAsState()
val route = backStackEntry?.destination?.route
val rid = backStackEntry?.arguments?.getString("rid")
val isRoomLikeScreen = route == "room/{rid}" || route == "space/{rid}"
val roomsFlow = remember(client) {
client!!.room.getAll().flattenValues().map { it.toList() }
}
val rooms by roomsFlow.collectAsState(initial = emptyList())
val currentRoom = remember(isRoomLikeScreen, rid, rooms) {
if (isRoomLikeScreen && rid != null) {
rooms.firstOrNull { it.roomId.toString() == rid }
} else {
null
}
}
if (isRoomLikeScreen) {
RoomTopBar(
room = currentRoom,
fallbackTitle = rid ?: stringResource(R.string.app_name),
onBack = { navController.popBackStack() },
)
} else {
HomeTopBar(syncState = syncState)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HomeTopBar(syncState: SyncState) {
CenterAlignedTopAppBar(
colors = topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
),
title = {
Text(syncState.toStatusTitle() ?: stringResource(R.string.app_name))
},
actions = {
val client = client!!
var userName by remember { mutableStateOf("?") }
var userAvatar: String? by remember { mutableStateOf(null) }
LaunchedEffect(client, syncState) {
val profile = client.api.user
.getProfile(client.userId)
.getOrNull()
userName = profile?.displayName ?: "?"
userAvatar = profile?.avatarUrl
}
Avatar(
Modifier.size(32.dp).clip(CircleShape),
userAvatar,
userName,
MaterialTheme.colorScheme.background
)
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomTopBar(
room: TrixnityRoom?,
fallbackTitle: String,
onBack: () -> Unit,
) {
CenterAlignedTopAppBar(
colors = topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface.copy(.5f),
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.primary,
),
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Iconsax.AutoMirrored.Outline.ArrowLeft2,
"To home",
)
}
},
title = {
if (room != null) {
RoomTopBarTitle(room)
} else {
Text(
text = fallbackTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleMedium,
)
}
},
)
}
@Composable
private fun RoomTopBarTitle(room: TrixnityRoom) {
val title = remember(room) { room.displayName() }
val isSpace = room.type == CreateEventContent.RoomType.Space
val avatarShape = if (isSpace) RoundedCornerShape(8.dp) else CircleShape
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
Modifier
.size(36.dp)
.clip(avatarShape),
room.avatarUrl,
title
)
Spacer(Modifier.width(10.dp))
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleMedium,
)
}
}
private fun TrixnityRoom.displayName(): String {
return name?.explicitName
?: name?.heroes?.firstNotNullOfOrNull { it.localpart }
?: roomId.toString()
}
private fun SyncState.toStatusTitle(): String? {
return when (this) {
SyncState.INITIAL_SYNC -> "Initial sync..."
SyncState.STARTED -> "Syncing..."
SyncState.TIMEOUT -> "No network connection"
SyncState.ERROR -> "Error syncing"
SyncState.RUNNING,
SyncState.STOPPED -> null
}
} }
@@ -0,0 +1,65 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon.ui.item
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.layout.ContentScale
@Composable
fun Avatar(
modifier: Modifier,
url: String?,
fallbackName: String,
fallbackColor: Color = MaterialTheme.colorScheme.secondaryContainer,
) {
if (url != null) {
MXCImage(
mxcUrl = url,
modifier = modifier,
contentScale = ContentScale.Crop,
contentDescription = fallbackName,
showProgress = false,
)
} else {
AvatarPlaceholder(
modifier,
fallbackName,
fallbackColor,
)
}
}
@Composable
private fun AvatarPlaceholder(
modifier: Modifier = Modifier,
fallbackName: String,
fallbackColor: Color,
) {
val initial = fallbackName
.trim()
.firstOrNull()
?.uppercaseChar()
?.toString()
?: "?"
Box(
modifier = modifier.background(fallbackColor),
contentAlignment = Alignment.Center,
) {
Text(
text = initial,
color = MaterialTheme.colorScheme.onSecondaryContainer,
style = MaterialTheme.typography.titleMedium,
)
}
}
@@ -1,6 +1,6 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
*/ */
package ru.risdeveau.pixeldragon.ui.item package ru.risdeveau.pixeldragon.ui.item
@@ -8,25 +8,27 @@ package ru.risdeveau.pixeldragon.ui.item
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons import io.github.rabehx.iconsax.Iconsax
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImagePainter import androidx.compose.ui.platform.LocalContext
import coil3.compose.rememberAsyncImagePainter import coil3.compose.rememberAsyncImagePainter
import coil3.network.NetworkHeaders
import coil3.network.httpHeaders
import coil3.request.ImageRequest import coil3.request.ImageRequest
import ru.risdeveau.pixeldragon.api.mxcToUrl import kotlinx.coroutines.flow.MutableStateFlow
import ru.risdeveau.pixeldragon.token import de.connect2x.trixnity.client.media
import splitties.init.appCtx import de.connect2x.trixnity.clientserverapi.model.media.FileTransferProgress
import io.github.rabehx.iconsax.outline.Warning2
import ru.risdeveau.pixeldragon.client
enum class ImageLoadState { enum class ImageLoadState {
Loading, Success, Error Loading, Success, Error
@@ -37,44 +39,90 @@ fun MXCImage(
mxcUrl: String, mxcUrl: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit, contentScale: ContentScale = ContentScale.Fit,
contentDescription: String = "" contentDescription: String = "",
showProgress: Boolean = true
) { ) {
mxcToUrl(mxcUrl)?.let { url -> val context = LocalContext.current
val loadState = remember { mutableStateOf(ImageLoadState.Loading) } var imageLoadState by remember { mutableStateOf(ImageLoadState.Loading) }
val painter = rememberAsyncImagePainter( var imageBytes by remember { mutableStateOf<ByteArray?>(null) }
model = ImageRequest.Builder(appCtx)
.data(url) val progressFlow = remember { MutableStateFlow<FileTransferProgress?>(null) }
.httpHeaders(
NetworkHeaders.Builder() LaunchedEffect(mxcUrl) {
.set("Authorization", "Bearer $token") if (mxcUrl.isBlank()) {
.set("Cache-Control", "max-age=86400") imageLoadState = ImageLoadState.Error
.build() return@LaunchedEffect
) }
.build(), imageLoadState = ImageLoadState.Loading
onState = { state -> progressFlow.value = null
loadState.value = when (state) {
is AsyncImagePainter.State.Loading -> ImageLoadState.Loading val result = client!!.media.getMedia(
is AsyncImagePainter.State.Success -> ImageLoadState.Success uri = mxcUrl,
else -> ImageLoadState.Error progress = progressFlow.takeIf { showProgress },
} saveToCache = true
}
) )
Box( imageLoadState = result.fold(
modifier = modifier.fillMaxSize(), onSuccess = { media ->
contentAlignment = Alignment.Center val bytes = media.toByteArray()
) { if (bytes != null) {
imageBytes = bytes
ImageLoadState.Success
} else {
ImageLoadState.Error
}
},
onFailure = {
ImageLoadState.Error
}
)
}
val progress by progressFlow.collectAsState()
val showProgressIndicator = showProgress &&
imageLoadState == ImageLoadState.Loading &&
progress != null
val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context)
.data(imageBytes)
.build()
)
Box(modifier = modifier, contentAlignment = Alignment.Center) {
if (imageBytes != null) {
Image( Image(
painter = painter, painter = painter,
contentDescription = contentDescription, contentDescription = contentDescription,
contentScale = contentScale, contentScale = contentScale,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
}
when (loadState.value) { when {
ImageLoadState.Loading -> CircularProgressIndicator() showProgressIndicator -> {
ImageLoadState.Error -> Icon(Icons.Outlined.Warning, "Error") progress?.let { p ->
ImageLoadState.Success -> Unit val percent = p.total?.let { p.transferred.toFloat() / it }
if (percent == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
CircularProgressIndicator(
progress = percent,
modifier = Modifier.align(Alignment.Center)
)
}
} ?: CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
imageLoadState == ImageLoadState.Loading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
imageLoadState == ImageLoadState.Error -> {
Icon(
Iconsax.Outline.Warning2,
contentDescription = "Error",
modifier = Modifier.align(Alignment.Center)
)
} }
} }
} }
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,12 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
*/ */
package ru.risdeveau.pixeldragon.ui.layout package ru.risdeveau.pixeldragon.ui.layout
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -21,24 +23,27 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import de.connect2x.trixnity.client.flattenValues
import ru.risdeveau.pixeldragon.repo.Room import de.connect2x.trixnity.client.room
import de.connect2x.trixnity.client.store.Room
import de.connect2x.trixnity.client.store.type
import de.connect2x.trixnity.core.model.events.m.room.CreateEventContent
import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.ui.item.MXCImage import ru.risdeveau.pixeldragon.ui.item.MXCImage
@SuppressLint("FlowOperatorInvokedInComposition")
@Composable @Composable
fun RoomList(modifier: Modifier = Modifier, navController: NavController) { fun RoomList(modifier: Modifier = Modifier, navController: NavController) {
var list by remember { mutableStateOf(listOf<Room>()) } val rooms by client!!.room.getAll().flattenValues().map { it.toList() }.collectAsState(initial = emptyList())
val listState = rememberLazyListState() val listState = rememberLazyListState()
// if (itemState.scrollToTop) { // if (itemState.scrollToTop) {
@@ -48,17 +53,17 @@ fun RoomList(modifier: Modifier = Modifier, navController: NavController) {
// } // }
// } // }
LaunchedEffect(Unit) { // LaunchedEffect(Unit) {
list = withContext(Dispatchers.IO) { Room.getJoined() } // list = withContext(Dispatchers.IO) { Room.getJoined() }
} // }
LazyColumn(modifier = modifier, state = listState) { LazyColumn(modifier = modifier, state = listState) {
items(list) { room -> items(rooms) { room ->
RoomItem(room = room, navController = navController ) RoomItem(room = room, navController = navController )
} }
item { item {
if (list.isEmpty()) { if (rooms.isEmpty()) {
Text("You have no rooms") Text("You have no rooms")
} }
} }
@@ -67,8 +72,9 @@ fun RoomList(modifier: Modifier = Modifier, navController: NavController) {
@Composable @Composable
fun RoomItem(modifier: Modifier = Modifier, room: Room, navController: NavController) { fun RoomItem(modifier: Modifier = Modifier, room: Room, navController: NavController) {
val avatarUrl = room.avatarUrl ?: (room.direct?.avatarUrl ?: "") val name = room.name
val name = room.name ?: (room.direct?.name ?: "Unnamed") val isSpace = room.type == CreateEventContent.RoomType.Space
Row( Row(
modifier modifier
@@ -77,42 +83,46 @@ fun RoomItem(modifier: Modifier = Modifier, room: Room, navController: NavContro
.fillMaxWidth() .fillMaxWidth()
.background( .background(
color = color =
if (room.type == "m.space") if (isSpace)
MaterialTheme.colorScheme.tertiary MaterialTheme.colorScheme.tertiary
else else
MaterialTheme.colorScheme.background MaterialTheme.colorScheme.background
) )
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.clickable { .clickable {
if (room.type == "m.space") if (isSpace)
navController.navigate("space/${room.id}") navController.navigate("space/${room.roomId}")
else else
navController.navigate("room/${room.id}") navController.navigate("room/${room.roomId}")
} }
.padding(8.dp) .padding(8.dp)
) { ) {
MXCImage( Log.v("RoomItem", room.avatarUrl.toString())
avatarUrl, room.avatarUrl?.let { mxc ->
modifier = Modifier MXCImage(
.padding(end = 4.dp) mxc,
.height(52.dp) modifier = Modifier
.width(52.dp) .padding(end = 4.dp)
.let { .height(52.dp)
if (room.type == "m.space") .width(52.dp)
it.clip(RoundedCornerShape(12.dp)) .let {
else if (isSpace)
it.clip(CircleShape) it.clip(RoundedCornerShape(12.dp))
}, else
ContentScale.Crop it.clip(CircleShape)
) },
Column { ContentScale.Crop
Text(room.type)
Text(
name,
maxLines = 1,
color = MaterialTheme.colorScheme.primary,
fontSize = MaterialTheme.typography.titleLarge.fontSize
) )
} }
Column {
(name?.explicitName ?: name?.heroes?.firstNotNullOf {it.localpart})?.let {
Text(
it,
maxLines = 1,
color = MaterialTheme.colorScheme.primary,
fontSize = MaterialTheme.typography.titleLarge.fontSize
)
}
}
} }
} }
@@ -0,0 +1,24 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon.util
import android.content.Context
import androidx.room.Room
import de.connect2x.trixnity.client.MediaStoreModule
import de.connect2x.trixnity.client.RepositoriesModule
import de.connect2x.trixnity.client.media.okio.okio
import de.connect2x.trixnity.client.store.repository.room.TrixnityRoomDatabase
import de.connect2x.trixnity.client.store.repository.room.room
import okio.Path.Companion.toPath
import splitties.init.appCtx
fun getMediaStore() = MediaStoreModule.okio(appCtx.filesDir.resolve("media").absolutePath.toPath())
fun getRoomStore(context: Context) = RepositoriesModule.room(
databaseBuilder = Room.databaseBuilder<TrixnityRoomDatabase>(
context,
"trixnity.db"
)
)
+3 -5
View File
@@ -1,7 +1,6 @@
/* /*
* Created by sweetbread on 22.02.2025, 15:45 * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
* Last modified 21.02.2025, 12:21
*/ */
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
@@ -9,5 +8,4 @@ plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false }
}
+18 -12
View File
@@ -1,20 +1,23 @@
[versions] [versions]
agp = "8.13.0" agp = "9.1.0"
coil = "3.1.0" coil = "3.4.0"
jsoup = "1.20.1" iconsaxCompose = "0.0.5"
kotlin = "2.0.21" jsoup = "1.22.1"
kotlin = "2.2.21"
coreKtx = "1.15.0" coreKtx = "1.15.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.2.1" junitVersion = "1.3.0"
espressoCore = "3.6.1" espressoCore = "3.7.0"
kotlinxSerializationJson = "1.7.3" kotlinxSerializationJson = "1.7.3"
ktor = "3.1.0" ktor = "3.4.1"
lifecycleRuntimeKtx = "2.8.7" lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.10.0" activityCompose = "1.10.0"
composeBom = "2025.02.00" composeBom = "2026.03.00"
navigationCompose = "2.8.8" navigationCompose = "2.9.7"
room = "2.6.1" room = "2.6.1"
splittiesFunPackAndroidBase = "3.0.0" splittiesFunPackAndroidBase = "3.0.0"
trixnityClient = "5.2.0"
uiUnit = "1.10.6"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -24,11 +27,11 @@ androidx-navigation-dynamic-features-fragment = { module = "androidx.navigation:
androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment", version.ref = "navigationCompose" } androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment", version.ref = "navigationCompose" }
androidx-navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "navigationCompose" } androidx-navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "navigationCompose" }
androidx-navigation-ui = { module = "androidx.navigation:navigation-ui", version.ref = "navigationCompose" } androidx-navigation-ui = { module = "androidx.navigation:navigation-ui", version.ref = "navigationCompose" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
iconsax-compose = { module = "io.github.rabehx:iconsax-compose", version.ref = "iconsaxCompose" }
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
@@ -43,14 +46,17 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
splitties-base = { module = "com.louiscad.splitties:splitties-fun-pack-android-base", version.ref = "splittiesFunPackAndroidBase" } splitties-base = { module = "com.louiscad.splitties:splitties-fun-pack-android-base", version.ref = "splittiesFunPackAndroidBase" }
trixnity-client = { module = "de.connect2x.trixnity:trixnity-client", version.ref = "trixnityClient" }
trixnity-client-media-okio = { module = "de.connect2x.trixnity:trixnity-client-media-okio", version.ref = "trixnityClient" }
trixnity-client-repository-room = { module = "de.connect2x.trixnity:trixnity-client-repository-room", version.ref = "trixnityClient" }
trixnity-client-cryptodriver-vodozemac = { module = "de.connect2x.trixnity:trixnity-client-cryptodriver-vodozemac", version.ref = "trixnityClient" }
androidx-compose-ui-unit = { group = "androidx.compose.ui", name = "ui-unit", version.ref = "uiUnit" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+1 -1
View File
@@ -6,6 +6,6 @@
#Thu Feb 20 10:45:47 GMT 2025 #Thu Feb 20 10:45:47 GMT 2025
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
+6 -3
View File
@@ -1,7 +1,6 @@
/* /*
* Created by sweetbread on 21.02.2025, 12:00 * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
* Last modified 21.02.2025, 12:00
*/ */
pluginManagement { pluginManagement {
@@ -17,11 +16,15 @@ pluginManagement {
gradlePluginPortal() gradlePluginPortal()
} }
} }
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
maven("https://gitlab.com/api/v4/projects/47538655/packages/maven")
} }
} }