wip: Migrate to Trixnity

This commit is contained in:
2026-02-21 16:48:31 +03:00
parent 8d6a76ccb5
commit c7b5f20c06
19 changed files with 217 additions and 1007 deletions
+11 -4
View File
@@ -1,13 +1,13 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * 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.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
id("com.google.devtools.ksp") // alias(libs.plugins.ksp)
} }
android { android {
@@ -16,7 +16,7 @@ android {
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"
@@ -62,6 +62,12 @@ 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.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 +81,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 +93,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
} }
@@ -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 package ru.risdeveau.pixeldragon
@@ -11,11 +11,12 @@ 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.MatrixSyncService import net.folivo.trixnity.client.MatrixClient
import ru.risdeveau.pixeldragon.api.getMe //import ru.risdeveau.pixeldragon.api.MatrixSyncService
//import ru.risdeveau.pixeldragon.api.getMe
import splitties.preferences.Preferences 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) {
@@ -28,9 +29,7 @@ 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("system_parameters") { object AccountData : Preferences("system_parameters") {
var token by stringOrNullPref("token", null) var token by stringOrNullPref("token", null)
@@ -40,15 +39,15 @@ object AccountData : Preferences("system_parameters") {
var filter by stringOrNullPref("filter", null) var filter by stringOrNullPref("filter", null)
} }
val syncService = MatrixSyncService() //val syncService = MatrixSyncService()
suspend fun initCheck(): Boolean { //suspend fun initCheck(): Boolean {
Log.d("initCheck", "checking...") // Log.d("initCheck", "checking...")
//
token = AccountData.token ?: return false // token = AccountData.token ?: return false
homeserver = AccountData.homeserver ?: return false // homeserver = AccountData.homeserver ?: return false
//
baseUrl = "$homeserver/_matrix/client/v3" // baseUrl = "$homeserver/_matrix/client/v3"
//
return getMe() != null // return getMe() != null
} //}
@@ -1,19 +1,11 @@
/* /*
* 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 ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.token
import java.time.Instant import java.time.Instant
class Event ( class Event (
@@ -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,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.api package ru.risdeveau.pixeldragon.api
@@ -9,11 +9,11 @@ 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 (_: Exception) { return null }
val json = try { JSONObject(r.bodyAsText()) } val json = try { JSONObject(r.bodyAsText()) }
@@ -27,11 +27,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,73 @@
/* /*
* 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 io.ktor.http.Url
import io.ktor.client.request.get import net.folivo.trixnity.client.MatrixClient
import io.ktor.client.request.post import net.folivo.trixnity.client.login
import io.ktor.client.request.setBody import net.folivo.trixnity.clientserverapi.model.authentication.IdentifierType
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 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 Me (val userId: String, val deviceId: String)
data class UserProfile (val displayName: String, val avatarUrl: String, val other: JSONObject) //data class UserProfile (val displayName: String, val avatarUrl: String, val other: JSONObject)
//
/** ///**
* This func is to validate the token // * This func is to validate the token
*/ // */
suspend fun getMe(): Me? { //suspend fun getMe(): Me? {
val r = client.get("$baseUrl/account/whoami") { bearerAuth(token) } // val r = webClient.get("$baseUrl/account/whoami") { bearerAuth(token) }
if (r.status != HttpStatusCode.OK) { // if (r.status != HttpStatusCode.OK) {
Log.e("getMe", r.bodyAsText()) // Log.e("getMe", r.bodyAsText())
return null // return null
} // }
//
val json = JSONObject(r.bodyAsText()) // val json = JSONObject(r.bodyAsText())
return Me(json.getString("user_id"), json.getString("device_id")) // 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.login(
"type": "m.login.password", baseUrl = hs,
"identifier": { identifier = IdentifierType.User(login),
"type": "m.id.user" password = pass,
}, initialDeviceDisplayName = "PixelDragon Android v${pinfo.versionName}",
"initial_device_display_name": "PixelDragon Android v${pinfo.versionName}" repositoriesModule = getRoomStore(appCtx),
} mediaStore = getMediaStore()
""".trimIndent() ).getOrThrow()
val json = JSONObject(pattern)
json.getJSONObject("identifier").put("user", login)
json.put("password", pass)
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? { //suspend fun getAccountData(user: String, state: String): JSONObject? {
val r = client.get("$baseUrl/user/$user/account_data/$state") { bearerAuth(token) } // val r = webClient.get("$baseUrl/user/$user/account_data/$state") { bearerAuth(token) }
if (r.status != HttpStatusCode.OK) return null // if (r.status != HttpStatusCode.OK) return null
return JSONObject(r.bodyAsText()) // return JSONObject(r.bodyAsText())
} //}
//
suspend fun getUserProfile(userId: String): UserProfile? { //suspend fun getUserProfile(userId: String): UserProfile? {
val r = client.get("$baseUrl/profile/$userId") { bearerAuth(token) } // val r = webClient.get("$baseUrl/profile/$userId") { bearerAuth(token) }
if (r.status != HttpStatusCode.OK) return null // if (r.status != HttpStatusCode.OK) return null
val json = JSONObject(r.bodyAsText()) // val json = JSONObject(r.bodyAsText())
val name = json.optString("displayname", ""); json.remove("displayname") // val name = json.optString("displayname", ""); json.remove("displayname")
val avatar = json.optString("avatar_url", ""); json.remove("avatar_url") // val avatar = json.optString("avatar_url", ""); json.remove("avatar_url")
return UserProfile(name, avatar, json) // return UserProfile(name, avatar, json)
} //}
@@ -1,242 +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 android.util.Log
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
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.contentLength
import io.ktor.http.contentType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.json.JSONObject
import ru.risdeveau.pixeldragon.AccountData
import ru.risdeveau.pixeldragon.AccountData.syncLastBatch
import ru.risdeveau.pixeldragon.baseUrl
import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.db.isConnected
import ru.risdeveau.pixeldragon.token
import ru.risdeveau.pixeldragon.ui.activity.ME
import splitties.init.appCtx
import java.io.File
class MatrixSyncService {
private val _syncState = MutableStateFlow<SyncState>(SyncState.Idle)
val syncState: StateFlow<SyncState> = _syncState.asStateFlow()
private var syncJob: Job? = null
// private var isInitialized = false
//
// fun initialize() {
// isInitialized = true
// }
fun startSync() {
if (AccountData.token == null)
Log.wtf("MatrixSyncService", "Token is null")
if (AccountData.userId == null)
Log.wtf("MatrixSyncService", "User ID is null")
if (AccountData.homeserver == null)
Log.wtf("MatrixSyncService", "Homeserver is null")
Log.i("MatrixSyncService", "Start syncing")
// if (!isInitialized)
// throw IllegalStateException("Sync service not initialized")
if (syncJob?.isActive == true) return
syncJob = CoroutineScope(Dispatchers.IO).launch {
if (syncLastBatch == null) {
Log.i("MatrixSyncService", "Init syncing")
_syncState.value = SyncState.Syncing
try {
initialSync()
_syncState.value = SyncState.Idle
} catch (e: Exception) {
Log.e("MatrixSyncService", "Initial sync error", e)
_syncState.value = SyncState.Error(e.message)
}
}
// while (isActive) {
// try {
// val response = sync (
// timeout = 30000,
// filter = getFilter()
// )
//
// processSyncResponse(response)
//
// } catch (e: Exception) {
// Log.w("sync", e.message.toString())
// delay(5000) // Wait before retry
// }
// }
}
}
fun stopSync() {
syncJob?.cancel()
_syncState.value = SyncState.Idle
}
fun pauseSync() {
// Called when app goes to background
syncJob?.cancel()
}
fun resumeSync() {
// Called when app comes to foreground
if (!isSyncActive()) {
startSync()
}
}
fun isSyncActive(): Boolean = syncJob?.isActive == true
private suspend fun parseSyncResponse(response: JSONObject) {
Log.v("syncResponse", response.toString(2))
val newMessages = mutableListOf<Message>()
val roomUpdates = mutableListOf<RoomUpdate>()
response.rooms?.join?.forEach { (roomId, roomData) ->
// Process timeline events (messages)
roomData.timeline?.events?.forEach { event ->
val message = event.toMessage(roomId)
database.messageDao().insertMessage(message)
newMessages.add(message)
}
// Process room state updates
roomData.state?.events?.forEach { event ->
when (event.type) {
"m.room.name", "m.room.avatar" -> {
roomUpdates.add(RoomUpdate(roomId, event.type, event.content))
}
}
}
// Process ephemeral events (typing, receipts)
roomData.ephemeral?.events?.forEach { event ->
when (event.type) {
"m.typing" -> handleTypingEvent(roomId, event)
"m.receipt" -> handleReceiptEvent(roomId, event)
}
}
}
// Notify the app about new data
if (newMessages.isNotEmpty()) {
_newMessages.emit(newMessages)
}
if (roomUpdates.isNotEmpty()) {
_roomUpdates.emit(roomUpdates)
}
}
}
sealed class SyncState {
object Idle : SyncState()
object Syncing : SyncState()
object Success : SyncState()
data class Error(val message: String?) : SyncState()
}
suspend fun sync(): JSONObject {
return JSONObject()
}
suspend fun initialSync(): JSONObject {
fetchRoomMeta()
}
/**
* Fetch rooms metadata during initial sync
*/
suspend fun fetchRoomMeta() {
val filterId = getFilterId("""
{
"room": {
"state": {
"types": [
"m.room.name",
"m.room.avatar",
"m.room.canonical_alias",
"m.room.member"
],
"lazy_load_members": true
},
"timeline": {
"limit": 0,
"types": []
},
"ephemeral": {
"types": []
},
"include_leave": false
},
"presence": {
"types": []
}
}
""".trimIndent())
// val filterId = "vmNk"
val r = client.get("$baseUrl/sync") {
bearerAuth(token)
url {
parameter("filter", filterId)
}
}
if (r.status != HttpStatusCode.OK)
throw IllegalStateException("Failed to sync")
Log.v("initialSync", "Response size: ${r.contentLength()}")
r.contentLength()?.let {
if (it >= 50*1024*1024)
Log.w("initialSync", "Response size is too large")
}
val json = JSONObject(r.bodyAsText())
syncLastBatch = json.getString("next_batch")
}
/**
* Send a filter to the server and get its id
* @param filter JSON Filter
* @return Filter ID
*/
suspend fun getFilterId(filter: String): String {
val userId = AccountData.userId
if (userId == null)
Log.wtf("getFilter", "user_id is not defined")
val r = client.post("$baseUrl/user/$userId/filter") {
setBody(filter)
contentType(ContentType.Application.Json)
bearerAuth(token)
}
return JSONObject(r.bodyAsText()).getString("filter_id")
}
@@ -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,16 +1,18 @@
/* /*
* 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.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.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -18,23 +20,24 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
import androidx.compose.runtime.LaunchedEffect 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.Modifier
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.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import kotlinx.coroutines.Dispatchers import net.folivo.trixnity.client.MatrixClient
import kotlinx.coroutines.withContext import net.folivo.trixnity.client.fromStore
import ru.risdeveau.pixeldragon.AccountData import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.api.Me
import ru.risdeveau.pixeldragon.api.getMe
import ru.risdeveau.pixeldragon.initCheck
import ru.risdeveau.pixeldragon.syncService
//import ru.risdeveau.pixeldragon.ui.layout.Room
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
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -42,50 +45,72 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (AccountData.token == null) {
start<Login>()
finish()
} else {
syncService.startSync()
}
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()
NavHost(navController = navController, startDestination = "none") { if (!isClientReady) {
composable("none") { } CircularProgressIndicator()
composable("rooms") { RoomList(Modifier.padding(innerPadding), navController) } } else {
composable( Scaffold(
"room/{rid}", modifier = Modifier.fillMaxSize(),
arguments = listOf(navArgument("rid") { type = NavType.StringType }) topBar = {
) { navBackStackEntry -> TopAppBar(
colors = topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
),
title = {
Text("Top app bar")
}
)
},
) { innerPadding ->
val navController = rememberNavController()
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 // Room(Modifier
// .padding(innerPadding) // .padding(innerPadding)
// .fillMaxSize(), navBackStackEntry.arguments!!.getString("rid")!!) // .fillMaxSize(), navBackStackEntry.arguments!!.getString("rid")!!)
}
composable(
"space/{rid}",
arguments = listOf(navArgument("rid") { type = NavType.StringType })
) { navBackStackEntry ->
Text(
modifier = Modifier.padding(innerPadding),
text = "Not implemented"
)
}
} }
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.fromStore(
getRoomStore(appCtx),
getMediaStore()
).getOrNull()
if (client == null) {
start<Login>()
finish()
return@LaunchedEffect
}
}
Log.i("MainActivity", "Log in as ${client!!.userId}")
isClientReady = true
}
} }
} }
} }
@@ -1,81 +1,4 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
*/ */
package ru.risdeveau.pixeldragon.ui.item
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImagePainter
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
enum class ImageLoadState {
Loading, Success, Error
}
@Composable
fun MXCImage(
mxcUrl: String,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
contentDescription: String = ""
) {
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
}
}
)
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
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
}
}
}
}
@@ -1,118 +1,4 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
*/ */
package ru.risdeveau.pixeldragon.ui.layout
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
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.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 ru.risdeveau.pixeldragon.ui.item.MXCImage
@Composable
fun RoomList(modifier: Modifier = Modifier, navController: NavController) {
var list by remember { mutableStateOf(listOf<Room>()) }
val listState = rememberLazyListState()
// if (itemState.scrollToTop) {
// LaunchedEffect(coroutineScope) {
// Log.e("TAG", "TopCoinsScreen: scrollToTop" )
// listState.scrollToItem(0)
// }
// }
// LaunchedEffect(Unit) {
// list = withContext(Dispatchers.IO) { Room.getJoined() }
// }
LazyColumn(modifier = modifier, state = listState) {
items(list) { room ->
RoomItem(room = room, navController = navController )
}
item {
if (list.isEmpty()) {
Text("You have no rooms")
}
}
}
}
@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")
Row(
modifier
.padding(8.dp)
.height((52 + 8 * 2).dp)
.fillMaxWidth()
.background(
color =
if (room.type == "m.space")
MaterialTheme.colorScheme.tertiary
else
MaterialTheme.colorScheme.background
)
.clip(RoundedCornerShape(12.dp))
.clickable {
if (room.type == "m.space")
navController.navigate("space/${room.id}")
else
navController.navigate("room/${room.id}")
}
.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
)
}
}
}
@@ -0,0 +1,27 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon.util
import android.content.Context
import androidx.room.Room
import net.folivo.trixnity.client.media.okio.OkioMediaStore
import net.folivo.trixnity.client.store.repository.room.TrixnityRoomDatabase
import net.folivo.trixnity.client.store.repository.room.createRoomRepositoriesModule
import okio.Path.Companion.toPath
import org.koin.dsl.module
import splitties.init.appCtx
fun getMediaStore() = OkioMediaStore(appCtx.filesDir.resolve("media").absolutePath.toPath())
fun getRoomStore(context: Context) = module {
includes(
createRoomRepositoriesModule(
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 }
}
+8 -2
View File
@@ -2,7 +2,7 @@
agp = "8.13.0" agp = "8.13.0"
coil = "3.1.0" coil = "3.1.0"
jsoup = "1.20.1" jsoup = "1.20.1"
kotlin = "2.0.21" 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.2.1"
@@ -15,6 +15,9 @@ composeBom = "2025.02.00"
navigationCompose = "2.8.8" navigationCompose = "2.8.8"
room = "2.6.1" room = "2.6.1"
splittiesFunPackAndroidBase = "3.0.0" splittiesFunPackAndroidBase = "3.0.0"
trixnityMessenger = "3.8.11"
trixnityClient = "4.15.0"
ksp = "2.0.21-1.0.27"
[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" }
@@ -48,9 +51,12 @@ ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "kto
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 = "net.folivo:trixnity-client", version.ref = "trixnityClient" }
trixnity-client-media-okio = { module = "net.folivo:trixnity-client-media-okio", version.ref = "trixnityClient" }
trixnity-client-repository-room = { module = "net.folivo:trixnity-client-repository-room", version.ref = "trixnityClient" }
trixnity-messenger = { module = "de.connect2x:trixnity-messenger", version.ref = "trixnityMessenger" }
[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" }
+3 -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 {
@@ -22,6 +21,7 @@ dependencyResolutionManagement {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
maven("https://gitlab.com/api/v4/projects/47538655/packages/maven")
} }
} }