Compare commits

..

3 Commits

Author SHA1 Message Date
e70049f1f5 feat: Round avatars 2025-02-25 00:39:22 +03:00
c0944ec0a8 wip: Get room info serially 2025-02-22 21:18:16 +03:00
5fbffd8700 wip: Room list 2025-02-22 21:18:16 +03:00
15 changed files with 405 additions and 79 deletions

View File

@ -1,7 +1,3 @@
<component name="CopyrightManager">
<settings default="My">
<module2copyright>
<element module="All" copyright="My" />
</module2copyright>
</settings>
<settings default="My" />
</component>

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>

2
.idea/kotlinc.xml generated
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>

View File

@ -1,13 +1,14 @@
/*
* Created by sweetbread on 21.02.2025, 12:01
* Created by sweetbread on 22.02.2025, 15:45
* Copyright (c) 2025. All rights reserved.
* Last modified 21.02.2025, 12:01
* Last modified 22.02.2025, 14:56
*/
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
id("com.google.devtools.ksp")
}
android {
@ -34,11 +35,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 +65,19 @@ 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)
// Others
implementation(libs.splitties.base) // Syntax sugar
}

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Created by sweetbread on 21.02.2025, 12:08
~ Created by sweetbread on 22.02.2025, 15:45
~ Copyright (c) 2025. All rights reserved.
~ Last modified 21.02.2025, 12:07
~ Last modified 22.02.2025, 14:00
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
@ -22,13 +22,7 @@
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 +30,11 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.activity.MainActivity"
android:exported="false"
android:theme="@style/Theme.PixelDragon" />
</application>
</manifest>

View File

@ -1,43 +1,55 @@
/*
* Created by sweetbread on 21.02.2025, 12:01
* Created by sweetbread on 22.02.2025, 15:45
* Copyright (c) 2025. All rights reserved.
* Last modified 21.02.2025, 12:01
* Last modified 22.02.2025, 15:45
*/
package ru.risdeveau.pixeldragon
import android.content.Context
import android.util.Log
import androidx.room.Room
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.getMe
import ru.risdeveau.pixeldragon.db.AppDatabase
import splitties.init.appCtx
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 {
Log.d("initCheck", "checking...")
if (!accountData.contains("token")) return false
if (!accountData.contains("homeserver")) return false
token = accountData.getString("token", "").toString()
urlBase = "https://${accountData.getString("homeserver", "")}/_matrix/client/v3"
homeserver = accountData.getString("homeserver", "").toString()
baseUrl = "https://$homeserver/_matrix/client/v3"
return getMe() != null
}
val db = Room.databaseBuilder(
appCtx,
AppDatabase::class.java, "database"
).build()

View File

@ -0,0 +1,57 @@
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Last modified 22.02.2025, 19:52
*/
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 org.json.JSONObject
import ru.risdeveau.pixeldragon.baseUrl
import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.db
import ru.risdeveau.pixeldragon.db.Room
import ru.risdeveau.pixeldragon.token
//fun getRooms(): List<Room> {
// return db.roomDoa().getAllJoined()
//}
//
//fun updateRooms(): List<Room> {
//
//}
suspend fun getRooms(): List<String> {
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) }
}
suspend fun getRoom(rid: String): Room {
var room = db.roomDoa().getById(rid)
if (room == null) {
val name = getState(rid, "m.room.name", "name")
val type = getState(rid, "m.room.create", "type") ?: "m.room"
val creator = getState(rid, "m.room.create", "creator")
val avatar = getState(rid, "m.room.avatar", "url")
room = Room(rid, name, type, creator, null, avatar, null, true)
db.roomDoa().insert(room)
}
return room
}
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)
}

View File

