This commit is contained in:
2026-02-20 17:28:12 +03:00
parent 1cfad2ca4e
commit 8d6a76ccb5
4 changed files with 460 additions and 463 deletions
@@ -34,6 +34,7 @@ lateinit var token: String
object AccountData : Preferences("system_parameters") { object AccountData : Preferences("system_parameters") {
var token by stringOrNullPref("token", null) var token by stringOrNullPref("token", null)
var userId by stringOrNullPref("user_id", null)
var homeserver by stringOrNullPref("homeserver", null) var homeserver by stringOrNullPref("homeserver", null)
var syncLastBatch by stringOrNullPref("next_batch", null) var syncLastBatch by stringOrNullPref("next_batch", null)
var filter by stringOrNullPref("filter", null) var filter by stringOrNullPref("filter", null)
@@ -15,6 +15,7 @@ import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.http.contentLength
import io.ktor.http.contentType import io.ktor.http.contentType
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -26,6 +27,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
import ru.risdeveau.pixeldragon.AccountData
import ru.risdeveau.pixeldragon.AccountData.syncLastBatch import ru.risdeveau.pixeldragon.AccountData.syncLastBatch
import ru.risdeveau.pixeldragon.baseUrl import ru.risdeveau.pixeldragon.baseUrl
import ru.risdeveau.pixeldragon.client import ru.risdeveau.pixeldragon.client
@@ -48,18 +50,28 @@ class MatrixSyncService {
// } // }
fun startSync() { 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) // if (!isInitialized)
// throw IllegalStateException("Sync service not initialized") // throw IllegalStateException("Sync service not initialized")
if (syncJob?.isActive == true) return if (syncJob?.isActive == true) return
syncJob = CoroutineScope(Dispatchers.IO).launch { syncJob = CoroutineScope(Dispatchers.IO).launch {
if (syncLastBatch == "") { if (syncLastBatch == null) {
Log.i("MatrixSyncService", "Init syncing")
_syncState.value = SyncState.Syncing _syncState.value = SyncState.Syncing
try { try {
processSyncResponse(initialSync()) initialSync()
_syncState.value = SyncState.Idle _syncState.value = SyncState.Idle
} catch (e: Exception) { } catch (e: Exception) {
Log.e("MatrixSyncService", "Initial sync error", e)
_syncState.value = SyncState.Error(e.message) _syncState.value = SyncState.Error(e.message)
} }
} }
@@ -101,44 +113,44 @@ class MatrixSyncService {
fun isSyncActive(): Boolean = syncJob?.isActive == true fun isSyncActive(): Boolean = syncJob?.isActive == true
private suspend fun processSyncResponse(response: JSONObject) { private suspend fun parseSyncResponse(response: JSONObject) {
// Log.d("syncResponse", response.toString(2)) Log.v("syncResponse", response.toString(2))
// val newMessages = mutableListOf<Message>() val newMessages = mutableListOf<Message>()
// val roomUpdates = mutableListOf<RoomUpdate>() val roomUpdates = mutableListOf<RoomUpdate>()
//
// response.rooms?.join?.forEach { (roomId, roomData) -> response.rooms?.join?.forEach { (roomId, roomData) ->
// // Process timeline events (messages) // Process timeline events (messages)
// roomData.timeline?.events?.forEach { event -> roomData.timeline?.events?.forEach { event ->
// val message = event.toMessage(roomId) val message = event.toMessage(roomId)
// database.messageDao().insertMessage(message) database.messageDao().insertMessage(message)
// newMessages.add(message) newMessages.add(message)
// } }
//
// // Process room state updates // Process room state updates
// roomData.state?.events?.forEach { event -> roomData.state?.events?.forEach { event ->
// when (event.type) { when (event.type) {
// "m.room.name", "m.room.avatar" -> { "m.room.name", "m.room.avatar" -> {
// roomUpdates.add(RoomUpdate(roomId, event.type, event.content)) roomUpdates.add(RoomUpdate(roomId, event.type, event.content))
// } }
// } }
// } }
//
// // Process ephemeral events (typing, receipts) // Process ephemeral events (typing, receipts)
// roomData.ephemeral?.events?.forEach { event -> roomData.ephemeral?.events?.forEach { event ->
// when (event.type) { when (event.type) {
// "m.typing" -> handleTypingEvent(roomId, event) "m.typing" -> handleTypingEvent(roomId, event)
// "m.receipt" -> handleReceiptEvent(roomId, event) "m.receipt" -> handleReceiptEvent(roomId, event)
// } }
// } }
// } }
//
// // Notify the app about new data // Notify the app about new data
// if (newMessages.isNotEmpty()) { if (newMessages.isNotEmpty()) {
// _newMessages.emit(newMessages) _newMessages.emit(newMessages)
// } }
// if (roomUpdates.isNotEmpty()) { if (roomUpdates.isNotEmpty()) {
// _roomUpdates.emit(roomUpdates) _roomUpdates.emit(roomUpdates)
// } }
} }
} }
@@ -156,7 +168,14 @@ suspend fun sync(): JSONObject {
} }
suspend fun initialSync(): JSONObject { suspend fun initialSync(): JSONObject {
val initialFilter = """ fetchRoomMeta()
}
/**
* Fetch rooms metadata during initial sync
*/
suspend fun fetchRoomMeta() {
val filterId = getFilterId("""
{ {
"room": { "room": {
"state": { "state": {
@@ -164,62 +183,27 @@ suspend fun initialSync(): JSONObject {
"m.room.name", "m.room.name",
"m.room.avatar", "m.room.avatar",
"m.room.canonical_alias", "m.room.canonical_alias",
"m.room.encryption",
"m.room.tombstone",
"m.room.power_levels",
"m.room.member" "m.room.member"
], ],
"lazy_load_members": true, "lazy_load_members": true
"not_types": []
}, },
"timeline": { "timeline": {
"limit": 10, "limit": 0,
"types": ["m.room.message"], "types": []
"not_types": [
"m.room.name",
"m.room.avatar",
"m.room.canonical_alias",
"m.room.encryption",
"m.room.tombstone",
"m.room.power_levels",
"m.room.member",
"m.call.*"
]
}, },
"ephemeral": { "ephemeral": {
"types": [], "types": []
"not_types": ["m.typing", "m.receipt"]
}, },
"include_leave": false "include_leave": false
}, },
"presence": { "presence": {
"types": [], "types": []
"not_types": ["*"]
},
"event_format": "client",
"event_fields": [
"type",
"content",
"sender",
"state_key",
"room_id",
"origin_server_ts"
]
} }
""".trimIndent()
var r = client.post("$baseUrl/user/${ME!!.userId}/filter") {
setBody(initialFilter)
contentType(ContentType.Application.Json)
bearerAuth(token)
} }
""".trimIndent())
if (r.status != HttpStatusCode.OK)
throw IllegalStateException("Failed to create a filter")
val filterId = JSONObject(r.bodyAsText()).getString("filter_id")
// val filterId = "vmNk" // val filterId = "vmNk"
/*val*/ r = client.get("$baseUrl/sync") { val r = client.get("$baseUrl/sync") {
bearerAuth(token) bearerAuth(token)
url { url {
parameter("filter", filterId) parameter("filter", filterId)
@@ -228,10 +212,31 @@ suspend fun initialSync(): JSONObject {
if (r.status != HttpStatusCode.OK) if (r.status != HttpStatusCode.OK)
throw IllegalStateException("Failed to sync") throw IllegalStateException("Failed to sync")
Log.d("initialSync", "Response size: ${r.bodyAsText().length}") 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()) val json = JSONObject(r.bodyAsText())
syncLastBatch = json.getString("next_batch") syncLastBatch = json.getString("next_batch")
}
return json
/**
* 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,22 +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.syncService import ru.risdeveau.pixeldragon.syncService
import ru.risdeveau.pixeldragon.ui.layout.Room //import ru.risdeveau.pixeldragon.ui.layout.Room
import ru.risdeveau.pixeldragon.ui.layout.RoomList import ru.risdeveau.pixeldragon.ui.layout.RoomList
import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme
import 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 {
@@ -61,22 +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) {
syncService.startSync()
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) }
@@ -84,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)
.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"
// ) // )
.addAttributes("a", // .padding(4.dp)
"target", "href" // .background(
) // if (event.sender != ME?.userId)
.addAttributes("img", // MaterialTheme.colorScheme.surfaceContainer
"width", "height", "alt", "title", "src" // else
) // MaterialTheme.colorScheme.primaryContainer
.addAttributes("ol", "start") // )
.addAttributes("code", "class") // .clip(RoundedCornerShape(16.dp))
.addAttributes("div", "data-mx-maths") // .padding(4.dp)
// ) {
val doc = Jsoup.parse(this) // Text(event.sender, maxLines = 1, fontWeight = FontWeight.Bold)
doc.select("mx-reply").remove() //
// when (val msgtype = event.content.optString("msgtype", null)) {
val out = Jsoup.clean(doc.body().toString(), matrixSafelist) // "m.text" -> when (event.content.optString("format")) {
return out // "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"))
@Composable // }
fun HtmlRenderer( //
htmlContent: String, // else -> Text(event.content.getString("body"))
modifier: Modifier = Modifier // }
) { //
val context = LocalContext.current // "m.image" ->
val webView = remember { WebView(context).apply { // MXCImage(event.content.getString("url"), Modifier.fillMaxWidth(.9f))
settings.apply { //
javaScriptEnabled = false // null -> Text(event.content.toString(2))
loadWithOverviewMode = true //
useWideViewPort = true // else -> Text("Unknown type: $msgtype", color = MaterialTheme.colorScheme.error)
} // }
isVerticalScrollBarEnabled = false //
setBackgroundColor(Color.Transparent.toArgb()) // }
} } //
// else -> Text(event.type,
val css = """ // Modifier
body { // .fillMaxHeight()
font-family: -apple-system, sans-serif; // .padding(4.dp)
font-size: 16px; // .background(MaterialTheme.colorScheme.errorContainer)
line-height: 1.6; // .padding(4.dp)
color: ${colorToCss(MaterialTheme.colorScheme.onBackground)}; // )
margin: 0; // }
padding: 0; // }
} //}
h1, h2, h3, h4, h5, h6 { //
font-weight: bold; //private fun String.sanitizeHTML(): String {
padding: 0; // val matrixSafelist = Safelist()
} // .addTags(
h1 { font-size: 24px; } // "h1", "h2", "h3", "h4", "h5", "h6",
h2 { font-size: 22px; } // "b", "i", "u", "strong", "s", "del",
h3 { font-size: 20px; } // "sup", "sub", "code",
h4 { font-size: 18px; } // "table", "thead", "tbody",
h5 { font-size: 16px; } // "tr", "th", "td", "ul", "ol", "li",
h6 { font-size: 14px; } // "blockquote", "details", "summary",
a { // "em", "code", "div", "pre", "span", "img"
color: ${colorToCss(MaterialTheme.colorScheme.primary)}; // )
text-decoration: none; //// .addAttributes("span",
} //// "data-mx-bg-color", "data-mx-color",
img { //// "data-mx-spoiler", "data-mx-maths"
max-width: 100%; //// )
height: auto; // .addAttributes("a",
display: block; // "target", "href"
margin: 12px 0; // )
} // .addAttributes("img",
table { // "width", "height", "alt", "title", "src"
width: 100%; // )
border-collapse: collapse; // .addAttributes("ol", "start")
margin: 16px 0; // .addAttributes("code", "class")
} // .addAttributes("div", "data-mx-maths")
th, td { //
border: 1px solid ${colorToCss(MaterialTheme.colorScheme.outline)}; // val doc = Jsoup.parse(this)
padding: 12px; // doc.select("mx-reply").remove()
text-align: left; //
} // val out = Jsoup.clean(doc.body().toString(), matrixSafelist)
th { // return out
background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)}; //}
font-weight: bold; //
} //
blockquote { //@Composable
border-left: 4px solid ${colorToCss(MaterialTheme.colorScheme.primary)}; //fun HtmlRenderer(
padding-left: 16px; // htmlContent: String,
margin-left: 0; // modifier: Modifier = Modifier
color: ${colorToCss(MaterialTheme.colorScheme.onSurfaceVariant)}; //) {
} // val context = LocalContext.current
pre { // val webView = remember { WebView(context).apply {
background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)}; // settings.apply {
padding: 16px; // javaScriptEnabled = false
overflow: auto; // loadWithOverviewMode = true
border-radius: 4px; // useWideViewPort = true
} // }
code { // isVerticalScrollBarEnabled = false
font-family: monospace; // setBackgroundColor(Color.Transparent.toArgb())
background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)}; // } }
padding: 2px 4px; //
border-radius: 4px; // val css = """
} // body {
hr { // font-family: -apple-system, sans-serif;
border: 0; // font-size: 16px;
height: 1px; // line-height: 1.6;
background-color: ${colorToCss(MaterialTheme.colorScheme.outline)}; // color: ${colorToCss(MaterialTheme.colorScheme.onBackground)};
margin: 24px 0; // margin: 0;
} // padding: 0;
ul, ol { // }
padding-left: 24px; // h1, h2, h3, h4, h5, h6 {
margin: 12px 0; // font-weight: bold;
} // padding: 0;
li { // }
margin-bottom: 8px; // h1 { font-size: 24px; }
} // h2 { font-size: 22px; }
details { // h3 { font-size: 20px; }
margin: 12px 0; // h4 { font-size: 18px; }
} // h5 { font-size: 16px; }
summary { // h6 { font-size: 14px; }
font-weight: bold; // a {
cursor: pointer; // color: ${colorToCss(MaterialTheme.colorScheme.primary)};
} // text-decoration: none;
@media (prefers-color-scheme: dark) { // }
:root { // img {
--border-color: ${colorToCss(MaterialTheme.colorScheme.outline)}; // max-width: 100%;
} // height: auto;
} // display: block;
""".trimIndent() // margin: 12px 0;
// }
LaunchedEffect(htmlContent) { // table {
webView.loadDataWithBaseURL( // width: 100%;
null, // border-collapse: collapse;
wrapHtml(htmlContent, css), // margin: 16px 0;
"text/html", // }
"UTF-8", // th, td {
null // border: 1px solid ${colorToCss(MaterialTheme.colorScheme.outline)};
) // padding: 12px;
} // text-align: left;
// }
DisposableEffect(webView) { // th {
onDispose { // background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)};
webView.destroy() // font-weight: bold;
} // }
} // blockquote {
// border-left: 4px solid ${colorToCss(MaterialTheme.colorScheme.primary)};
AndroidView( // padding-left: 16px;
factory = { webView }, // margin-left: 0;
modifier = modifier, // color: ${colorToCss(MaterialTheme.colorScheme.onSurfaceVariant)};
update = { view -> // }
view.webViewClient = SafeWebViewClient(context) // pre {
} // background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)};
) // padding: 16px;
} // overflow: auto;
// border-radius: 4px;
private class SafeWebViewClient( // }
private val context: Context // code {
) : WebViewClient() { // font-family: monospace;
override fun shouldOverrideUrlLoading( // background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)};
view: WebView, // padding: 2px 4px;
request: WebResourceRequest // border-radius: 4px;
): Boolean { // }
val url = request.url.toString() // hr {
try { // border: 0;
// Открываем ссылки во внешнем браузере // height: 1px;
context.startActivity( // background-color: ${colorToCss(MaterialTheme.colorScheme.outline)};
Intent(Intent.ACTION_VIEW, Uri.parse(url)) // margin: 24px 0;
) // }
return true // ul, ol {
} catch (e: Exception) { // padding-left: 24px;
// Обработка ошибок открытия ссылки // margin: 12px 0;
return false // }
} // li {
} // margin-bottom: 8px;
} // }
// details {
private fun wrapHtml(content: String, css: String): String { // margin: 12px 0;
return """ // }
<!DOCTYPE html> // summary {
<html> // font-weight: bold;
<head> // cursor: pointer;
<meta name="viewport" content="width=device-width, initial-scale=1"> // }
<style> // @media (prefers-color-scheme: dark) {
$css // :root {
</style> // --border-color: ${colorToCss(MaterialTheme.colorScheme.outline)};
</head> // }
<body> // }
${content.sanitizeHTML()} // """.trimIndent()
</body> //
</html> // LaunchedEffect(htmlContent) {
""".trimIndent() // webView.loadDataWithBaseURL(
} // null,
// wrapHtml(htmlContent, css),
private fun colorToCss(color: Color): String { // "text/html",
val argb = color.toArgb() // "UTF-8",
return String.format("#%06X", 0xFFFFFF and argb) // 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)
//}