wip: migrate to Trixnity
This commit is contained in:
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
+3
@@ -49,6 +49,9 @@
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
|
||||
Generated
+4
@@ -5,8 +5,12 @@
|
||||
<set>
|
||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="StudioBotProjectSettings">
|
||||
<option name="shareContext" value="OptedIn" />
|
||||
</component>
|
||||
</project>
|
||||
+12
-4
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* 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,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"
|
||||
)
|
||||
)
|
||||
+2
-4
@@ -1,7 +1,6 @@
|
||||
/*
|
||||
* Created by sweetbread on 22.02.2025, 15:45
|
||||
* Copyright (c) 2025. All rights reserved.
|
||||
* Last modified 21.02.2025, 12:21
|
||||
* Created by sweetbread
|
||||
* Copyright (c) 2026. All rights reserved.
|
||||
*/
|
||||
|
||||
// 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.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
agp = "8.13.0"
|
||||
coil = "3.1.0"
|
||||
jsoup = "1.20.1"
|
||||
kotlin = "2.0.21"
|
||||
kotlin = "2.2.21"
|
||||
coreKtx = "1.15.0"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.2.1"
|
||||
@@ -15,6 +15,7 @@ composeBom = "2025.02.00"
|
||||
navigationCompose = "2.8.8"
|
||||
room = "2.6.1"
|
||||
splittiesFunPackAndroidBase = "3.0.0"
|
||||
trixnityClient = "5.2.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -24,7 +25,6 @@ androidx-navigation-dynamic-features-fragment = { module = "androidx.navigation:
|
||||
androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment", version.ref = "navigationCompose" }
|
||||
androidx-navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "navigationCompose" }
|
||||
androidx-navigation-ui = { module = "androidx.navigation:navigation-ui", version.ref = "navigationCompose" }
|
||||
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
|
||||
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||
@@ -43,14 +43,16 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
|
||||
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||
ktor-client-core = { module = "io.ktor:ktor-client-core", 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" }
|
||||
trixnity-client = { module = "de.connect2x.trixnity:trixnity-client", version.ref = "trixnityClient" }
|
||||
trixnity-client-media-okio = { module = "de.connect2x.trixnity:trixnity-client-media-okio", version.ref = "trixnityClient" }
|
||||
trixnity-client-repository-room = { module = "de.connect2x.trixnity:trixnity-client-repository-room", version.ref = "trixnityClient" }
|
||||
trixnity-client-cryptodriver-vodozemac = { module = "de.connect2x.trixnity:trixnity-client-cryptodriver-vodozemac", version.ref = "trixnityClient" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
|
||||
|
||||
+3
-3
@@ -1,7 +1,6 @@
|
||||
/*
|
||||
* Created by sweetbread on 21.02.2025, 12:00
|
||||
* Copyright (c) 2025. All rights reserved.
|
||||
* Last modified 21.02.2025, 12:00
|
||||
* Created by sweetbread
|
||||
* Copyright (c) 2026. All rights reserved.
|
||||
*/
|
||||
|
||||
pluginManagement {
|
||||
@@ -22,6 +21,7 @@ dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven("https://gitlab.com/api/v4/projects/47538655/packages/maven")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user