@ -1,7 +1,7 @@
/*
* Created by sweetbread on 21.02.2025, 12:01
* Created by sweetbread on 22.02.2025, 17:28
* Copyright (c) 2025. All rights reserved.
* Last modified 21.02.2025, 12:01
* Last modified 22.02.2025, 17:28
*/
package ru.risdeveau.pixeldragon.api
@ -11,6 +11,7 @@ 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 {
val r = try { client.get("https://$url/.well-known/matrix/client") }
@ -21,3 +22,10 @@ suspend fun isMatrixServer(url: String): Boolean {
return true
}
fun mxcToUrl(mxc: String): String {
val pattern = Regex("mxc://([-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6})/([a-zA-Z]+)")
val match = pattern.find(mxc)
return "https://$homeserver/_matrix/client/v1/media/download/${match?.groupValues[1]}/${match?.groupValues[2]}"
}

View File

@ -1,7 +1,7 @@
/*
* Created by sweetbread on 21.02.2025, 12:09
* Created by sweetbread on 22.02.2025, 15:45
* Copyright (c) 2025. All rights reserved.
* Last modified 21.02.2025, 12:01
* Last modified 22.02.2025, 15:45
*/
package ru.risdeveau.pixeldragon.api
@ -18,10 +18,10 @@ import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import org.json.JSONObject
import ru.risdeveau.pixeldragon.accountData
import ru.risdeveau.pixeldragon.baseUrl
import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.initCheck
import ru.risdeveau.pixeldragon.token
import ru.risdeveau.pixeldragon.urlBase
import splitties.init.appCtx
data class Me (val userId: String, val deviceId: String)
@ -30,7 +30,7 @@ data class Me (val userId: String, val deviceId: String)
* 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

View File

@ -0,0 +1,81 @@
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Last modified 22.02.2025, 19:49
*/
package ru.risdeveau.pixeldragon.db
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Delete
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.Junction
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Relation
import androidx.room.RoomDatabase
@Entity
data class Room(
@PrimaryKey val roomId: String,
val name: String?,
val type: String,
val creatorId: String?,
val createTime: Long?,
val avatarUrl: String?,
val members: Int?,
val joined: Boolean
)
@Dao
interface RoomDao {
// @Query("SELECT * FROM room")
// fun getAll(): List<User>
// @Query("SELECT * FROM user WHERE uid IN (:userIds)")
// fun loadAllByIds(userIds: IntArray): List<User>
// @Query("SELECT * FROM user WHERE first_name LIKE :first AND " +
// "last_name LIKE :last LIMIT 1")
// fun findByName(first: String, last: String): User
@Query("SELECT * FROM room WHERE roomId LIKE :rid LIMIT 1")
fun getById(rid: String): Room?
// @Transaction
// @Query("SELECT * FROM room WHERE ")
// fun getSpace(rid: String): Space
@Query("SELECT * FROM room WHERE joined = 1 AND roomId NOT IN (SELECT roomId FROM SpaceToRoom)")
fun getAllJoined(): List<Room>
@Insert
fun insert(vararg rooms: Room)
@Delete
fun delete(room: Room)
}
@Entity(primaryKeys = ["spaceId", "roomId"])
data class SpaceToRoom(
val spaceId: String,
val roomId: String
)
data class Space(
@Embedded val space: Room,
@Relation(
parentColumn = "spaceId",
entityColumn = "roomId",
associateBy = Junction(SpaceToRoom::class)
)
val children: List<Room>
)
@Database(entities = [Room::class, SpaceToRoom::class, User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun roomDoa(): RoomDao
}

View File

@ -0,0 +1,18 @@
/*
* Created by sweetbread on 22.02.2025, 15:45
* Copyright (c) 2025. All rights reserved.
* Last modified 21.02.2025, 13:38
*/
package ru.risdeveau.pixeldragon.db
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class User(
@PrimaryKey val uid: String,
@ColumnInfo(name = "name") val name: String?,
@ColumnInfo(name = "avatar") val avatar: String?
)

View File

@ -1,7 +1,7 @@
/*
* Created by sweetbread on 21.02.2025, 12:08
* Created by sweetbread on 22.02.2025, 15:45
* Copyright (c) 2025. All rights reserved.
* Last modified 21.02.2025, 12:08
* Last modified 22.02.2025, 15:45
*/
package ru.risdeveau.pixeldragon.ui.activity
@ -35,15 +35,25 @@ 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.GlobalScope
import kotlinx.coroutines.launch
import ru.risdeveau.pixeldragon.api.isMatrixServer
import ru.risdeveau.pixeldragon.api.login
import ru.risdeveau.pixeldragon.initCheck
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)
GlobalScope.launch {
if (initCheck()) start<MainActivity>()
}
enableEdgeToEdge()
setContent {
PixelDragonTheme {
@ -56,7 +66,10 @@ class Login : ComponentActivity() {
SnackbarHost(hostState = snackbarHostState)
}
) { innerPadding ->
Box(Modifier.fillMaxSize().padding(innerPadding)) {
Box(
Modifier
.fillMaxSize()
.padding(innerPadding)) {
LoginField(
Modifier.align(Alignment.Center),
{ start<MainActivity>() },
@ -119,12 +132,11 @@ 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()
scope.launch {
if (login(homeserver, login, pass)) ok()
else err()
}
}
) {
Text("Login")
}

View File

@ -1,7 +1,7 @@
/*
* 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
* Last modified 22.02.2025, 20:24
*/
package ru.risdeveau.pixeldragon.ui.activity
@ -10,31 +10,56 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
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.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
import androidx.compose.runtime.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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.SubcomposeAsyncImage
import coil3.network.NetworkHeaders
import coil3.network.httpHeaders
import coil3.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import ru.risdeveau.pixeldragon.initCheck
import kotlinx.coroutines.withContext
import ru.risdeveau.pixeldragon.api.getRoom
import ru.risdeveau.pixeldragon.api.getRooms
import ru.risdeveau.pixeldragon.api.mxcToUrl
import ru.risdeveau.pixeldragon.db.Room
import ru.risdeveau.pixeldragon.token
import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme
import splitties.activities.start
import splitties.init.appCtx
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class, DelicateCoroutinesApi::class)
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
GlobalScope.launch {
if (!initCheck()) start<Login>()
}
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@ -54,10 +79,7 @@ class MainActivity : ComponentActivity() {
)
},
) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
RoomList(Modifier.padding(innerPadding))
}
}
}
@ -65,17 +87,115 @@ class MainActivity : ComponentActivity() {
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
fun RoomList(modifier: Modifier = Modifier) {
var list by remember { mutableStateOf(listOf<String>()) }
val coroutineScope = rememberCoroutineScope()
val listState = rememberLazyListState()
// if (itemState.scrollToTop) {
// LaunchedEffect(coroutineScope) {
// Log.e("TAG", "TopCoinsScreen: scrollToTop" )
// listState.scrollToItem(0)
// }
// }
LaunchedEffect(true) {
coroutineScope.launch {
withContext(Dispatchers.IO) {
list = getRooms()
}
}
}
LazyColumn(modifier = modifier, state = listState) {
items(list) { rid ->
RoomItem(rid = rid)
}
item {
if (list.isEmpty()) {
Text("You have no rooms")
}
}
}
}
@Composable
fun RoomItem(modifier: Modifier = Modifier, rid: String) {
var room by remember { mutableStateOf<Room?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(true) {
scope.launch {
withContext(Dispatchers.IO) {
room = getRoom(rid)
}
}
}
if (room != null) {
Row(
modifier
.padding(8.dp)
.height(80.dp)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(16.dp))
.padding(8.dp)
) {
SubcomposeAsyncImage(
modifier = Modifier
.padding(end = 4.dp)
.height(64.dp)
.width(64.dp)
.let {
if (room!!.type == "m.space")
it.clip(RoundedCornerShape(12.dp))
else
it.clip(CircleShape)
},
model = ImageRequest.Builder(appCtx)
.data(mxcToUrl(room!!.avatarUrl ?: ""))
.httpHeaders(
NetworkHeaders.Builder()
.set("Authorization", "Bearer $token")
.set("Cache-Control", "max-age=86400")
.build()
)
.build(),
contentDescription = room!!.roomId,
contentScale = ContentScale.Crop,
loading = {
CircularProgressIndicator()
}
)
Column {
Text(
text = "Hello $name!",
modifier = modifier
room!!.name ?: "Unnamed",
maxLines = 1,
color = MaterialTheme.colorScheme.primary,
fontSize = MaterialTheme.typography.titleLarge.fontSize
)
}
}
} else {
Row(
modifier
.padding(8.dp)
.height(80.dp)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(16.dp))
.padding(8.dp)
) {
LinearProgressIndicator(Modifier.fillMaxWidth())
}
}
}
//@Preview(showBackground = true)
//@Composable
//fun GreetingPreview() {
// PixelDragonTheme {
// RoomItem()
// }
//}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
PixelDragonTheme {
Greeting("Android")
}
}

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
}

View File

@ -1,7 +1,7 @@
[versions]
agp = "8.7.3"
coil = "3.1.0"
kotlin = "2.0.0"
kotlin = "2.0.21"
coreKtx = "1.15.0"
junit = "4.13.2"
junitVersion = "1.2.1"
@ -10,10 +10,15 @@ ktor = "3.1.0"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.10.0"
composeBom = "2025.02.00"
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-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" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
@ -29,8 +34,9 @@ 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" }
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]