Compare commits

2 Commits

Author SHA1 Message Date
Sweetbread 8d6a76ccb5 wip 2026-02-20 17:28:12 +03:00
Sweetbread 1cfad2ca4e wip 2026-02-20 09:41:06 +03:00
9 changed files with 661 additions and 404 deletions
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+4
View File
@@ -5,8 +5,12 @@
<set> <set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" /> <option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" /> <option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" /> <option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" /> <option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set> </set>
</option> </option>
</component> </component>
Generated Executable
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>
@@ -11,6 +11,7 @@ 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 ru.risdeveau.pixeldragon.api.getMe import ru.risdeveau.pixeldragon.api.getMe
import splitties.preferences.Preferences import splitties.preferences.Preferences
@@ -31,20 +32,23 @@ lateinit var homeserver: String
lateinit var baseUrl: String lateinit var baseUrl: String
lateinit var token: String lateinit var token: String
object AccountData : Preferences("settings") { object AccountData : Preferences("system_parameters") {
var token by stringPref("token", "") var token by stringOrNullPref("token", null)
var homeserver by stringPref("homeserver", "") var userId by stringOrNullPref("user_id", null)
var homeserver by stringOrNullPref("homeserver", null)
var syncLastBatch by stringOrNullPref("next_batch", null)
var filter by stringOrNullPref("filter", null)
} }
val syncService = MatrixSyncService()
suspend fun initCheck(): Boolean { suspend fun initCheck(): Boolean {
Log.d("initCheck", "checking...") Log.d("initCheck", "checking...")
token = AccountData.token token = AccountData.token ?: return false
homeserver = AccountData.homeserver homeserver = AccountData.homeserver ?: return false
if (token.isEmpty() or homeserver.isEmpty()) return false
baseUrl = "$homeserver/_matrix/client/v3" baseUrl = "$homeserver/_matrix/client/v3"
return getMe() != null return getMe() != null
} }
@@ -14,43 +14,45 @@ import org.json.JSONObject
import ru.risdeveau.pixeldragon.baseUrl import ru.risdeveau.pixeldragon.baseUrl
import ru.risdeveau.pixeldragon.client import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.token import ru.risdeveau.pixeldragon.token
import java.time.Instant
class Event ( class Event (
val id: String, val id: String,
val rid: String, val rid: String,
val sender: String,
val type: String, val type: String,
val content: JSONObject val content: JSONObject,
val time: Instant,
val sender: String
) { ) {
constructor(json: JSONObject) : this( // constructor(json: JSONObject) : this(
json.getString("event_id"), // json.getString("event_id"),
json.getString("room_id"), // json.getString("room_id"),
json.getString("sender"), // json.getString("sender"),
json.getString("type"), // json.getString("type"),
json.getJSONObject("content") // json.getJSONObject("content")
) // )
} }
data class EventsAround ( //data class EventsAround (
val base: Event, // val base: Event,
val before: List<Event>, // val before: List<Event>,
val after: List<Event> // val after: List<Event>
) //)
//
suspend fun getEventsAround(room: String, event: String): EventsAround { //suspend fun getEventsAround(room: String, event: String): EventsAround {
val r = client.get("$baseUrl/rooms/$room/context/$event") { // val r = client.get("$baseUrl/rooms/$room/context/$event") {
bearerAuth(token) // bearerAuth(token)
parameter("limit", "50") // parameter("limit", "50")
} // }
val json = JSONObject(r.bodyAsText()) // val json = JSONObject(r.bodyAsText())
//
return EventsAround( // return EventsAround(
Event(json.getJSONObject("event")), // Event(json.getJSONObject("event")),
if (json.has("events_before")) json.getJSONArray("events_before").let { // if (json.has("events_before")) json.getJSONArray("events_before").let {
List<Event>(it.length()) { i -> Event(it.getJSONObject(i))}.reversed() // List<Event>(it.length()) { i -> Event(it.getJSONObject(i))}.reversed()
} else listOf(), // } else listOf(),
if (json.has("events_after")) json.getJSONArray("events_after").let { // if (json.has("events_after")) json.getJSONArray("events_after").let {
List<Event>(it.length()) { i -> Event(it.getJSONObject(i))} // List<Event>(it.length()) { i -> Event(it.getJSONObject(i))}
} else listOf() // } else listOf()
) // )
} //}
+242
View File
@@ -0,0 +1,242 @@
/*
* Created by sweetbread
* Copyright (c) 2025. 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")
}
@@ -26,21 +26,29 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.risdeveau.pixeldragon.AccountData
import ru.risdeveau.pixeldragon.api.Me import ru.risdeveau.pixeldragon.api.Me
import ru.risdeveau.pixeldragon.api.getMe import ru.risdeveau.pixeldragon.api.getMe
import ru.risdeveau.pixeldragon.initCheck import ru.risdeveau.pixeldragon.initCheck
import ru.risdeveau.pixeldragon.ui.layout.Room import ru.risdeveau.pixeldragon.syncService
//import ru.risdeveau.pixeldragon.ui.layout.Room
import ru.risdeveau.pixeldragon.ui.layout.RoomList import ru.risdeveau.pixeldragon.ui.layout.RoomList
import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme
import splitties.activities.start import splitties.activities.start
var ME: Me? = null
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
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 {
@@ -60,21 +68,6 @@ class MainActivity : ComponentActivity() {
) { innerPadding -> ) { innerPadding ->
val navController = rememberNavController() val navController = rememberNavController()
LaunchedEffect(Unit) {
if (initCheck()) {
ME = withContext(Dispatchers.IO) { getMe() }
if (ME != null) {
navController.navigate("rooms")
} else {
start<Login>()
finish()
}
} else {
start<Login>()
finish()
}
}
NavHost(navController = navController, startDestination = "none") { NavHost(navController = navController, startDestination = "none") {
composable("none") { } composable("none") { }
composable("rooms") { RoomList(Modifier.padding(innerPadding), navController) } composable("rooms") { RoomList(Modifier.padding(innerPadding), navController) }
@@ -82,9 +75,9 @@ class MainActivity : ComponentActivity() {
"room/{rid}", "room/{rid}",
arguments = listOf(navArgument("rid") { type = NavType.StringType }) arguments = listOf(navArgument("rid") { type = NavType.StringType })
) { navBackStackEntry -> ) { navBackStackEntry ->
Room(Modifier // Room(Modifier
.padding(innerPadding) // .padding(innerPadding)
.fillMaxSize(), navBackStackEntry.arguments!!.getString("rid")!!) // .fillMaxSize(), navBackStackEntry.arguments!!.getString("rid")!!)
} }
composable( composable(
"space/{rid}", "space/{rid}",
@@ -1,342 +1,342 @@
/* ///*
* Created by sweetbread // * Created by sweetbread
* Copyright (c) 2025. All rights reserved. // * Copyright (c) 2025. All rights reserved.
*/ // */
//
package ru.risdeveau.pixeldragon.ui.layout //package ru.risdeveau.pixeldragon.ui.layout
//
import android.content.Context //import android.content.Context
import android.content.Intent //import android.content.Intent
import android.net.Uri //import android.net.Uri
import android.webkit.WebResourceRequest //import android.webkit.WebResourceRequest
import android.webkit.WebView //import android.webkit.WebView
import android.webkit.WebViewClient //import android.webkit.WebViewClient
import androidx.compose.foundation.background //import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box //import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column //import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight //import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize //import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth //import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding //import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn //import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items //import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState //import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape //import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api //import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme //import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text //import androidx.compose.material3.Text
import androidx.compose.runtime.Composable //import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect //import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect //import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue //import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf //import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember //import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue //import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier //import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip //import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color //import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb //import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext //import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight //import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp //import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView //import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.coroutines.Dispatchers //import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext //import kotlinx.coroutines.withContext
import org.jsoup.Jsoup //import org.jsoup.Jsoup
import org.jsoup.safety.Safelist //import org.jsoup.safety.Safelist
import ru.risdeveau.pixeldragon.api.Event //import ru.risdeveau.pixeldragon.api.Event
import ru.risdeveau.pixeldragon.api.getAccountData //import ru.risdeveau.pixeldragon.api.getAccountData
import ru.risdeveau.pixeldragon.api.getEventsAround //import ru.risdeveau.pixeldragon.api.getEventsAround
import ru.risdeveau.pixeldragon.ui.activity.ME //import ru.risdeveau.pixeldragon.ui.activity.ME
import ru.risdeveau.pixeldragon.ui.item.MXCImage //import ru.risdeveau.pixeldragon.ui.item.MXCImage
//
@Composable //@Composable
fun Room(modifier: Modifier = Modifier, rid: String) { //fun Room(modifier: Modifier = Modifier, rid: String) {
var eventsId by remember { mutableStateOf(listOf<Event>()) } // var eventsId by remember { mutableStateOf(listOf<Event>()) }
val listState = rememberLazyListState() // val listState = rememberLazyListState()
//
LaunchedEffect(Unit) { // LaunchedEffect(Unit) {
withContext(Dispatchers.IO) { // withContext(Dispatchers.IO) {
val readMark = getAccountData(ME!!.userId, rid, "m.fully_read") // val readMark = getAccountData(ME!!.userId, rid, "m.fully_read")
val eventsAround = getEventsAround(rid, readMark!!.getString("event_id")) //FIXME: Null check // val eventsAround = getEventsAround(rid, readMark!!.getString("event_id")) //FIXME: Null check
eventsId = eventsAround.let { // eventsId = eventsAround.let {
it.before + listOf(it.base) + it.after // it.before + listOf(it.base) + it.after
} // }
} // }
} // }
//
LazyColumn(modifier = modifier, state = listState, reverseLayout = true) { // LazyColumn(modifier = modifier, state = listState, reverseLayout = true) {
items(eventsId.reversed()) { event -> // items(eventsId.reversed()) { event ->
EventItem(event) // EventItem(event)
} // }
//
item { // item {
if (eventsId.isEmpty()) { // if (eventsId.isEmpty()) {
Text("Empty room") // Text("Empty room")
} // }
} // }
} // }
} //}
//
@OptIn(ExperimentalMaterial3Api::class) //@OptIn(ExperimentalMaterial3Api::class)
@Composable //@Composable
fun EventItem(event: Event) { //fun EventItem(event: Event) {
Box (Modifier.fillMaxWidth()) { // Box (Modifier.fillMaxWidth()) {
when (event.type) { // when (event.type) {
"m.room.message" -> Column( // "m.room.message" -> Column(
Modifier // Modifier
.fillMaxSize() // .fillMaxSize()
.then( // .then(
if (event.sender != ME!!.userId) // if (event.sender != ME!!.userId)
Modifier.padding(end = 16.dp) // Modifier.padding(end = 16.dp)
else // else
Modifier.padding(start = 16.dp) // Modifier.padding(start = 16.dp)
) // )
.padding(4.dp) // .padding(4.dp)
.background( // .background(
if (event.sender != ME?.userId) // if (event.sender != ME?.userId)
MaterialTheme.colorScheme.surfaceContainer // MaterialTheme.colorScheme.surfaceContainer
else // else
MaterialTheme.colorScheme.primaryContainer // MaterialTheme.colorScheme.primaryContainer
) // )
.clip(RoundedCornerShape(16.dp)) // .clip(RoundedCornerShape(16.dp))
.padding(4.dp) // .padding(4.dp)
) { // ) {
Text(event.sender, maxLines = 1, fontWeight = FontWeight.Bold) // Text(event.sender, maxLines = 1, fontWeight = FontWeight.Bold)
//
when (val msgtype = event.content.optString("msgtype", null)) { // when (val msgtype = event.content.optString("msgtype", null)) {
"m.text" -> when (event.content.optString("format")) { // "m.text" -> when (event.content.optString("format")) {
"org.matrix.custom.html" -> { // "org.matrix.custom.html" -> {
if (event.content.getString("body") == event.content.getString("formatted_body")) // if (event.content.getString("body") == event.content.getString("formatted_body"))
Text(event.content.getString("body")) // Text(event.content.getString("body"))
HtmlRenderer(event.content.getString("formatted_body")) // HtmlRenderer(event.content.getString("formatted_body"))
} // }
//
else -> Text(event.content.getString("body")) // else -> Text(event.content.getString("body"))
} // }
//
"m.image" -> // "m.image" ->
MXCImage(event.content.getString("url"), Modifier.fillMaxWidth(.9f)) // MXCImage(event.content.getString("url"), Modifier.fillMaxWidth(.9f))
//
null -> Text(event.content.toString(2)) // null -> Text(event.content.toString(2))
//
else -> Text("Unknown type: $msgtype", color = MaterialTheme.colorScheme.error) // else -> Text("Unknown type: $msgtype", color = MaterialTheme.colorScheme.error)
} // }
//
} // }
//
else -> Text(event.type, // else -> Text(event.type,
Modifier // Modifier
.fillMaxHeight() // .fillMaxHeight()
.padding(4.dp) // .padding(4.dp)
.background(MaterialTheme.colorScheme.errorContainer) // .background(MaterialTheme.colorScheme.errorContainer)
.padding(4.dp) // .padding(4.dp)
) // )
} // }
} // }
} //}
//
private fun String.sanitizeHTML(): String { //private fun String.sanitizeHTML(): String {
val matrixSafelist = Safelist() // val matrixSafelist = Safelist()
.addTags( // .addTags(
"h1", "h2", "h3", "h4", "h5", "h6", // "h1", "h2", "h3", "h4", "h5", "h6",
"b", "i", "u", "strong", "s", "del", // "b", "i", "u", "strong", "s", "del",
"sup", "sub", "code", // "sup", "sub", "code",
"table", "thead", "tbody", // "table", "thead", "tbody",
"tr", "th", "td", "ul", "ol", "li", // "tr", "th", "td", "ul", "ol", "li",
"blockquote", "details", "summary", // "blockquote", "details", "summary",
"em", "code", "div", "pre", "span", "img" // "em", "code", "div", "pre", "span", "img"
)
// .addAttributes("span",
// "data-mx-bg-color", "data-mx-color",
// "data-mx-spoiler", "data-mx-maths"
// ) // )
.addAttributes("a", //// .addAttributes("span",
"target", "href" //// "data-mx-bg-color", "data-mx-color",
) //// "data-mx-spoiler", "data-mx-maths"
.addAttributes("img", //// )
"width", "height", "alt", "title", "src" // .addAttributes("a",
) // "target", "href"
.addAttributes("ol", "start") // )
.addAttributes("code", "class") // .addAttributes("img",
.addAttributes("div", "data-mx-maths") // "width", "height", "alt", "title", "src"
// )
val doc = Jsoup.parse(this) // .addAttributes("ol", "start")
doc.select("mx-reply").remove() // .addAttributes("code", "class")
// .addAttributes("div", "data-mx-maths")
val out = Jsoup.clean(doc.body().toString(), matrixSafelist) //
return out // val doc = Jsoup.parse(this)
} // doc.select("mx-reply").remove()
//
// val out = Jsoup.clean(doc.body().toString(), matrixSafelist)
@Composable // return out
fun HtmlRenderer( //}
htmlContent: String, //
modifier: Modifier = Modifier //
) { //@Composable
val context = LocalContext.current //fun HtmlRenderer(
val webView = remember { WebView(context).apply { // htmlContent: String,
settings.apply { // modifier: Modifier = Modifier
javaScriptEnabled = false //) {
loadWithOverviewMode = true // val context = LocalContext.current
useWideViewPort = true // val webView = remember { WebView(context).apply {
} // settings.apply {
isVerticalScrollBarEnabled = false // javaScriptEnabled = false
setBackgroundColor(Color.Transparent.toArgb()) // loadWithOverviewMode = true
} } // useWideViewPort = true
// }
val css = """ // isVerticalScrollBarEnabled = false
body { // setBackgroundColor(Color.Transparent.toArgb())
font-family: -apple-system, sans-serif; // } }
font-size: 16px; //
line-height: 1.6; // val css = """
color: ${colorToCss(MaterialTheme.colorScheme.onBackground)}; // body {
margin: 0; // font-family: -apple-system, sans-serif;
padding: 0; // font-size: 16px;
} // line-height: 1.6;
h1, h2, h3, h4, h5, h6 { // color: ${colorToCss(MaterialTheme.colorScheme.onBackground)};
font-weight: bold; // margin: 0;
padding: 0; // padding: 0;
} // }
h1 { font-size: 24px; } // h1, h2, h3, h4, h5, h6 {
h2 { font-size: 22px; } // font-weight: bold;
h3 { font-size: 20px; } // padding: 0;
h4 { font-size: 18px; } // }
h5 { font-size: 16px; } // h1 { font-size: 24px; }
h6 { font-size: 14px; } // h2 { font-size: 22px; }
a { // h3 { font-size: 20px; }
color: ${colorToCss(MaterialTheme.colorScheme.primary)}; // h4 { font-size: 18px; }
text-decoration: none; // h5 { font-size: 16px; }
} // h6 { font-size: 14px; }
img { // a {
max-width: 100%; // color: ${colorToCss(MaterialTheme.colorScheme.primary)};
height: auto; // text-decoration: none;
display: block; // }
margin: 12px 0; // img {
} // max-width: 100%;
table { // height: auto;
width: 100%; // display: block;
border-collapse: collapse; // margin: 12px 0;
margin: 16px 0; // }
} // table {
th, td { // width: 100%;
border: 1px solid ${colorToCss(MaterialTheme.colorScheme.outline)}; // border-collapse: collapse;
padding: 12px; // margin: 16px 0;
text-align: left; // }
} // th, td {
th { // border: 1px solid ${colorToCss(MaterialTheme.colorScheme.outline)};
background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)}; // padding: 12px;
font-weight: bold; // text-align: left;
} // }
blockquote { // th {
border-left: 4px solid ${colorToCss(MaterialTheme.colorScheme.primary)}; // background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)};
padding-left: 16px; // font-weight: bold;
margin-left: 0; // }
color: ${colorToCss(MaterialTheme.colorScheme.onSurfaceVariant)}; // blockquote {
} // border-left: 4px solid ${colorToCss(MaterialTheme.colorScheme.primary)};
pre { // padding-left: 16px;
background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)}; // margin-left: 0;
padding: 16px; // color: ${colorToCss(MaterialTheme.colorScheme.onSurfaceVariant)};
overflow: auto; // }
border-radius: 4px; // pre {
} // background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)};
code { // padding: 16px;
font-family: monospace; // overflow: auto;
background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)}; // border-radius: 4px;
padding: 2px 4px; // }
border-radius: 4px; // code {
} // font-family: monospace;
hr { // background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)};
border: 0; // padding: 2px 4px;
height: 1px; // border-radius: 4px;
background-color: ${colorToCss(MaterialTheme.colorScheme.outline)}; // }
margin: 24px 0; // hr {
} // border: 0;
ul, ol { // height: 1px;
padding-left: 24px; // background-color: ${colorToCss(MaterialTheme.colorScheme.outline)};
margin: 12px 0; // margin: 24px 0;
} // }
li { // ul, ol {
margin-bottom: 8px; // padding-left: 24px;
} // margin: 12px 0;
details { // }
margin: 12px 0; // li {
} // margin-bottom: 8px;
summary { // }
font-weight: bold; // details {
cursor: pointer; // margin: 12px 0;
} // }
@media (prefers-color-scheme: dark) { // summary {
:root { // font-weight: bold;
--border-color: ${colorToCss(MaterialTheme.colorScheme.outline)}; // cursor: pointer;
} // }
} // @media (prefers-color-scheme: dark) {
""".trimIndent() // :root {
// --border-color: ${colorToCss(MaterialTheme.colorScheme.outline)};
LaunchedEffect(htmlContent) { // }
webView.loadDataWithBaseURL( // }
null, // """.trimIndent()
wrapHtml(htmlContent, css), //
"text/html", // LaunchedEffect(htmlContent) {
"UTF-8", // webView.loadDataWithBaseURL(
null // null,
) // wrapHtml(htmlContent, css),
} // "text/html",
// "UTF-8",
DisposableEffect(webView) { // null
onDispose { // )
webView.destroy() // }
} //
} // DisposableEffect(webView) {
// onDispose {
AndroidView( // webView.destroy()
factory = { webView }, // }
modifier = modifier, // }
update = { view -> //
view.webViewClient = SafeWebViewClient(context) // AndroidView(
} // factory = { webView },
) // modifier = modifier,
} // update = { view ->
// view.webViewClient = SafeWebViewClient(context)
private class SafeWebViewClient( // }
private val context: Context // )
) : WebViewClient() { //}
override fun shouldOverrideUrlLoading( //
view: WebView, //private class SafeWebViewClient(
request: WebResourceRequest // private val context: Context
): Boolean { //) : WebViewClient() {
val url = request.url.toString() // override fun shouldOverrideUrlLoading(
try { // view: WebView,
// Открываем ссылки во внешнем браузере // request: WebResourceRequest
context.startActivity( // ): Boolean {
Intent(Intent.ACTION_VIEW, Uri.parse(url)) // val url = request.url.toString()
) // try {
return true // // Открываем ссылки во внешнем браузере
} catch (e: Exception) { // context.startActivity(
// Обработка ошибок открытия ссылки // Intent(Intent.ACTION_VIEW, Uri.parse(url))
return false // )
} // return true
} // } catch (e: Exception) {
} // // Обработка ошибок открытия ссылки
// return false
private fun wrapHtml(content: String, css: String): String { // }
return """ // }
<!DOCTYPE html> //}
<html> //
<head> //private fun wrapHtml(content: String, css: String): String {
<meta name="viewport" content="width=device-width, initial-scale=1"> // return """
<style> // <!DOCTYPE html>
$css // <html>
</style> // <head>
</head> // <meta name="viewport" content="width=device-width, initial-scale=1">
<body> // <style>
${content.sanitizeHTML()} // $css
</body> // </style>
</html> // </head>
""".trimIndent() // <body>
} // ${content.sanitizeHTML()}
// </body>
private fun colorToCss(color: Color): String { // </html>
val argb = color.toArgb() // """.trimIndent()
return String.format("#%06X", 0xFFFFFF and argb) //}
} //
//private fun colorToCss(color: Color): String {
// val argb = color.toArgb()
// return String.format("#%06X", 0xFFFFFF and argb)
//}
@@ -48,9 +48,9 @@ fun RoomList(modifier: Modifier = Modifier, navController: NavController) {
// } // }
// } // }
LaunchedEffect(Unit) { // LaunchedEffect(Unit) {
list = withContext(Dispatchers.IO) { Room.getJoined() } // list = withContext(Dispatchers.IO) { Room.getJoined() }
} // }
LazyColumn(modifier = modifier, state = listState) { LazyColumn(modifier = modifier, state = listState) {
items(list) { room -> items(list) { room ->