Compare commits

28 Commits

Author SHA1 Message Date
Sweetbread 1cfad2ca4e wip 2026-02-20 09:41:06 +03:00
Sweetbread b6e8c73758 ref: open Login from MainActivity, not vice versa 2025-11-08 20:40:56 +03:00
Sweetbread 28337b1306 ref: async and thread stuff 2025-11-05 01:23:35 +03:00
Sweetbread 05d3d739e2 feat: check for internet connection before sending API requests 2025-11-05 00:06:04 +03:00
Sweetbread 5c6cd29a05 fix: change mxc regex pattern 2025-11-04 23:32:22 +03:00
Sweetbread 70d9db6cbf ref: caching joined rooms 2025-11-04 23:24:20 +03:00
Sweetbread 7026acc229 fixup! ref: reformat caching data in Rooms 2025-11-04 23:23:52 +03:00
Sweetbread fa53e0ae86 ref: remove coroutineScope 2025-11-04 20:42:11 +03:00
Sweetbread ebbc9a4b1f ref: reformat caching data in Rooms 2025-11-04 20:30:22 +03:00
Sweetbread 05fbfbac07 feat: show room avatar when is DM 2025-11-04 16:01:14 +03:00
Sweetbread 5897d31a51 ref: SharedPref to Splitties 2025-11-04 15:19:50 +03:00
Sweetbread a0bfba23cf dev: Highlight m.spaces 2025-11-03 23:52:43 +03:00
Sweetbread 00f273c866 deps: update AGP 2025-11-03 23:44:59 +03:00
Sweetbread 0384626d83 feat: change message style 2025-06-06 01:23:41 +03:00
Sweetbread ea783c9f27 fixup! feat: add image display 2025-06-06 01:16:50 +03:00
Sweetbread 4236c4342b fixup! wip: feat: add formated text display 2025-06-06 01:09:06 +03:00
Sweetbread c40b13a7ea fixup! ref: add MXCImage 2025-06-05 22:15:07 +03:00
Sweetbread de205b739f feat: add image display 2025-06-05 21:50:39 +03:00
Sweetbread 8ac9ccfaca ref: add MXCImage 2025-06-05 21:50:19 +03:00
Sweetbread 7a2567f019 wip: feat: add formated text display 2025-06-05 19:11:43 +03:00
Sweetbread 26417b8072 fix: add a clip 2025-06-05 00:33:51 +03:00
Sweetbread 23780489f6 feat: Messages 2025-03-04 00:30:01 +03:00
Sweetbread bd87ca2729 fix: Finish LoginActivity 2025-03-03 19:15:04 +03:00
Sweetbread 7ba5876a71 impr: Change room elements style
Make room elements smaller and change background color
2025-03-03 18:55:38 +03:00
Sweetbread cab56d6329 feat: Delegate homeserver
Now if matrix homeserver on matrix.example.com, example.com will be correct too
2025-03-03 18:44:07 +03:00
Sweetbread e70049f1f5 feat: Round avatars 2025-02-25 00:39:22 +03:00
Sweetbread c0944ec0a8 wip: Get room info serially 2025-02-22 21:18:16 +03:00
Sweetbread 5fbffd8700 wip: Room list 2025-02-22 21:18:16 +03:00
27 changed files with 1485 additions and 119 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>
+1 -5
View File
@@ -1,7 +1,3 @@
<component name="CopyrightManager">
<settings default="My">
<module2copyright>
<element module="All" copyright="My" />
</module2copyright>
</settings>
<settings default="My" />
</component>
+8
View File
@@ -4,6 +4,14 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-02-22T11:46:39.159466074Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=22163a3c" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.0.0" />
<option name="version" value="2.0.21" />
</component>
</project>
+4
View File
@@ -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>
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>
+24 -9
View File
@@ -1,13 +1,13 @@
/*
* Created by sweetbread on 21.02.2025, 12:01
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Last modified 21.02.2025, 12:01
*/
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
id("com.google.devtools.ksp")
}
android {
@@ -16,7 +16,7 @@ android {
defaultConfig {
applicationId = "ru.risdeveau.pixeldragon"
minSdk = 24
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
@@ -34,11 +34,11 @@ android {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "1.8"
jvmTarget = "17"
}
buildFeatures {
compose = true
@@ -64,12 +64,27 @@ dependencies {
// Ktor - web client
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.logging)
// Coil - image loader
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
// Room - database
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
// Navigation Compose
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation.fragment)
implementation(libs.androidx.navigation.ui)
implementation(libs.androidx.navigation.dynamic.features.fragment)
androidTestImplementation(libs.androidx.navigation.testing)
// Others
implementation(libs.splitties.base) // Syntax sugar
implementation(libs.splitties.base) // Syntax sugar
implementation(libs.jsoup) // HTML parser
}
+6 -8
View File
@@ -1,8 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Created by sweetbread on 21.02.2025, 12:08
~ Created by sweetbread
~ Copyright (c) 2025. All rights reserved.
~ Last modified 21.02.2025, 12:07
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
@@ -20,15 +19,9 @@
android:supportsRtl="true"
android:theme="@style/Theme.PixelDragon"
tools:targetApi="31">
<activity
android:name=".ui.activity.Login"
android:exported="false"
android:label="@string/title_activity_login"
android:theme="@style/Theme.PixelDragon" />
<activity
android:name=".ui.activity.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.PixelDragon">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -36,6 +29,11 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.activity.Login"
android:exported="false"
android:theme="@style/Theme.PixelDragon" />
</application>
</manifest>
@@ -1,43 +1,53 @@
/*
* Created by sweetbread on 21.02.2025, 12:01
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Last modified 21.02.2025, 12:01
*/
package ru.risdeveau.pixeldragon
import android.content.Context
import android.util.Log
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.engine.cio.endpoint
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.MatrixSyncService
import ru.risdeveau.pixeldragon.api.getMe
import splitties.init.appCtx
import splitties.preferences.Preferences
val client = HttpClient(CIO) {
engine {
endpoint {
maxConnectionsPerRoute = 100
pipelineMaxSize = 20
keepAliveTime = 30000
connectTimeout = 15000
connectAttempts = 5
val client = HttpClient {
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
Log.i("Ktor", message)
}
}
level = LogLevel.ALL
}
install(HttpCache)
}
val accountData = appCtx.getSharedPreferences("settings", Context.MODE_PRIVATE)
lateinit var urlBase: String
lateinit var homeserver: String
lateinit var baseUrl: String
lateinit var token: String
suspend fun initCheck(): Boolean {
if (!accountData.contains("token")) return false
if (!accountData.contains("homeserver")) return false
object AccountData : Preferences("system_parameters") {
var token by stringOrNullPref("token", null)
var homeserver by stringOrNullPref("homeserver", null)
var syncLastBatch by stringOrNullPref("next_batch", null)
var filter by stringOrNullPref("filter", null)
}
token = accountData.getString("token", "").toString()
urlBase = "https://${accountData.getString("homeserver", "")}/_matrix/client/v3"
val syncService = MatrixSyncService()
suspend fun initCheck(): Boolean {
Log.d("initCheck", "checking...")
token = AccountData.token ?: return false
homeserver = AccountData.homeserver ?: return false
baseUrl = "$homeserver/_matrix/client/v3"
return getMe() != null
}
}
+58
View File
@@ -0,0 +1,58 @@
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Last modified 03.03.2025, 20:21
*/
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 type: String,
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")
// )
}
//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()
// )
//}
+97
View File
@@ -0,0 +1,97 @@
/*
* Created by sweetbread
* Copyright (c) 2025. 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,7 +1,6 @@
/*
* Created by sweetbread on 21.02.2025, 12:01
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Last modified 21.02.2025, 12:01
*/
package ru.risdeveau.pixeldragon.api
@@ -11,13 +10,28 @@ import io.ktor.client.statement.bodyAsText
import org.json.JSONException
import org.json.JSONObject
import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.homeserver
suspend fun isMatrixServer(url: String): Boolean {
suspend fun getHomeserver(url: String): String? {
val r = try { client.get("https://$url/.well-known/matrix/client") }
catch (_: Exception) { return false }
catch (_: Exception) { return null }
try { JSONObject(r.bodyAsText()) }
catch (_: JSONException) { return false }
val json = try { JSONObject(r.bodyAsText()) }
catch (_: JSONException) { return null }
return true
if (!json.has("m.homeserver")) return null
var homeserver = json.getJSONObject("m.homeserver").getString("base_url")
if (homeserver.endsWith("/")) homeserver = homeserver.dropLast(1)
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]}"
}
@@ -1,12 +1,10 @@
/*
* Created by sweetbread on 21.02.2025, 12:09
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Last modified 21.02.2025, 12:01
*/
package ru.risdeveau.pixeldragon.api
import android.annotation.SuppressLint
import android.util.Log
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.get
@@ -17,20 +15,23 @@ 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.AccountData
import ru.risdeveau.pixeldragon.baseUrl
import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.initCheck
import ru.risdeveau.pixeldragon.token
import ru.risdeveau.pixeldragon.urlBase
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("$urlBase/account/whoami") { bearerAuth(token) }
val r = client.get("$baseUrl/account/whoami") { bearerAuth(token) }
if (r.status != HttpStatusCode.OK) {
Log.e("getMe", r.bodyAsText())
return null
@@ -40,9 +41,11 @@ suspend fun getMe(): Me? {
return Me(json.getString("user_id"), json.getString("device_id"))
}
@SuppressLint("ApplySharedPref")
suspend fun login(homeserver: String, login: String, pass: String): Boolean {
val pinfo = appCtx.packageManager.getPackageInfo(appCtx.packageName, 0);
@OptIn(ExperimentalSplittiesApi::class)
suspend fun login(server: String, login: String, pass: String): Boolean {
val hs = getHomeserver(server)!!
val pinfo = appCtx.packageManager.getPackageInfo(appCtx.packageName, 0)
val pattern = """
{
@@ -58,7 +61,7 @@ suspend fun login(homeserver: String, login: String, pass: String): Boolean {
json.put("password", pass)
val r = try {
client.post("https://$homeserver/_matrix/client/v3/login") {
client.post("$hs/_matrix/client/v3/login") {
setBody(json.toString())
contentType(ContentType.Application.Json)
}
@@ -73,10 +76,25 @@ suspend fun login(homeserver: String, login: String, pass: String): Boolean {
}
val res = JSONObject(r.bodyAsText())
val editor = accountData.edit()
editor.putString("token", res.getString("access_token"))
editor.putString("homeserver", res.getString("home_server"))
editor.commit()
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)
}
+237
View File
@@ -0,0 +1,237 @@
/*
* 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.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.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 (!isInitialized)
// throw IllegalStateException("Sync service not initialized")
if (syncJob?.isActive == true) return
syncJob = CoroutineScope(Dispatchers.IO).launch {
if (syncLastBatch == "") {
_syncState.value = SyncState.Syncing
try {
processSyncResponse(initialSync())
_syncState.value = SyncState.Idle
} catch (e: Exception) {
_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 processSyncResponse(response: JSONObject) {
// Log.d("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 {
val initialFilter = """
{
"room": {
"state": {
"types": [
"m.room.name",
"m.room.avatar",
"m.room.canonical_alias",
"m.room.encryption",
"m.room.tombstone",
"m.room.power_levels",
"m.room.member"
],
"lazy_load_members": true,
"not_types": []
},
"timeline": {
"limit": 10,
"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()
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*/ r = client.get("$baseUrl/sync") {
bearerAuth(token)
url {
parameter("filter", filterId)
}
}
if (r.status != HttpStatusCode.OK)
throw IllegalStateException("Failed to sync")
Log.d("initialSync", "Response size: ${r.bodyAsText().length}")
val json = JSONObject(r.bodyAsText())
syncLastBatch = json.getString("next_batch")
return json
}
+41
View File
@@ -0,0 +1,41 @@
/*
* Created by sweetbread
* Copyright (c) 2025. 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()
+101
View File
@@ -0,0 +1,101 @@
/*
* Created by sweetbread
* Copyright (c) 2025. 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>
)
+60
View File
@@ -0,0 +1,60 @@
/*
* Created by sweetbread
* Copyright (c) 2025. 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)
}
+62
View File
@@ -0,0 +1,62 @@
/*
* Created by sweetbread
* Copyright (c) 2025. 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() }
}
}
}
+51
View File
@@ -0,0 +1,51 @@
/*
* Created by sweetbread
* Copyright (c) 2025. 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,7 +1,6 @@
/*
* Created by sweetbread on 21.02.2025, 12:08
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Last modified 21.02.2025, 12:08
*/
package ru.risdeveau.pixeldragon.ui.activity
@@ -35,15 +34,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.launch
import ru.risdeveau.pixeldragon.api.isMatrixServer
import ru.risdeveau.pixeldragon.api.getHomeserver
import ru.risdeveau.pixeldragon.api.login
import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme
import splitties.activities.start
class Login : ComponentActivity() {
@OptIn(DelicateCoroutinesApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PixelDragonTheme {
@@ -56,10 +58,16 @@ class Login : ComponentActivity() {
SnackbarHost(hostState = snackbarHostState)
}
) { innerPadding ->
Box(Modifier.fillMaxSize().padding(innerPadding)) {
Box(
Modifier
.fillMaxSize()
.padding(innerPadding)) {
LoginField(
Modifier.align(Alignment.Center),
{ start<MainActivity>() },
{
start<MainActivity>()
finish()
},
{
scope.launch {
snackbarHostState
@@ -89,7 +97,7 @@ fun LoginField(modifier: Modifier = Modifier, ok: () -> Unit, err: () -> Unit) {
var login by remember { mutableStateOf("") }
var pass by remember { mutableStateOf("") }
var hmsValid by remember { mutableStateOf(false) }
var hmsValid: Boolean by remember { mutableStateOf(false) }
OutlinedTextField(
modifier = Modifier.padding(4.dp),
@@ -119,18 +127,17 @@ fun LoginField(modifier: Modifier = Modifier, ok: () -> Unit, err: () -> Unit) {
Button(
enabled = hmsValid && !login.isEmpty() && !pass.isEmpty(),
onClick = {
var loginSuccess = false
scope.launch { loginSuccess = login(homeserver, login, pass) }
if (loginSuccess) ok()
else err()
scope.launch {
if (login(homeserver, login, pass)) ok()
else err()
}
}
) {
Text("Login")
}
LaunchedEffect(homeserver) {
scope.launch { hmsValid = isMatrixServer(homeserver) }
scope.launch { hmsValid = (getHomeserver(homeserver) != null) }
}
}
}
@@ -1,7 +1,6 @@
/*
* Created by sweetbread on 21.02.2025, 12:08
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Last modified 21.02.2025, 12:07
*/
package ru.risdeveau.pixeldragon.ui.activity
@@ -18,23 +17,29 @@ 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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
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.syncService
import ru.risdeveau.pixeldragon.ui.layout.Room
import ru.risdeveau.pixeldragon.ui.layout.RoomList
import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme
import splitties.activities.start
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class, DelicateCoroutinesApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
GlobalScope.launch {
if (!initCheck()) start<Login>()
}
var ME: Me? = null
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -54,28 +59,43 @@ class MainActivity : ComponentActivity() {
)
},
) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
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") {
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") }
}
}
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
PixelDragonTheme {
Greeting("Android")
}
}
@@ -0,0 +1,81 @@
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
*/
package ru.risdeveau.pixeldragon.ui.item
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImagePainter
import coil3.compose.rememberAsyncImagePainter
import coil3.network.NetworkHeaders
import coil3.network.httpHeaders
import coil3.request.ImageRequest
import ru.risdeveau.pixeldragon.api.mxcToUrl
import ru.risdeveau.pixeldragon.token
import splitties.init.appCtx
enum class ImageLoadState {
Loading, Success, Error
}
@Composable
fun MXCImage(
mxcUrl: String,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
contentDescription: String = ""
) {
mxcToUrl(mxcUrl)?.let { url ->
val loadState = remember { mutableStateOf(ImageLoadState.Loading) }
val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(appCtx)
.data(url)
.httpHeaders(
NetworkHeaders.Builder()
.set("Authorization", "Bearer $token")
.set("Cache-Control", "max-age=86400")
.build()
)
.build(),
onState = { state ->
loadState.value = when (state) {
is AsyncImagePainter.State.Loading -> ImageLoadState.Loading
is AsyncImagePainter.State.Success -> ImageLoadState.Success
else -> ImageLoadState.Error
}
}
)
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Image(
painter = painter,
contentDescription = contentDescription,
contentScale = contentScale,
modifier = Modifier.fillMaxSize()
)
when (loadState.value) {
ImageLoadState.Loading -> CircularProgressIndicator()
ImageLoadState.Error -> Icon(Icons.Outlined.Warning, "Error")
ImageLoadState.Success -> Unit
}
}
}
}
@@ -0,0 +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"
// )
.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)
}
@@ -0,0 +1,118 @@
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
*/
package ru.risdeveau.pixeldragon.ui.layout
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.risdeveau.pixeldragon.repo.Room
import ru.risdeveau.pixeldragon.ui.item.MXCImage
@Composable
fun RoomList(modifier: Modifier = Modifier, navController: NavController) {
var list by remember { mutableStateOf(listOf<Room>()) }
val listState = rememberLazyListState()
// if (itemState.scrollToTop) {
// LaunchedEffect(coroutineScope) {
// Log.e("TAG", "TopCoinsScreen: scrollToTop" )
// listState.scrollToItem(0)
// }
// }
// LaunchedEffect(Unit) {
// list = withContext(Dispatchers.IO) { Room.getJoined() }
// }
LazyColumn(modifier = modifier, state = listState) {
items(list) { room ->
RoomItem(room = room, navController = navController )
}
item {
if (list.isEmpty()) {
Text("You have no rooms")
}
}
}
}
@Composable
fun RoomItem(modifier: Modifier = Modifier, room: Room, navController: NavController) {
val avatarUrl = room.avatarUrl ?: (room.direct?.avatarUrl ?: "")
val name = room.name ?: (room.direct?.name ?: "Unnamed")
Row(
modifier
.padding(8.dp)
.height((52 + 8 * 2).dp)
.fillMaxWidth()
.background(
color =
if (room.type == "m.space")
MaterialTheme.colorScheme.tertiary
else
MaterialTheme.colorScheme.background
)
.clip(RoundedCornerShape(12.dp))
.clickable {
if (room.type == "m.space")
navController.navigate("space/${room.id}")
else
navController.navigate("room/${room.id}")
}
.padding(8.dp)
) {
MXCImage(
avatarUrl,
modifier = Modifier
.padding(end = 4.dp)
.height(52.dp)
.width(52.dp)
.let {
if (room.type == "m.space")
it.clip(RoundedCornerShape(12.dp))
else
it.clip(CircleShape)
},
ContentScale.Crop
)
Column {
Text(room.type)
Text(
name,
maxLines = 1,
color = MaterialTheme.colorScheme.primary,
fontSize = MaterialTheme.typography.titleLarge.fontSize
)
}
}
}
+3 -2
View File
@@ -1,7 +1,7 @@
/*
* Created by sweetbread on 21.02.2025, 12:00
* Created by sweetbread on 22.02.2025, 15:45
* Copyright (c) 2025. All rights reserved.
* Last modified 21.02.2025, 12:00
* Last modified 21.02.2025, 12:21
*/
// Top-level build file where you can add configuration options common to all sub-projects/modules.
@@ -9,4 +9,5 @@ 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
}
+19 -3
View File
@@ -1,21 +1,35 @@
[versions]
agp = "8.7.3"
agp = "8.13.0"
coil = "3.1.0"
kotlin = "2.0.0"
jsoup = "1.20.1"
kotlin = "2.0.21"
coreKtx = "1.15.0"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
kotlinxSerializationJson = "1.7.3"
ktor = "3.1.0"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.10.0"
composeBom = "2025.02.00"
navigationCompose = "2.8.8"
room = "2.6.1"
splittiesFunPackAndroidBase = "3.0.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "room" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
androidx-navigation-dynamic-features-fragment = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigationCompose" }
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" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
@@ -29,8 +43,10 @@ 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" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
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" }
[plugins]
+2 -3
View File
@@ -1,12 +1,11 @@
#
# Created by sweetbread on 21.02.2025, 12:00
# Created by sweetbread
# Copyright (c) 2025. All rights reserved.
# Last modified 21.02.2025, 12:00
#
#Thu Feb 20 10:45:47 GMT 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists