wip: migrate to Trixnity

This commit is contained in:
2026-02-20 09:41:06 +03:00
parent b6e8c73758
commit 6adb462226
24 changed files with 739 additions and 1041 deletions
+12 -4
View File
@@ -1,13 +1,13 @@
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Copyright (c) 2026. All rights reserved.
*/
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
id("com.google.devtools.ksp")
// alias(libs.plugins.ksp)
}
android {
@@ -16,7 +16,7 @@ android {
defaultConfig {
applicationId = "ru.risdeveau.pixeldragon"
minSdk = 26
minSdk = 28
targetSdk = 35
versionCode = 1
versionName = "1.0"
@@ -62,6 +62,13 @@ dependencies {
debugImplementation(libs.androidx.ui.tooling)
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
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
@@ -75,7 +82,7 @@ dependencies {
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
// ksp(libs.androidx.room.compiler)
// Navigation Compose
implementation(libs.androidx.navigation.compose)
@@ -87,4 +94,5 @@ dependencies {
// Others
implementation(libs.splitties.base) // Syntax sugar
implementation(libs.jsoup) // HTML parser
}
@@ -1,20 +1,19 @@
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon
import android.util.Log
import de.connect2x.trixnity.client.MatrixClient
import io.ktor.client.HttpClient
import io.ktor.client.plugins.cache.HttpCache
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
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) {
logger = object : Logger {
override fun log(message: String) {
@@ -27,24 +26,4 @@ val client = HttpClient {
install(HttpCache)
}
lateinit var homeserver: String
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
}
var client: MatrixClient? = null
@@ -1,56 +1,50 @@
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Last modified 03.03.2025, 20:21
* 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.request.parameter
import io.ktor.client.statement.bodyAsText
import org.json.JSONObject
import ru.risdeveau.pixeldragon.baseUrl
import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.token
import java.time.Instant
class Event (
val id: String,
val rid: String,
val sender: String,
val type: String,
val content: JSONObject
val content: JSONObject,
val time: Instant,
val sender: String
) {
constructor(json: JSONObject) : this(
json.getString("event_id"),
json.getString("room_id"),
json.getString("sender"),
json.getString("type"),
json.getJSONObject("content")
)
// constructor(json: JSONObject) : this(
// json.getString("event_id"),
// json.getString("room_id"),
// json.getString("sender"),
// json.getString("type"),
// json.getJSONObject("content")
// )
}
data class EventsAround (
val base: Event,
val before: List<Event>,
val after: List<Event>
)
suspend fun getEventsAround(room: String, event: String): EventsAround {
val r = client.get("$baseUrl/rooms/$room/context/$event") {
bearerAuth(token)
parameter("limit", "50")
}
val json = JSONObject(r.bodyAsText())
return EventsAround(
Event(json.getJSONObject("event")),
if (json.has("events_before")) json.getJSONArray("events_before").let {
List<Event>(it.length()) { i -> Event(it.getJSONObject(i))}.reversed()
} else listOf(),
if (json.has("events_after")) json.getJSONArray("events_after").let {
List<Event>(it.length()) { i -> Event(it.getJSONObject(i))}
} else listOf()
)
}
//data class EventsAround (
// val base: Event,
// val before: List<Event>,
// val after: List<Event>
//)
//
//suspend fun getEventsAround(room: String, event: String): EventsAround {
// val r = client.get("$baseUrl/rooms/$room/context/$event") {
// bearerAuth(token)
// parameter("limit", "50")
// }
// val json = JSONObject(r.bodyAsText())
//
// return EventsAround(
// Event(json.getJSONObject("event")),
// if (json.has("events_before")) json.getJSONArray("events_before").let {
// List<Event>(it.length()) { i -> Event(it.getJSONObject(i))}.reversed()
// } else listOf(),
// if (json.has("events_after")) json.getJSONArray("events_after").let {
// List<Event>(it.length()) { i -> Event(it.getJSONObject(i))}
// } else listOf()
// )
//}
@@ -1,97 +1,4 @@
/*
* 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
* Copyright (c) 2025. All rights reserved.
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon.api
import android.util.Log
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import org.json.JSONException
import org.json.JSONObject
import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.homeserver
import ru.risdeveau.pixeldragon.webClient
//import ru.risdeveau.pixeldragon.homeserver
suspend fun getHomeserver(url: String): String? {
val r = try { client.get("https://$url/.well-known/matrix/client") }
catch (_: Exception) { return null }
val r = try { webClient.get("https://$url/.well-known/matrix/client") }
catch (e: Exception) {
Log.w("getHomeserver", "Fail sending the request", e)
return null
}
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
@@ -27,11 +34,11 @@ suspend fun getHomeserver(url: String): String? {
return homeserver
}
fun mxcToUrl(mxc: String): String? {
val pattern = Regex("mxc://([-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6})/([^#?]+)")
val match = pattern.find(mxc)
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]}"
}
//fun mxcToUrl(mxc: String): String? {
// val pattern = Regex("mxc://([-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6})/([^#?]+)")
// val match = pattern.find(mxc)
//
// 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]}"
//}
@@ -1,100 +1,49 @@
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon.api
import android.util.Log
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import org.json.JSONObject
import ru.risdeveau.pixeldragon.AccountData
import ru.risdeveau.pixeldragon.baseUrl
import de.connect2x.trixnity.client.CryptoDriverModule
import io.ktor.http.Url
import de.connect2x.trixnity.client.MatrixClient
import de.connect2x.trixnity.client.create
import de.connect2x.trixnity.client.cryptodriver.vodozemac.vodozemac
import de.connect2x.trixnity.clientserverapi.client.MatrixClientAuthProviderData
import de.connect2x.trixnity.clientserverapi.client.classicLogin
import de.connect2x.trixnity.clientserverapi.model.authentication.IdentifierType
import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.initCheck
import ru.risdeveau.pixeldragon.token
import ru.risdeveau.pixeldragon.util.getMediaStore
import ru.risdeveau.pixeldragon.util.getRoomStore
import splitties.experimental.ExperimentalSplittiesApi
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)
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 pattern = """
{
"type": "m.login.password",
"identifier": {
"type": "m.id.user"
},
"initial_device_display_name": "PixelDragon Android v${pinfo.versionName}"
}
""".trimIndent()
val json = JSONObject(pattern)
json.getJSONObject("identifier").put("user", login)
json.put("password", pass)
try {
client = MatrixClient.create(
repositoriesModule = getRoomStore(appCtx),
mediaStoreModule = getMediaStore(),
cryptoDriverModule = CryptoDriverModule.vodozemac(),
authProviderData = MatrixClientAuthProviderData.classicLogin(
baseUrl = hs,
identifier = IdentifierType.User(login),
password = pass,
initialDeviceDisplayName = "PixelDragon Android v${pinfo.versionName}",
).getOrThrow()
).getOrThrow()
val r = try {
client.post("$hs/_matrix/client/v3/login") {
setBody(json.toString())
contentType(ContentType.Application.Json)
}
return true
} catch (e: Exception) {
Log.e("login", e.toString())
return false
Log.i("Login", "Failed to login", e)
}
if (r.status != HttpStatusCode.OK) {
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)
return false
}
+4
View File
@@ -0,0 +1,4 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
@@ -1,41 +1,4 @@
/*
* Created by sweetbread
* Copyright (c) 2025. 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()
* Copyright (c) 2026. All rights reserved.
*/
@@ -1,101 +1,4 @@
/*
* Created by sweetbread
* Copyright (c) 2025. 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>
)
* Copyright (c) 2026. All rights reserved.
*/
@@ -1,60 +1,4 @@
/*
* Created by sweetbread
* Copyright (c) 2025. 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)
}
* Copyright (c) 2026. All rights reserved.
*/
@@ -1,62 +1,4 @@
/*
* Created by sweetbread
* Copyright (c) 2025. 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() }
}
}
}
* Copyright (c) 2026. All rights reserved.
*/
@@ -1,51 +1,4 @@
/*
* Created by sweetbread
* Copyright (c) 2025. 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()
}
}
}
* Copyright (c) 2026. All rights reserved.
*/
@@ -1,40 +1,53 @@
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon.ui.activity
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
import androidx.compose.runtime.LaunchedEffect
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.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import de.connect2x.trixnity.client.CryptoDriverModule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.risdeveau.pixeldragon.api.Me
import ru.risdeveau.pixeldragon.api.getMe
import ru.risdeveau.pixeldragon.initCheck
import ru.risdeveau.pixeldragon.ui.layout.Room
import kotlinx.coroutines.launch
import de.connect2x.trixnity.client.MatrixClient
import de.connect2x.trixnity.client.create
import de.connect2x.trixnity.client.cryptodriver.vodozemac.vodozemac
import de.connect2x.trixnity.clientserverapi.client.SyncState
import ru.risdeveau.pixeldragon.R
import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.ui.layout.RoomList
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.init.appCtx
import splitties.resources.str
var ME: Me? = null
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
@@ -44,56 +57,87 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
PixelDragonTheme {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
colors = topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
),
title = {
Text("Top app bar")
}
)
},
) { innerPadding ->
val navController = rememberNavController()
var isClientReady by remember { mutableStateOf(false) }
val syncState by remember { mutableStateOf(SyncState.STOPPED) }
LaunchedEffect(Unit) {
if (initCheck()) {
ME = withContext(Dispatchers.IO) { getMe() }
if (ME != null) {
navController.navigate("rooms")
} else {
start<Login>()
finish()
if (!isClientReady) {
CircularProgressIndicator()
} else {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
CenterAlignedTopAppBar(
colors = topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
),
title = {
when (syncState) {
SyncState.STARTED -> Text("Syncing...")
SyncState.INITIAL_SYNC -> Text("Initial sync...")
SyncState.STOPPED,
SyncState.RUNNING -> Text(str(R.string.app_name))
SyncState.TIMEOUT -> Text("No network connection")
SyncState.ERROR -> Text("Error syncing")
}
}
)
},
) { innerPadding ->
val navController = rememberNavController()
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 })
) { navBackStackEntry ->
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>()
finish()
return@LaunchedEffect
}
}
NavHost(navController = navController, startDestination = "none") {
composable("none") { }
composable("rooms") { RoomList(Modifier.padding(innerPadding), navController) }
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") }
}
Log.i("MainActivity", "Log in as ${client!!.userId}")
client!!.startSync()
isClientReady = true
}
}
}
}
override fun onDestroy() {
super.onDestroy()
CoroutineScope(Dispatchers.Main).launch {
client?.stopSync()
}
}
}
@@ -1,6 +1,6 @@
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon.ui.item
@@ -13,20 +13,22 @@ import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImagePainter
import androidx.compose.ui.platform.LocalContext
import coil3.compose.rememberAsyncImagePainter
import coil3.network.NetworkHeaders
import coil3.network.httpHeaders
import coil3.request.ImageRequest
import ru.risdeveau.pixeldragon.api.mxcToUrl
import ru.risdeveau.pixeldragon.token
import splitties.init.appCtx
import kotlinx.coroutines.flow.MutableStateFlow
import de.connect2x.trixnity.client.media
import de.connect2x.trixnity.clientserverapi.model.media.FileTransferProgress
import ru.risdeveau.pixeldragon.client
enum class ImageLoadState {
Loading, Success, Error
@@ -37,44 +39,90 @@ fun MXCImage(
mxcUrl: String,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
contentDescription: String = ""
contentDescription: String = "",
showProgress: Boolean = true
) {
mxcToUrl(mxcUrl)?.let { url ->
val loadState = remember { mutableStateOf(ImageLoadState.Loading) }
val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(appCtx)
.data(url)
.httpHeaders(
NetworkHeaders.Builder()
.set("Authorization", "Bearer $token")
.set("Cache-Control", "max-age=86400")
.build()
)
.build(),
onState = { state ->
loadState.value = when (state) {
is AsyncImagePainter.State.Loading -> ImageLoadState.Loading
is AsyncImagePainter.State.Success -> ImageLoadState.Success
else -> ImageLoadState.Error
}
}
val context = LocalContext.current
var imageLoadState by remember { mutableStateOf(ImageLoadState.Loading) }
var imageBytes by remember { mutableStateOf<ByteArray?>(null) }
val progressFlow = remember { MutableStateFlow<FileTransferProgress?>(null) }
LaunchedEffect(mxcUrl) {
if (mxcUrl.isBlank()) {
imageLoadState = ImageLoadState.Error
return@LaunchedEffect
}
imageLoadState = ImageLoadState.Loading
progressFlow.value = null
val result = client!!.media.getMedia(
uri = mxcUrl,
progress = progressFlow.takeIf { showProgress },
saveToCache = true
)
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
imageLoadState = result.fold(
onSuccess = { media ->
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(
painter = painter,
contentDescription = contentDescription,
contentScale = contentScale,
modifier = Modifier.fillMaxSize()
)
}
when (loadState.value) {
ImageLoadState.Loading -> CircularProgressIndicator()
ImageLoadState.Error -> Icon(Icons.Outlined.Warning, "Error")
ImageLoadState.Success -> Unit
when {
showProgressIndicator -> {
progress?.let { p ->
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(
Icons.Outlined.Warning,
contentDescription = "Error",
modifier = Modifier.align(Alignment.Center)
)
}
}
}
@@ -1,342 +1,342 @@
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
*/
package ru.risdeveau.pixeldragon.ui.layout
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.jsoup.safety.Safelist
import ru.risdeveau.pixeldragon.api.Event
import ru.risdeveau.pixeldragon.api.getAccountData
import ru.risdeveau.pixeldragon.api.getEventsAround
import ru.risdeveau.pixeldragon.ui.activity.ME
import ru.risdeveau.pixeldragon.ui.item.MXCImage
@Composable
fun Room(modifier: Modifier = Modifier, rid: String) {
var eventsId by remember { mutableStateOf(listOf<Event>()) }
val listState = rememberLazyListState()
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
val readMark = getAccountData(ME!!.userId, rid, "m.fully_read")
val eventsAround = getEventsAround(rid, readMark!!.getString("event_id")) //FIXME: Null check
eventsId = eventsAround.let {
it.before + listOf(it.base) + it.after
}
}
}
LazyColumn(modifier = modifier, state = listState, reverseLayout = true) {
items(eventsId.reversed()) { event ->
EventItem(event)
}
item {
if (eventsId.isEmpty()) {
Text("Empty room")
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventItem(event: Event) {
Box (Modifier.fillMaxWidth()) {
when (event.type) {
"m.room.message" -> Column(
Modifier
.fillMaxSize()
.then(
if (event.sender != ME!!.userId)
Modifier.padding(end = 16.dp)
else
Modifier.padding(start = 16.dp)
)
.padding(4.dp)
.background(
if (event.sender != ME?.userId)
MaterialTheme.colorScheme.surfaceContainer
else
MaterialTheme.colorScheme.primaryContainer
)
.clip(RoundedCornerShape(16.dp))
.padding(4.dp)
) {
Text(event.sender, maxLines = 1, fontWeight = FontWeight.Bold)
when (val msgtype = event.content.optString("msgtype", null)) {
"m.text" -> when (event.content.optString("format")) {
"org.matrix.custom.html" -> {
if (event.content.getString("body") == event.content.getString("formatted_body"))
Text(event.content.getString("body"))
HtmlRenderer(event.content.getString("formatted_body"))
}
else -> Text(event.content.getString("body"))
}
"m.image" ->
MXCImage(event.content.getString("url"), Modifier.fillMaxWidth(.9f))
null -> Text(event.content.toString(2))
else -> Text("Unknown type: $msgtype", color = MaterialTheme.colorScheme.error)
}
}
else -> Text(event.type,
Modifier
.fillMaxHeight()
.padding(4.dp)
.background(MaterialTheme.colorScheme.errorContainer)
.padding(4.dp)
)
}
}
}
private fun String.sanitizeHTML(): String {
val matrixSafelist = Safelist()
.addTags(
"h1", "h2", "h3", "h4", "h5", "h6",
"b", "i", "u", "strong", "s", "del",
"sup", "sub", "code",
"table", "thead", "tbody",
"tr", "th", "td", "ul", "ol", "li",
"blockquote", "details", "summary",
"em", "code", "div", "pre", "span", "img"
)
// .addAttributes("span",
// "data-mx-bg-color", "data-mx-color",
// "data-mx-spoiler", "data-mx-maths"
///*
// * Created by sweetbread
// * Copyright (c) 2025. All rights reserved.
// */
//
//package ru.risdeveau.pixeldragon.ui.layout
//
//import android.content.Context
//import android.content.Intent
//import android.net.Uri
//import android.webkit.WebResourceRequest
//import android.webkit.WebView
//import android.webkit.WebViewClient
//import androidx.compose.foundation.background
//import androidx.compose.foundation.layout.Box
//import androidx.compose.foundation.layout.Column
//import androidx.compose.foundation.layout.fillMaxHeight
//import androidx.compose.foundation.layout.fillMaxSize
//import androidx.compose.foundation.layout.fillMaxWidth
//import androidx.compose.foundation.layout.padding
//import androidx.compose.foundation.lazy.LazyColumn
//import androidx.compose.foundation.lazy.items
//import androidx.compose.foundation.lazy.rememberLazyListState
//import androidx.compose.foundation.shape.RoundedCornerShape
//import androidx.compose.material3.ExperimentalMaterial3Api
//import androidx.compose.material3.MaterialTheme
//import androidx.compose.material3.Text
//import androidx.compose.runtime.Composable
//import androidx.compose.runtime.DisposableEffect
//import androidx.compose.runtime.LaunchedEffect
//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.draw.clip
//import androidx.compose.ui.graphics.Color
//import androidx.compose.ui.graphics.toArgb
//import androidx.compose.ui.platform.LocalContext
//import androidx.compose.ui.text.font.FontWeight
//import androidx.compose.ui.unit.dp
//import androidx.compose.ui.viewinterop.AndroidView
//import kotlinx.coroutines.Dispatchers
//import kotlinx.coroutines.withContext
//import org.jsoup.Jsoup
//import org.jsoup.safety.Safelist
//import ru.risdeveau.pixeldragon.api.Event
//import ru.risdeveau.pixeldragon.api.getAccountData
//import ru.risdeveau.pixeldragon.api.getEventsAround
//import ru.risdeveau.pixeldragon.ui.activity.ME
//import ru.risdeveau.pixeldragon.ui.item.MXCImage
//
//@Composable
//fun Room(modifier: Modifier = Modifier, rid: String) {
// var eventsId by remember { mutableStateOf(listOf<Event>()) }
// val listState = rememberLazyListState()
//
// LaunchedEffect(Unit) {
// withContext(Dispatchers.IO) {
// val readMark = getAccountData(ME!!.userId, rid, "m.fully_read")
// val eventsAround = getEventsAround(rid, readMark!!.getString("event_id")) //FIXME: Null check
// eventsId = eventsAround.let {
// it.before + listOf(it.base) + it.after
// }
// }
// }
//
// LazyColumn(modifier = modifier, state = listState, reverseLayout = true) {
// items(eventsId.reversed()) { event ->
// EventItem(event)
// }
//
// item {
// if (eventsId.isEmpty()) {
// Text("Empty room")
// }
// }
// }
//}
//
//@OptIn(ExperimentalMaterial3Api::class)
//@Composable
//fun EventItem(event: Event) {
// Box (Modifier.fillMaxWidth()) {
// when (event.type) {
// "m.room.message" -> Column(
// Modifier
// .fillMaxSize()
// .then(
// if (event.sender != ME!!.userId)
// Modifier.padding(end = 16.dp)
// else
// Modifier.padding(start = 16.dp)
// )
// .padding(4.dp)
// .background(
// if (event.sender != ME?.userId)
// MaterialTheme.colorScheme.surfaceContainer
// else
// MaterialTheme.colorScheme.primaryContainer
// )
// .clip(RoundedCornerShape(16.dp))
// .padding(4.dp)
// ) {
// Text(event.sender, maxLines = 1, fontWeight = FontWeight.Bold)
//
// when (val msgtype = event.content.optString("msgtype", null)) {
// "m.text" -> when (event.content.optString("format")) {
// "org.matrix.custom.html" -> {
// if (event.content.getString("body") == event.content.getString("formatted_body"))
// Text(event.content.getString("body"))
// HtmlRenderer(event.content.getString("formatted_body"))
// }
//
// else -> Text(event.content.getString("body"))
// }
//
// "m.image" ->
// MXCImage(event.content.getString("url"), Modifier.fillMaxWidth(.9f))
//
// null -> Text(event.content.toString(2))
//
// else -> Text("Unknown type: $msgtype", color = MaterialTheme.colorScheme.error)
// }
//
// }
//
// else -> Text(event.type,
// Modifier
// .fillMaxHeight()
// .padding(4.dp)
// .background(MaterialTheme.colorScheme.errorContainer)
// .padding(4.dp)
// )
// }
// }
//}
//
//private fun String.sanitizeHTML(): String {
// val matrixSafelist = Safelist()
// .addTags(
// "h1", "h2", "h3", "h4", "h5", "h6",
// "b", "i", "u", "strong", "s", "del",
// "sup", "sub", "code",
// "table", "thead", "tbody",
// "tr", "th", "td", "ul", "ol", "li",
// "blockquote", "details", "summary",
// "em", "code", "div", "pre", "span", "img"
// )
.addAttributes("a",
"target", "href"
)
.addAttributes("img",
"width", "height", "alt", "title", "src"
)
.addAttributes("ol", "start")
.addAttributes("code", "class")
.addAttributes("div", "data-mx-maths")
val doc = Jsoup.parse(this)
doc.select("mx-reply").remove()
val out = Jsoup.clean(doc.body().toString(), matrixSafelist)
return out
}
@Composable
fun HtmlRenderer(
htmlContent: String,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val webView = remember { WebView(context).apply {
settings.apply {
javaScriptEnabled = false
loadWithOverviewMode = true
useWideViewPort = true
}
isVerticalScrollBarEnabled = false
setBackgroundColor(Color.Transparent.toArgb())
} }
val css = """
body {
font-family: -apple-system, sans-serif;
font-size: 16px;
line-height: 1.6;
color: ${colorToCss(MaterialTheme.colorScheme.onBackground)};
margin: 0;
padding: 0;
}
h1, h2, h3, h4, h5, h6 {
font-weight: bold;
padding: 0;
}
h1 { font-size: 24px; }
h2 { font-size: 22px; }
h3 { font-size: 20px; }
h4 { font-size: 18px; }
h5 { font-size: 16px; }
h6 { font-size: 14px; }
a {
color: ${colorToCss(MaterialTheme.colorScheme.primary)};
text-decoration: none;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 12px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
th, td {
border: 1px solid ${colorToCss(MaterialTheme.colorScheme.outline)};
padding: 12px;
text-align: left;
}
th {
background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)};
font-weight: bold;
}
blockquote {
border-left: 4px solid ${colorToCss(MaterialTheme.colorScheme.primary)};
padding-left: 16px;
margin-left: 0;
color: ${colorToCss(MaterialTheme.colorScheme.onSurfaceVariant)};
}
pre {
background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)};
padding: 16px;
overflow: auto;
border-radius: 4px;
}
code {
font-family: monospace;
background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)};
padding: 2px 4px;
border-radius: 4px;
}
hr {
border: 0;
height: 1px;
background-color: ${colorToCss(MaterialTheme.colorScheme.outline)};
margin: 24px 0;
}
ul, ol {
padding-left: 24px;
margin: 12px 0;
}
li {
margin-bottom: 8px;
}
details {
margin: 12px 0;
}
summary {
font-weight: bold;
cursor: pointer;
}
@media (prefers-color-scheme: dark) {
:root {
--border-color: ${colorToCss(MaterialTheme.colorScheme.outline)};
}
}
""".trimIndent()
LaunchedEffect(htmlContent) {
webView.loadDataWithBaseURL(
null,
wrapHtml(htmlContent, css),
"text/html",
"UTF-8",
null
)
}
DisposableEffect(webView) {
onDispose {
webView.destroy()
}
}
AndroidView(
factory = { webView },
modifier = modifier,
update = { view ->
view.webViewClient = SafeWebViewClient(context)
}
)
}
private class SafeWebViewClient(
private val context: Context
) : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean {
val url = request.url.toString()
try {
// Открываем ссылки во внешнем браузере
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(url))
)
return true
} catch (e: Exception) {
// Обработка ошибок открытия ссылки
return false
}
}
}
private fun wrapHtml(content: String, css: String): String {
return """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
$css
</style>
</head>
<body>
${content.sanitizeHTML()}
</body>
</html>
""".trimIndent()
}
private fun colorToCss(color: Color): String {
val argb = color.toArgb()
return String.format("#%06X", 0xFFFFFF and argb)
}
//// .addAttributes("span",
//// "data-mx-bg-color", "data-mx-color",
//// "data-mx-spoiler", "data-mx-maths"
//// )
// .addAttributes("a",
// "target", "href"
// )
// .addAttributes("img",
// "width", "height", "alt", "title", "src"
// )
// .addAttributes("ol", "start")
// .addAttributes("code", "class")
// .addAttributes("div", "data-mx-maths")
//
// val doc = Jsoup.parse(this)
// doc.select("mx-reply").remove()
//
// val out = Jsoup.clean(doc.body().toString(), matrixSafelist)
// return out
//}
//
//
//@Composable
//fun HtmlRenderer(
// htmlContent: String,
// modifier: Modifier = Modifier
//) {
// val context = LocalContext.current
// val webView = remember { WebView(context).apply {
// settings.apply {
// javaScriptEnabled = false
// loadWithOverviewMode = true
// useWideViewPort = true
// }
// isVerticalScrollBarEnabled = false
// setBackgroundColor(Color.Transparent.toArgb())
// } }
//
// val css = """
// body {
// font-family: -apple-system, sans-serif;
// font-size: 16px;
// line-height: 1.6;
// color: ${colorToCss(MaterialTheme.colorScheme.onBackground)};
// margin: 0;
// padding: 0;
// }
// h1, h2, h3, h4, h5, h6 {
// font-weight: bold;
// padding: 0;
// }
// h1 { font-size: 24px; }
// h2 { font-size: 22px; }
// h3 { font-size: 20px; }
// h4 { font-size: 18px; }
// h5 { font-size: 16px; }
// h6 { font-size: 14px; }
// a {
// color: ${colorToCss(MaterialTheme.colorScheme.primary)};
// text-decoration: none;
// }
// img {
// max-width: 100%;
// height: auto;
// display: block;
// margin: 12px 0;
// }
// table {
// width: 100%;
// border-collapse: collapse;
// margin: 16px 0;
// }
// th, td {
// border: 1px solid ${colorToCss(MaterialTheme.colorScheme.outline)};
// padding: 12px;
// text-align: left;
// }
// th {
// background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)};
// font-weight: bold;
// }
// blockquote {
// border-left: 4px solid ${colorToCss(MaterialTheme.colorScheme.primary)};
// padding-left: 16px;
// margin-left: 0;
// color: ${colorToCss(MaterialTheme.colorScheme.onSurfaceVariant)};
// }
// pre {
// background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)};
// padding: 16px;
// overflow: auto;
// border-radius: 4px;
// }
// code {
// font-family: monospace;
// background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)};
// padding: 2px 4px;
// border-radius: 4px;
// }
// hr {
// border: 0;
// height: 1px;
// background-color: ${colorToCss(MaterialTheme.colorScheme.outline)};
// margin: 24px 0;
// }
// ul, ol {
// padding-left: 24px;
// margin: 12px 0;
// }
// li {
// margin-bottom: 8px;
// }
// details {
// margin: 12px 0;
// }
// summary {
// font-weight: bold;
// cursor: pointer;
// }
// @media (prefers-color-scheme: dark) {
// :root {
// --border-color: ${colorToCss(MaterialTheme.colorScheme.outline)};
// }
// }
// """.trimIndent()
//
// LaunchedEffect(htmlContent) {
// webView.loadDataWithBaseURL(
// null,
// wrapHtml(htmlContent, css),
// "text/html",
// "UTF-8",
// null
// )
// }
//
// DisposableEffect(webView) {
// onDispose {
// webView.destroy()
// }
// }
//
// AndroidView(
// factory = { webView },
// modifier = modifier,
// update = { view ->
// view.webViewClient = SafeWebViewClient(context)
// }
// )
//}
//
//private class SafeWebViewClient(
// private val context: Context
//) : WebViewClient() {
// override fun shouldOverrideUrlLoading(
// view: WebView,
// request: WebResourceRequest
// ): Boolean {
// val url = request.url.toString()
// try {
// // Открываем ссылки во внешнем браузере
// context.startActivity(
// Intent(Intent.ACTION_VIEW, Uri.parse(url))
// )
// return true
// } catch (e: Exception) {
// // Обработка ошибок открытия ссылки
// return false
// }
// }
//}
//
//private fun wrapHtml(content: String, css: String): String {
// return """
// <!DOCTYPE html>
// <html>
// <head>
// <meta name="viewport" content="width=device-width, initial-scale=1">
// <style>
// $css
// </style>
// </head>
// <body>
// ${content.sanitizeHTML()}
// </body>
// </html>
// """.trimIndent()
//}
//
//private fun colorToCss(color: Color): String {
// val argb = color.toArgb()
// return String.format("#%06X", 0xFFFFFF and argb)
//}
@@ -1,10 +1,12 @@
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon.ui.layout
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.Text
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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.risdeveau.pixeldragon.repo.Room
import kotlinx.coroutines.flow.map
import de.connect2x.trixnity.client.flattenValues
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
@SuppressLint("FlowOperatorInvokedInComposition")
@Composable
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()
// if (itemState.scrollToTop) {
@@ -48,17 +53,17 @@ fun RoomList(modifier: Modifier = Modifier, navController: NavController) {
// }
// }
LaunchedEffect(Unit) {
list = withContext(Dispatchers.IO) { Room.getJoined() }
}
// LaunchedEffect(Unit) {
// list = withContext(Dispatchers.IO) { Room.getJoined() }
// }
LazyColumn(modifier = modifier, state = listState) {
items(list) { room ->
items(rooms) { room ->
RoomItem(room = room, navController = navController )
}
item {
if (list.isEmpty()) {
if (rooms.isEmpty()) {
Text("You have no rooms")
}
}
@@ -67,8 +72,9 @@ fun RoomList(modifier: Modifier = Modifier, navController: NavController) {
@Composable
fun RoomItem(modifier: Modifier = Modifier, room: Room, navController: NavController) {
val avatarUrl = room.avatarUrl ?: (room.direct?.avatarUrl ?: "")
val name = room.name ?: (room.direct?.name ?: "Unnamed")
val name = room.name
val isSpace = room.type == CreateEventContent.RoomType.Space
Row(
modifier
@@ -77,42 +83,46 @@ fun RoomItem(modifier: Modifier = Modifier, room: Room, navController: NavContro
.fillMaxWidth()
.background(
color =
if (room.type == "m.space")
if (isSpace)
MaterialTheme.colorScheme.tertiary
else
MaterialTheme.colorScheme.background
)
.clip(RoundedCornerShape(12.dp))
.clickable {
if (room.type == "m.space")
navController.navigate("space/${room.id}")
if (isSpace)
navController.navigate("space/${room.roomId}")
else
navController.navigate("room/${room.id}")
navController.navigate("room/${room.roomId}")
}
.padding(8.dp)
) {
MXCImage(
avatarUrl,
modifier = Modifier
.padding(end = 4.dp)
.height(52.dp)
.width(52.dp)
.let {
if (room.type == "m.space")
it.clip(RoundedCornerShape(12.dp))
else
it.clip(CircleShape)
},
ContentScale.Crop
)
Column {
Text(room.type)
Text(
name,
maxLines = 1,
color = MaterialTheme.colorScheme.primary,
fontSize = MaterialTheme.typography.titleLarge.fontSize
Log.v("RoomItem", room.avatarUrl.toString())
room.avatarUrl?.let { mxc ->
MXCImage(
mxc,
modifier = Modifier
.padding(end = 4.dp)
.height(52.dp)
.width(52.dp)
.let {
if (isSpace)
it.clip(RoundedCornerShape(12.dp))
else
it.clip(CircleShape)
},
ContentScale.Crop
)
}
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"
)
)