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,70 +168,42 @@ 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": {
"types": [ "types": [
"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.member"
"m.room.tombstone", ],
"m.room.power_levels", "lazy_load_members": true
"m.room.member" },
], "timeline": {
"lazy_load_members": true, "limit": 0,
"not_types": [] "types": []
},
"ephemeral": {
"types": []
},
"include_leave": false
}, },
"timeline": { "presence": {
"limit": 10, "types": []
"types": ["m.room.message"], }
"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": {
"types": [],
"not_types": ["m.typing", "m.receipt"]
},
"include_leave": false
},
"presence": {
"types": [],
"not_types": ["*"]
},
"event_format": "client",
"event_fields": [
"type",
"content",
"sender",
"state_key",
"room_id",
"origin_server_ts"
]
} }
""".trimIndent() """.trimIndent())
var r = client.post("$baseUrl/user/${ME!!.userId}/filter") {
setBody(initialFilter)
contentType(ContentType.Application.Json)
bearerAuth(token)
}
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) // .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)
//}