Compare commits
1 Commits
dev
..
1cfad2ca4e
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cfad2ca4e |
-3
@@ -49,9 +49,6 @@
|
|||||||
<option name="composableFile" value="true" />
|
<option name="composableFile" value="true" />
|
||||||
<option name="previewFile" value="true" />
|
<option name="previewFile" value="true" />
|
||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
|
||||||
<option name="composableFile" value="true" />
|
|
||||||
</inspection_tool>
|
|
||||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
<option name="composableFile" value="true" />
|
<option name="composableFile" value="true" />
|
||||||
<option name="previewFile" value="true" />
|
<option name="previewFile" value="true" />
|
||||||
|
|||||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="" vcs="Git" />
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
+9
-28
@@ -1,28 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* Created by sweetbread
|
* Created by sweetbread
|
||||||
* Copyright (c) 2026. All rights reserved.
|
* Copyright (c) 2025. All rights reserved.
|
||||||
*/
|
|
||||||
|
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Created by sweetbread
|
|
||||||
* Copyright (c) 2026. All rights reserved.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
// alias(libs.plugins.ksp)
|
id("com.google.devtools.ksp")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "ru.risdeveau.pixeldragon"
|
namespace = "ru.risdeveau.pixeldragon"
|
||||||
compileSdk = 36
|
compileSdk = 35
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "ru.risdeveau.pixeldragon"
|
applicationId = "ru.risdeveau.pixeldragon"
|
||||||
minSdk = 28
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
@@ -43,17 +37,14 @@ android {
|
|||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_17)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
@@ -63,7 +54,6 @@ dependencies {
|
|||||||
implementation(libs.androidx.ui.graphics)
|
implementation(libs.androidx.ui.graphics)
|
||||||
implementation(libs.androidx.ui.tooling.preview)
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
implementation(libs.androidx.material3)
|
implementation(libs.androidx.material3)
|
||||||
implementation(libs.androidx.compose.ui.unit)
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
@@ -72,13 +62,6 @@ dependencies {
|
|||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
debugImplementation(libs.androidx.ui.test.manifest)
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
|
|
||||||
// Trixnity - Matrix wrapper
|
|
||||||
implementation(libs.trixnity.client)
|
|
||||||
implementation(libs.trixnity.client.media.okio)
|
|
||||||
implementation(libs.trixnity.client.repository.room)
|
|
||||||
implementation(libs.trixnity.client.cryptodriver.vodozemac)
|
|
||||||
// implementation(libs.trixnity.messenger)
|
|
||||||
|
|
||||||
// Ktor - web client
|
// Ktor - web client
|
||||||
implementation(libs.ktor.client.core)
|
implementation(libs.ktor.client.core)
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
@@ -92,7 +75,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.room.runtime)
|
implementation(libs.androidx.room.runtime)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
// ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
// Navigation Compose
|
// Navigation Compose
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
@@ -104,6 +87,4 @@ dependencies {
|
|||||||
// Others
|
// Others
|
||||||
implementation(libs.splitties.base) // Syntax sugar
|
implementation(libs.splitties.base) // Syntax sugar
|
||||||
implementation(libs.jsoup) // HTML parser
|
implementation(libs.jsoup) // HTML parser
|
||||||
implementation(libs.iconsax.compose) // Material icons
|
|
||||||
implementation(libs.unifiedpush.connector) // UnifiedPush / ntfy notifications
|
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!--
|
<!--
|
||||||
~ Created by sweetbread
|
~ Created by sweetbread
|
||||||
~ Copyright (c) 2026. All rights reserved.
|
~ Copyright (c) 2025. All rights reserved.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -35,14 +34,6 @@
|
|||||||
android:name=".ui.activity.Login"
|
android:name=".ui.activity.Login"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:theme="@style/Theme.PixelDragon" />
|
android:theme="@style/Theme.PixelDragon" />
|
||||||
|
|
||||||
<service
|
|
||||||
android:name=".push.PixelDragonUnifiedPushService"
|
|
||||||
android:exported="false">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
|
|
||||||
</intent-filter>
|
|
||||||
</service>
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
/*
|
/*
|
||||||
* Created by sweetbread
|
* Created by sweetbread
|
||||||
* Copyright (c) 2026. All rights reserved.
|
* Copyright (c) 2025. All rights reserved.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package ru.risdeveau.pixeldragon
|
package ru.risdeveau.pixeldragon
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.connect2x.trixnity.client.MatrixClient
|
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.plugins.cache.HttpCache
|
import io.ktor.client.plugins.cache.HttpCache
|
||||||
import io.ktor.client.plugins.logging.LogLevel
|
import io.ktor.client.plugins.logging.LogLevel
|
||||||
import io.ktor.client.plugins.logging.Logger
|
import io.ktor.client.plugins.logging.Logger
|
||||||
import io.ktor.client.plugins.logging.Logging
|
import io.ktor.client.plugins.logging.Logging
|
||||||
|
import ru.risdeveau.pixeldragon.api.MatrixSyncService
|
||||||
|
import ru.risdeveau.pixeldragon.api.getMe
|
||||||
|
import splitties.preferences.Preferences
|
||||||
|
|
||||||
val webClient = HttpClient {
|
val client = HttpClient {
|
||||||
install(Logging) {
|
install(Logging) {
|
||||||
logger = object : Logger {
|
logger = object : Logger {
|
||||||
override fun log(message: String) {
|
override fun log(message: String) {
|
||||||
@@ -26,4 +28,26 @@ val webClient = HttpClient {
|
|||||||
install(HttpCache)
|
install(HttpCache)
|
||||||
}
|
}
|
||||||
|
|
||||||
var client: MatrixClient? = null
|
lateinit var homeserver: String
|
||||||
|
lateinit var baseUrl: String
|
||||||
|
lateinit var token: String
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
/*
|
/*
|
||||||
* Created by sweetbread
|
* Created by sweetbread
|
||||||
* Copyright (c) 2026. All rights reserved.
|
* Copyright (c) 2025. All rights reserved.
|
||||||
|
* Last modified 03.03.2025, 20:21
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package ru.risdeveau.pixeldragon.api
|
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 org.json.JSONObject
|
||||||
|
import ru.risdeveau.pixeldragon.baseUrl
|
||||||
|
import ru.risdeveau.pixeldragon.client
|
||||||
|
import ru.risdeveau.pixeldragon.token
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
class Event (
|
class Event (
|
||||||
|
|||||||
@@ -1,4 +1,97 @@
|
|||||||
/*
|
/*
|
||||||
* Created by sweetbread
|
* Created by sweetbread
|
||||||
* Copyright (c) 2026. All rights reserved.
|
* 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,30 +1,23 @@
|
|||||||
/*
|
/*
|
||||||
* Created by sweetbread
|
* Created by sweetbread
|
||||||
* Copyright (c) 2026. All rights reserved.
|
* Copyright (c) 2025. All rights reserved.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package ru.risdeveau.pixeldragon.api
|
package ru.risdeveau.pixeldragon.api
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.statement.bodyAsText
|
import io.ktor.client.statement.bodyAsText
|
||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import ru.risdeveau.pixeldragon.webClient
|
import ru.risdeveau.pixeldragon.client
|
||||||
//import ru.risdeveau.pixeldragon.homeserver
|
import ru.risdeveau.pixeldragon.homeserver
|
||||||
|
|
||||||
suspend fun getHomeserver(url: String): String? {
|
suspend fun getHomeserver(url: String): String? {
|
||||||
val r = try { webClient.get("https://$url/.well-known/matrix/client") }
|
val r = try { client.get("https://$url/.well-known/matrix/client") }
|
||||||
catch (e: Exception) {
|
catch (_: Exception) { return null }
|
||||||
Log.w("getHomeserver", "Fail sending the request", e)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val json = try { JSONObject(r.bodyAsText()) }
|
val json = try { JSONObject(r.bodyAsText()) }
|
||||||
catch (e: JSONException) {
|
catch (_: JSONException) { return null }
|
||||||
Log.w("getHomeserver", "Fail parsing the JSON", e)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!json.has("m.homeserver")) return null
|
if (!json.has("m.homeserver")) return null
|
||||||
|
|
||||||
@@ -34,11 +27,11 @@ suspend fun getHomeserver(url: String): String? {
|
|||||||
return homeserver
|
return homeserver
|
||||||
}
|
}
|
||||||
|
|
||||||
//fun mxcToUrl(mxc: String): String? {
|
fun mxcToUrl(mxc: String): String? {
|
||||||
// val pattern = Regex("mxc://([-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6})/([^#?]+)")
|
val pattern = Regex("mxc://([-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6})/([^#?]+)")
|
||||||
// val match = pattern.find(mxc)
|
val match = pattern.find(mxc)
|
||||||
//
|
|
||||||
// if ((match?.groupValues[1] == null) or (match?.groupValues[2] == null)) return null
|
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]}"
|
return "$homeserver/_matrix/client/v1/media/download/${match?.groupValues[1]}/${match?.groupValues[2]}"
|
||||||
//}
|
}
|
||||||
@@ -1,49 +1,100 @@
|
|||||||
/*
|
/*
|
||||||
* Created by sweetbread
|
* Created by sweetbread
|
||||||
* Copyright (c) 2026. All rights reserved.
|
* Copyright (c) 2025. All rights reserved.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package ru.risdeveau.pixeldragon.api
|
package ru.risdeveau.pixeldragon.api
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.connect2x.trixnity.client.CryptoDriverModule
|
import io.ktor.client.request.bearerAuth
|
||||||
import io.ktor.http.Url
|
import io.ktor.client.request.get
|
||||||
import de.connect2x.trixnity.client.MatrixClient
|
import io.ktor.client.request.post
|
||||||
import de.connect2x.trixnity.client.create
|
import io.ktor.client.request.setBody
|
||||||
import de.connect2x.trixnity.client.cryptodriver.vodozemac.vodozemac
|
import io.ktor.client.statement.bodyAsText
|
||||||
import de.connect2x.trixnity.clientserverapi.client.MatrixClientAuthProviderData
|
import io.ktor.http.ContentType
|
||||||
import de.connect2x.trixnity.clientserverapi.client.classicLogin
|
import io.ktor.http.HttpStatusCode
|
||||||
import de.connect2x.trixnity.clientserverapi.model.authentication.IdentifierType
|
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.client
|
||||||
import ru.risdeveau.pixeldragon.util.getMediaStore
|
import ru.risdeveau.pixeldragon.initCheck
|
||||||
import ru.risdeveau.pixeldragon.util.getRoomStore
|
import ru.risdeveau.pixeldragon.token
|
||||||
import splitties.experimental.ExperimentalSplittiesApi
|
import splitties.experimental.ExperimentalSplittiesApi
|
||||||
import splitties.init.appCtx
|
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("$baseUrl/account/whoami") { bearerAuth(token) }
|
||||||
|
if (r.status != HttpStatusCode.OK) {
|
||||||
|
Log.e("getMe", r.bodyAsText())
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val json = JSONObject(r.bodyAsText())
|
||||||
|
return Me(json.getString("user_id"), json.getString("device_id"))
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalSplittiesApi::class)
|
@OptIn(ExperimentalSplittiesApi::class)
|
||||||
suspend fun login(server: String, login: String, pass: String): Boolean {
|
suspend fun login(server: String, login: String, pass: String): Boolean {
|
||||||
val hs = Url(getHomeserver(server)!!)
|
val hs = getHomeserver(server)!!
|
||||||
|
|
||||||
val pinfo = appCtx.packageManager.getPackageInfo(appCtx.packageName, 0)
|
val pinfo = appCtx.packageManager.getPackageInfo(appCtx.packageName, 0)
|
||||||
|
|
||||||
try {
|
val pattern = """
|
||||||
client = MatrixClient.create(
|
{
|
||||||
repositoriesModule = getRoomStore(appCtx),
|
"type": "m.login.password",
|
||||||
mediaStoreModule = getMediaStore(),
|
"identifier": {
|
||||||
cryptoDriverModule = CryptoDriverModule.vodozemac(),
|
"type": "m.id.user"
|
||||||
authProviderData = MatrixClientAuthProviderData.classicLogin(
|
},
|
||||||
baseUrl = hs,
|
"initial_device_display_name": "PixelDragon Android v${pinfo.versionName}"
|
||||||
identifier = IdentifierType.User(login),
|
}
|
||||||
password = pass,
|
""".trimIndent()
|
||||||
initialDeviceDisplayName = "PixelDragon Android v${pinfo.versionName}",
|
val json = JSONObject(pattern)
|
||||||
).getOrThrow()
|
json.getJSONObject("identifier").put("user", login)
|
||||||
).getOrThrow()
|
json.put("password", pass)
|
||||||
|
|
||||||
return true
|
val r = try {
|
||||||
|
client.post("$hs/_matrix/client/v3/login") {
|
||||||
|
setBody(json.toString())
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.i("Login", "Failed to login", e)
|
Log.e("login", e.toString())
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
if (r.status != HttpStatusCode.OK) {
|
||||||
|
Log.e("login", r.bodyAsText())
|
||||||
|
return false // TODO: Inform a user of error code
|
||||||
|
}
|
||||||
|
|
||||||
|
val res = JSONObject(r.bodyAsText())
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,237 @@
|
|||||||
/*
|
/*
|
||||||
* Created by sweetbread
|
* Created by sweetbread
|
||||||
* Copyright (c) 2026. All rights reserved.
|
* 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
|
||||||
|
}
|
||||||
@@ -1,4 +1,41 @@
|
|||||||
/*
|
/*
|
||||||
* Created by sweetbread
|
* Created by sweetbread
|
||||||
* Copyright (c) 2026. All rights reserved.
|
* 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()
|
||||||
@@ -1,4 +1,101 @@
|
|||||||
/*
|
/*
|
||||||
* Created by sweetbread
|
* Created by sweetbread
|
||||||
* Copyright (c) 2026. All rights reserved.
|
* 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>
|
||||||
|
)
|
||||||
@@ -1,4 +1,60 @@
|
|||||||
/*
|
/*
|
||||||
* Created by sweetbread
|
* Created by sweetbread
|
||||||
* Copyright (c) 2026. All rights reserved.
|
* 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)
|
||||||
|
}
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
/*
|
|
||||||
* Created by sweetbread
|
|
||||||
* Copyright (c) 2026. All rights reserved.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package ru.risdeveau.pixeldragon.push
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import de.connect2x.trixnity.client.MatrixClient
|
|
||||||
import de.connect2x.trixnity.clientserverapi.model.push.PusherData
|
|
||||||
import de.connect2x.trixnity.clientserverapi.model.push.SetPushers
|
|
||||||
import ru.risdeveau.pixeldragon.client
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
const val PIXELDRAGON_UNIFIED_PUSH_APP_ID = "ru.risdeveau.pixeldragon.unifiedpush"
|
|
||||||
|
|
||||||
object MatrixUnifiedPushManager {
|
|
||||||
suspend fun reconcileRegisteredPusher(context: Context): Result<Boolean> {
|
|
||||||
val state = UnifiedPushSettingsStore.read(context)
|
|
||||||
if (!state.enabled || state.endpoint.isNullOrBlank()) {
|
|
||||||
return Result.success(false)
|
|
||||||
}
|
|
||||||
return registerMatrixPusher(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun registerMatrixPusher(context: Context): Result<Boolean> {
|
|
||||||
return runCatching {
|
|
||||||
val state = UnifiedPushSettingsStore.read(context)
|
|
||||||
val endpoint = state.endpoint?.takeIf { it.isNotBlank() }
|
|
||||||
?: error("UnifiedPush endpoint is not available yet")
|
|
||||||
val gatewayUrl = state.gatewayUrl?.takeIf { it.isNotBlank() }
|
|
||||||
?: error("ntfy Matrix gateway URL is not configured")
|
|
||||||
val matrixClient = client ?: error("Matrix client is not ready")
|
|
||||||
|
|
||||||
matrixClient.registerNtfyPusher(
|
|
||||||
endpoint = endpoint,
|
|
||||||
gatewayUrl = gatewayUrl,
|
|
||||||
)
|
|
||||||
UnifiedPushSettingsStore.setMatrixPusherStatus(context, registered = true)
|
|
||||||
true
|
|
||||||
}.onFailure { throwable ->
|
|
||||||
UnifiedPushSettingsStore.setMatrixPusherStatus(
|
|
||||||
context = context,
|
|
||||||
registered = false,
|
|
||||||
error = throwable.toPushError(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun unregisterMatrixPusher(context: Context): Result<Boolean> {
|
|
||||||
return runCatching {
|
|
||||||
val endpoint = UnifiedPushSettingsStore.read(context).endpoint
|
|
||||||
?: return@runCatching false
|
|
||||||
val matrixClient = client ?: error("Matrix client is not ready")
|
|
||||||
|
|
||||||
matrixClient.api.push.setPushers(
|
|
||||||
SetPushers.Request.Remove(
|
|
||||||
appId = PIXELDRAGON_UNIFIED_PUSH_APP_ID,
|
|
||||||
pushkey = endpoint,
|
|
||||||
)
|
|
||||||
).getOrThrow()
|
|
||||||
UnifiedPushSettingsStore.setMatrixPusherStatus(context, registered = false)
|
|
||||||
true
|
|
||||||
}.onFailure { throwable ->
|
|
||||||
UnifiedPushSettingsStore.setMatrixPusherStatus(
|
|
||||||
context = context,
|
|
||||||
registered = false,
|
|
||||||
error = throwable.toPushError(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun refreshMatrixPusherStatus(context: Context): Result<Boolean> {
|
|
||||||
return runCatching {
|
|
||||||
val endpoint = UnifiedPushSettingsStore.read(context).endpoint
|
|
||||||
?: return@runCatching false
|
|
||||||
val matrixClient = client ?: error("Matrix client is not ready")
|
|
||||||
val isRegistered = matrixClient.api.push.getPushers()
|
|
||||||
.getOrThrow()
|
|
||||||
.devices
|
|
||||||
.any { pusher ->
|
|
||||||
pusher.appId == PIXELDRAGON_UNIFIED_PUSH_APP_ID && pusher.pushkey == endpoint
|
|
||||||
}
|
|
||||||
UnifiedPushSettingsStore.setMatrixPusherStatus(context, registered = isRegistered)
|
|
||||||
isRegistered
|
|
||||||
}.onFailure { throwable ->
|
|
||||||
UnifiedPushSettingsStore.setMatrixPusherStatus(
|
|
||||||
context = context,
|
|
||||||
registered = false,
|
|
||||||
error = throwable.toPushError(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun MatrixClient.registerNtfyPusher(
|
|
||||||
endpoint: String,
|
|
||||||
gatewayUrl: String,
|
|
||||||
) {
|
|
||||||
api.push.setPushers(
|
|
||||||
SetPushers.Request.Set(
|
|
||||||
appId = PIXELDRAGON_UNIFIED_PUSH_APP_ID,
|
|
||||||
pushkey = endpoint,
|
|
||||||
kind = "http",
|
|
||||||
appDisplayName = "PixelDragon",
|
|
||||||
deviceDisplayName = deviceDisplayName(),
|
|
||||||
lang = Locale.getDefault().toLanguageTag(),
|
|
||||||
data = PusherData(
|
|
||||||
url = gatewayUrl,
|
|
||||||
format = "event_id_only",
|
|
||||||
),
|
|
||||||
append = true,
|
|
||||||
)
|
|
||||||
).getOrThrow()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun MatrixClient.deviceDisplayName(): String {
|
|
||||||
return listOfNotNull(
|
|
||||||
Build.MANUFACTURER.takeIf { it.isNotBlank() },
|
|
||||||
Build.MODEL.takeIf { it.isNotBlank() },
|
|
||||||
deviceId?.takeIf { it.isNotBlank() },
|
|
||||||
).joinToString(" ").ifBlank { "Android" }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Throwable.toPushError(): String {
|
|
||||||
return message?.takeIf { it.isNotBlank() } ?: this::class.simpleName ?: "Unknown error"
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
/*
|
|
||||||
* Created by sweetbread
|
|
||||||
* Copyright (c) 2026. All rights reserved.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package ru.risdeveau.pixeldragon.push
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import ru.risdeveau.pixeldragon.R
|
|
||||||
import ru.risdeveau.pixeldragon.ui.activity.MainActivity
|
|
||||||
|
|
||||||
private const val MATRIX_CHANNEL_ID = "matrix_messages"
|
|
||||||
private const val MATRIX_NOTIFICATION_ID = 1001
|
|
||||||
|
|
||||||
object PixelDragonNotifier {
|
|
||||||
fun showWakeUpNotification(context: Context) {
|
|
||||||
val appContext = context.applicationContext
|
|
||||||
if (!appContext.canPostNotifications()) return
|
|
||||||
|
|
||||||
ensureChannel(appContext)
|
|
||||||
|
|
||||||
val intent = Intent(appContext, MainActivity::class.java).apply {
|
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
}
|
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
|
||||||
appContext,
|
|
||||||
0,
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
||||||
)
|
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(appContext, MATRIX_CHANNEL_ID)
|
|
||||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
|
||||||
.setContentTitle("New Matrix activity")
|
|
||||||
.setContentText("Open PixelDragon to sync and decrypt the latest messages.")
|
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
NotificationManagerCompat.from(appContext).notify(MATRIX_NOTIFICATION_ID, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureChannel(context: Context) {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
|
||||||
val notificationManager = context.getSystemService(NotificationManager::class.java)
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
MATRIX_CHANNEL_ID,
|
|
||||||
"Matrix messages",
|
|
||||||
NotificationManager.IMPORTANCE_DEFAULT,
|
|
||||||
).apply {
|
|
||||||
description = "Wake-up notifications from ntfy / UnifiedPush."
|
|
||||||
}
|
|
||||||
notificationManager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Context.canPostNotifications(): Boolean {
|
|
||||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
|
||||||
ContextCompat.checkSelfPermission(
|
|
||||||
this,
|
|
||||||
Manifest.permission.POST_NOTIFICATIONS,
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
/*
|
|
||||||
* Created by sweetbread
|
|
||||||
* Copyright (c) 2026. All rights reserved.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package ru.risdeveau.pixeldragon.push
|
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.unifiedpush.android.connector.FailedReason
|
|
||||||
import org.unifiedpush.android.connector.PushService
|
|
||||||
import org.unifiedpush.android.connector.data.PushEndpoint
|
|
||||||
import org.unifiedpush.android.connector.data.PushMessage
|
|
||||||
import ru.risdeveau.pixeldragon.client
|
|
||||||
|
|
||||||
class PixelDragonUnifiedPushService : PushService() {
|
|
||||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
|
|
||||||
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
|
|
||||||
UnifiedPushSettingsStore.setEndpoint(applicationContext, endpoint.url)
|
|
||||||
UnifiedPushSettingsStore.setLastError(applicationContext, null)
|
|
||||||
|
|
||||||
serviceScope.launch {
|
|
||||||
MatrixUnifiedPushManager.registerMatrixPusher(applicationContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessage(message: PushMessage, instance: String) {
|
|
||||||
UnifiedPushSettingsStore.recordPush(applicationContext)
|
|
||||||
|
|
||||||
serviceScope.launch {
|
|
||||||
runCatching {
|
|
||||||
client?.startSync()
|
|
||||||
}
|
|
||||||
PixelDragonNotifier.showWakeUpNotification(applicationContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
|
|
||||||
UnifiedPushSettingsStore.setLastError(applicationContext, reason.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTempUnavailable(instance: String) {
|
|
||||||
UnifiedPushSettingsStore.setLastError(
|
|
||||||
context = applicationContext,
|
|
||||||
error = "Distributor backend is temporarily unavailable",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onUnregistered(instance: String) {
|
|
||||||
serviceScope.launch {
|
|
||||||
MatrixUnifiedPushManager.unregisterMatrixPusher(applicationContext)
|
|
||||||
UnifiedPushSettingsStore.clear(applicationContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
serviceScope.cancel()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
/*
|
|
||||||
* Created by sweetbread
|
|
||||||
* Copyright (c) 2026. All rights reserved.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package ru.risdeveau.pixeldragon.push
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
|
|
||||||
private const val PREFS_NAME = "unified_push_settings"
|
|
||||||
private const val KEY_ENABLED = "enabled"
|
|
||||||
private const val KEY_ENDPOINT = "endpoint"
|
|
||||||
private const val KEY_GATEWAY_OVERRIDE = "gateway_override"
|
|
||||||
private const val KEY_LAST_ERROR = "last_error"
|
|
||||||
private const val KEY_LAST_PUSH_TS = "last_push_ts"
|
|
||||||
private const val KEY_MATRIX_PUSHER_REGISTERED = "matrix_pusher_registered"
|
|
||||||
private const val KEY_MATRIX_PUSHER_TS = "matrix_pusher_ts"
|
|
||||||
private const val KEY_MATRIX_ERROR = "matrix_error"
|
|
||||||
|
|
||||||
data class UnifiedPushSettingsState(
|
|
||||||
val enabled: Boolean = false,
|
|
||||||
val endpoint: String? = null,
|
|
||||||
val gatewayUrlOverride: String? = null,
|
|
||||||
val lastError: String? = null,
|
|
||||||
val lastPushAt: Long? = null,
|
|
||||||
val matrixPusherRegistered: Boolean = false,
|
|
||||||
val matrixPusherUpdatedAt: Long? = null,
|
|
||||||
val matrixError: String? = null,
|
|
||||||
) {
|
|
||||||
val gatewayUrl: String?
|
|
||||||
get() = gatewayUrlOverride?.takeIf { it.isNotBlank() }
|
|
||||||
?: endpoint?.toNtfyMatrixGatewayUrl()
|
|
||||||
}
|
|
||||||
|
|
||||||
object UnifiedPushSettingsStore {
|
|
||||||
private val state = MutableStateFlow(UnifiedPushSettingsState())
|
|
||||||
|
|
||||||
fun observe(context: Context): StateFlow<UnifiedPushSettingsState> {
|
|
||||||
refresh(context)
|
|
||||||
return state.asStateFlow()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refresh(context: Context) {
|
|
||||||
state.value = read(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setEnabled(context: Context, enabled: Boolean) {
|
|
||||||
edit(context) { putBoolean(KEY_ENABLED, enabled) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setEndpoint(context: Context, endpoint: String?) {
|
|
||||||
edit(context) {
|
|
||||||
putNullableString(KEY_ENDPOINT, endpoint)
|
|
||||||
putBoolean(KEY_ENABLED, endpoint != null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setGatewayOverride(context: Context, gatewayUrl: String?) {
|
|
||||||
edit(context) { putNullableString(KEY_GATEWAY_OVERRIDE, gatewayUrl?.trim()?.takeIf { it.isNotBlank() }) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setLastError(context: Context, error: String?) {
|
|
||||||
edit(context) { putNullableString(KEY_LAST_ERROR, error) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun recordPush(context: Context) {
|
|
||||||
edit(context) {
|
|
||||||
putLong(KEY_LAST_PUSH_TS, System.currentTimeMillis())
|
|
||||||
remove(KEY_LAST_ERROR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setMatrixPusherStatus(
|
|
||||||
context: Context,
|
|
||||||
registered: Boolean,
|
|
||||||
error: String? = null,
|
|
||||||
) {
|
|
||||||
edit(context) {
|
|
||||||
putBoolean(KEY_MATRIX_PUSHER_REGISTERED, registered)
|
|
||||||
if (registered) {
|
|
||||||
putLong(KEY_MATRIX_PUSHER_TS, System.currentTimeMillis())
|
|
||||||
remove(KEY_MATRIX_ERROR)
|
|
||||||
} else {
|
|
||||||
remove(KEY_MATRIX_PUSHER_TS)
|
|
||||||
putNullableString(KEY_MATRIX_ERROR, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clear(context: Context) {
|
|
||||||
prefs(context).edit().clear().apply()
|
|
||||||
refresh(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun read(context: Context): UnifiedPushSettingsState {
|
|
||||||
val prefs = prefs(context)
|
|
||||||
return UnifiedPushSettingsState(
|
|
||||||
enabled = prefs.getBoolean(KEY_ENABLED, false),
|
|
||||||
endpoint = prefs.getString(KEY_ENDPOINT, null),
|
|
||||||
gatewayUrlOverride = prefs.getString(KEY_GATEWAY_OVERRIDE, null),
|
|
||||||
lastError = prefs.getString(KEY_LAST_ERROR, null),
|
|
||||||
lastPushAt = prefs.getLongOrNull(KEY_LAST_PUSH_TS),
|
|
||||||
matrixPusherRegistered = prefs.getBoolean(KEY_MATRIX_PUSHER_REGISTERED, false),
|
|
||||||
matrixPusherUpdatedAt = prefs.getLongOrNull(KEY_MATRIX_PUSHER_TS),
|
|
||||||
matrixError = prefs.getString(KEY_MATRIX_ERROR, null),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun edit(context: Context, block: android.content.SharedPreferences.Editor.() -> Unit) {
|
|
||||||
prefs(context).edit().apply(block).apply()
|
|
||||||
refresh(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun prefs(context: Context) =
|
|
||||||
context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun android.content.SharedPreferences.getLongOrNull(key: String): Long? {
|
|
||||||
return if (contains(key)) getLong(key, 0L) else null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun android.content.SharedPreferences.Editor.putNullableString(
|
|
||||||
key: String,
|
|
||||||
value: String?,
|
|
||||||
) {
|
|
||||||
if (value == null) remove(key) else putString(key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun String.toNtfyMatrixGatewayUrl(): String? {
|
|
||||||
val uri = android.net.Uri.parse(this)
|
|
||||||
val scheme = uri.scheme ?: return null
|
|
||||||
val authority = uri.authority ?: return null
|
|
||||||
return "$scheme://$authority/_matrix/push/v1/notify"
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,62 @@
|
|||||||
/*
|
/*
|
||||||
* Created by sweetbread
|
* Created by sweetbread
|
||||||
* Copyright (c) 2026. All rights reserved.
|
* 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,51 @@
|
|||||||
/*
|
/*
|
||||||
* Created by sweetbread
|
* Created by sweetbread
|
||||||
* Copyright (c) 2026. All rights reserved.
|
* 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,454 +1,101 @@
|
|||||||
/*
|
/*
|
||||||
* Created by sweetbread
|
* Created by sweetbread
|
||||||
* Copyright (c) 2026. All rights reserved.
|
* Copyright (c) 2025. All rights reserved.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package ru.risdeveau.pixeldragon.ui.activity
|
package ru.risdeveau.pixeldragon.ui.activity
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FloatingActionButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
|
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.navigation.NavHostController
|
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
|
||||||
import androidx.navigation.compose.navigation
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import de.connect2x.trixnity.client.CryptoDriverModule
|
|
||||||
import de.connect2x.trixnity.client.MatrixClient
|
|
||||||
import de.connect2x.trixnity.client.create
|
|
||||||
import de.connect2x.trixnity.client.cryptodriver.vodozemac.vodozemac
|
|
||||||
import de.connect2x.trixnity.client.flattenValues
|
|
||||||
import de.connect2x.trixnity.client.room
|
|
||||||
import de.connect2x.trixnity.client.store.type
|
|
||||||
import de.connect2x.trixnity.clientserverapi.client.SyncState
|
|
||||||
import de.connect2x.trixnity.clientserverapi.model.user.avatarUrl
|
|
||||||
import de.connect2x.trixnity.clientserverapi.model.user.displayName
|
|
||||||
import de.connect2x.trixnity.core.model.events.m.room.CreateEventContent
|
|
||||||
import io.github.rabehx.iconsax.Iconsax
|
|
||||||
import io.github.rabehx.iconsax.automirrored.outline.ArrowLeft2
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.launch
|
import ru.risdeveau.pixeldragon.api.Me
|
||||||
import ru.risdeveau.pixeldragon.R
|
import ru.risdeveau.pixeldragon.api.getMe
|
||||||
import ru.risdeveau.pixeldragon.client
|
import ru.risdeveau.pixeldragon.initCheck
|
||||||
import ru.risdeveau.pixeldragon.push.MatrixUnifiedPushManager
|
import ru.risdeveau.pixeldragon.syncService
|
||||||
import ru.risdeveau.pixeldragon.ui.item.Avatar
|
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.AccountSettingsScreen
|
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.AppearanceSettingsScreen
|
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.CreateRoomScreen
|
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.DevicesSettingsScreen
|
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.JoinRoomScreen
|
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.NotificationsSettingsScreen
|
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.Room
|
import ru.risdeveau.pixeldragon.ui.layout.Room
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.RoomActionsRoutes
|
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.RoomActionsSheet
|
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.RoomList
|
import ru.risdeveau.pixeldragon.ui.layout.RoomList
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.SettingsMainScreen
|
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.SettingsRoutes
|
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.settingsTitleForRoute
|
|
||||||
import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme
|
import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme
|
||||||
import ru.risdeveau.pixeldragon.util.getMediaStore
|
|
||||||
import ru.risdeveau.pixeldragon.util.getRoomStore
|
|
||||||
import splitties.activities.start
|
import splitties.activities.start
|
||||||
import splitties.init.appCtx
|
|
||||||
import de.connect2x.trixnity.client.store.Room as TrixnityRoom
|
|
||||||
|
|
||||||
|
var ME: Me? = null
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
PixelDragonTheme {
|
PixelDragonTheme {
|
||||||
var isClientReady by remember { mutableStateOf(false) }
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
if (!isClientReady || client == null) {
|
topBar = {
|
||||||
Box(
|
TopAppBar(
|
||||||
modifier = Modifier.fillMaxSize(),
|
colors = topAppBarColors(
|
||||||
contentAlignment = Alignment.Center,
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
) {
|
titleContentColor = MaterialTheme.colorScheme.primary,
|
||||||
CircularProgressIndicator()
|
),
|
||||||
}
|
title = {
|
||||||
} else {
|
Text("Top app bar")
|
||||||
val navController = rememberNavController()
|
}
|
||||||
val syncState by client!!.api.sync.currentSyncState
|
|
||||||
.collectAsState(initial = SyncState.STOPPED)
|
|
||||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
|
||||||
val route = backStackEntry?.destination?.route
|
|
||||||
var showRoomActionsSheet by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
if (showRoomActionsSheet) {
|
|
||||||
RoomActionsSheet(
|
|
||||||
onDismiss = { showRoomActionsSheet = false },
|
|
||||||
onJoinRoom = { navController.navigate(RoomActionsRoutes.JoinRoom) },
|
|
||||||
onCreateRoom = { navController.navigate(RoomActionsRoutes.CreateRoom) },
|
|
||||||
onCreateSpace = { navController.navigate(RoomActionsRoutes.CreateSpace) },
|
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
val navController = rememberNavController()
|
||||||
|
|
||||||
Scaffold(
|
LaunchedEffect(Unit) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
if (initCheck()) {
|
||||||
topBar = {
|
ME = withContext(Dispatchers.IO) { getMe() }
|
||||||
PixelDragonTopBar(
|
if (ME != null) {
|
||||||
navController = navController,
|
syncService.startSync()
|
||||||
syncState = syncState,
|
navController.navigate("rooms")
|
||||||
)
|
} else {
|
||||||
},
|
start<Login>()
|
||||||
floatingActionButton = {
|
finish()
|
||||||
if (route == RoomActionsRoutes.Rooms) {
|
|
||||||
FloatingActionButton(onClick = { showRoomActionsSheet = true }) {
|
|
||||||
Text("+")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
} else {
|
||||||
) { innerPadding ->
|
|
||||||
NavHost(navController = navController, startDestination = RoomActionsRoutes.Rooms) {
|
|
||||||
composable(RoomActionsRoutes.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 })
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
modifier = Modifier.padding(innerPadding),
|
|
||||||
text = "Not implemented"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(RoomActionsRoutes.JoinRoom) {
|
|
||||||
JoinRoomScreen(
|
|
||||||
modifier = Modifier.padding(innerPadding),
|
|
||||||
onJoined = { roomId ->
|
|
||||||
navController.navigate("room/${roomId.full}") {
|
|
||||||
popUpTo(RoomActionsRoutes.Rooms)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(RoomActionsRoutes.CreateRoom) {
|
|
||||||
CreateRoomScreen(
|
|
||||||
modifier = Modifier.padding(innerPadding),
|
|
||||||
isSpace = false,
|
|
||||||
onCreated = { roomId ->
|
|
||||||
navController.navigate("room/${roomId.full}") {
|
|
||||||
popUpTo(RoomActionsRoutes.Rooms)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(RoomActionsRoutes.CreateSpace) {
|
|
||||||
CreateRoomScreen(
|
|
||||||
modifier = Modifier.padding(innerPadding),
|
|
||||||
isSpace = true,
|
|
||||||
onCreated = { roomId ->
|
|
||||||
navController.navigate("space/${roomId.full}") {
|
|
||||||
popUpTo(RoomActionsRoutes.Rooms)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
navigation(
|
|
||||||
route = SettingsRoutes.Graph,
|
|
||||||
startDestination = SettingsRoutes.Main,
|
|
||||||
) {
|
|
||||||
composable(SettingsRoutes.Main) {
|
|
||||||
SettingsMainScreen(
|
|
||||||
modifier = Modifier.padding(innerPadding),
|
|
||||||
onCategoryClick = { category ->
|
|
||||||
navController.navigate(category.route)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(SettingsRoutes.Account) {
|
|
||||||
AccountSettingsScreen(Modifier.padding(innerPadding))
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(SettingsRoutes.Devices) {
|
|
||||||
DevicesSettingsScreen(Modifier.padding(innerPadding))
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(SettingsRoutes.Notifications) {
|
|
||||||
NotificationsSettingsScreen(Modifier.padding(innerPadding))
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(SettingsRoutes.Appearance) {
|
|
||||||
AppearanceSettingsScreen(Modifier.padding(innerPadding))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
if (client == null) {
|
|
||||||
client = MatrixClient.create(
|
|
||||||
repositoriesModule = getRoomStore(appCtx),
|
|
||||||
mediaStoreModule = getMediaStore(),
|
|
||||||
cryptoDriverModule = CryptoDriverModule.vodozemac()
|
|
||||||
).getOrNull()
|
|
||||||
|
|
||||||
if (client == null) {
|
|
||||||
start<Login>()
|
start<Login>()
|
||||||
finish()
|
finish()
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.i("MainActivity", "Log in as ${client!!.userId}")
|
NavHost(navController = navController, startDestination = "none") {
|
||||||
client!!.startSync()
|
composable("none") { }
|
||||||
MatrixUnifiedPushManager.reconcileRegisteredPusher(appCtx)
|
composable("rooms") { RoomList(Modifier.padding(innerPadding), navController) }
|
||||||
isClientReady = true
|
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") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
client?.stopSync()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("FlowOperatorInvokedInComposition")
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun PixelDragonTopBar(
|
|
||||||
navController: NavHostController,
|
|
||||||
syncState: SyncState,
|
|
||||||
) {
|
|
||||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
|
||||||
val route = backStackEntry?.destination?.route
|
|
||||||
val rid = backStackEntry?.arguments?.getString("rid")
|
|
||||||
val isRoomLikeScreen = route == "room/{rid}" || route == "space/{rid}"
|
|
||||||
val roomActionTitle = RoomActionsRoutes.titleFor(route)
|
|
||||||
val isSettingsScreen = route == SettingsRoutes.Graph || route?.startsWith("settings/") == true
|
|
||||||
|
|
||||||
val roomsFlow = remember(client) {
|
|
||||||
client!!.room.getAll().flattenValues().map { it.toList() }
|
|
||||||
}
|
|
||||||
val rooms by roomsFlow.collectAsState(initial = emptyList())
|
|
||||||
|
|
||||||
val currentRoom = remember(isRoomLikeScreen, rid, rooms) {
|
|
||||||
if (isRoomLikeScreen && rid != null) {
|
|
||||||
rooms.firstOrNull { it.roomId.toString() == rid }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
when {
|
|
||||||
isRoomLikeScreen -> {
|
|
||||||
RoomTopBar(
|
|
||||||
room = currentRoom,
|
|
||||||
fallbackTitle = rid ?: stringResource(R.string.app_name),
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
roomActionTitle != null -> {
|
|
||||||
RoomTopBar(
|
|
||||||
room = null,
|
|
||||||
fallbackTitle = roomActionTitle,
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
isSettingsScreen -> {
|
|
||||||
RoomTopBar(
|
|
||||||
room = null,
|
|
||||||
fallbackTitle = settingsTitleForRoute(route),
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
HomeTopBar(
|
|
||||||
syncState = syncState,
|
|
||||||
onAvatarClick = {
|
|
||||||
navController.navigate(SettingsRoutes.Graph) {
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun HomeTopBar(
|
|
||||||
syncState: SyncState,
|
|
||||||
onAvatarClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
CenterAlignedTopAppBar(
|
|
||||||
colors = topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
titleContentColor = MaterialTheme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
title = {
|
|
||||||
Text(syncState.toStatusTitle() ?: stringResource(R.string.app_name))
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
val client = client!!
|
|
||||||
|
|
||||||
var userName by remember { mutableStateOf("?") }
|
|
||||||
var userAvatar: String? by remember { mutableStateOf(null) }
|
|
||||||
|
|
||||||
LaunchedEffect(client, syncState) {
|
|
||||||
val profile = client.api.user
|
|
||||||
.getProfile(client.userId)
|
|
||||||
.getOrNull()
|
|
||||||
|
|
||||||
userName = profile?.displayName ?: "?"
|
|
||||||
userAvatar = profile?.avatarUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
IconButton(onClick = onAvatarClick) {
|
|
||||||
Avatar(
|
|
||||||
Modifier
|
|
||||||
.size(32.dp)
|
|
||||||
.clip(CircleShape),
|
|
||||||
userAvatar,
|
|
||||||
userName,
|
|
||||||
MaterialTheme.colorScheme.background
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun RoomTopBar(
|
|
||||||
room: TrixnityRoom?,
|
|
||||||
fallbackTitle: String,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
CenterAlignedTopAppBar(
|
|
||||||
colors = topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface.copy(.5f),
|
|
||||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
navigationIconContentColor = MaterialTheme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(
|
|
||||||
Iconsax.AutoMirrored.Outline.ArrowLeft2,
|
|
||||||
"To home",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
if (room != null) {
|
|
||||||
RoomTopBarTitle(room)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = fallbackTitle,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RoomTopBarTitle(room: TrixnityRoom) {
|
|
||||||
val title = remember(room) { room.displayName() }
|
|
||||||
val isSpace = room.type == CreateEventContent.RoomType.Space
|
|
||||||
val avatarShape = if (isSpace) RoundedCornerShape(8.dp) else CircleShape
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Avatar(
|
|
||||||
Modifier
|
|
||||||
.size(36.dp)
|
|
||||||
.clip(avatarShape),
|
|
||||||
room.avatarUrl,
|
|
||||||
title
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun TrixnityRoom.displayName(): String {
|
|
||||||
return name?.explicitName
|
|
||||||
?: name?.heroes?.firstNotNullOfOrNull { it.localpart }
|
|
||||||
?: roomId.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun SyncState.toStatusTitle(): String? {
|
|
||||||
return when (this) {
|
|
||||||
SyncState.INITIAL_SYNC -> "Initial sync..."
|
|
||||||
SyncState.STARTED -> "Syncing..."
|
|
||||||
SyncState.TIMEOUT -> "No network connection"
|
|
||||||
SyncState.ERROR -> "Error syncing"
|
|
||||||
SyncState.RUNNING,
|
|
||||||
SyncState.STOPPED -> null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
/*
|
|
||||||
* Created by sweetbread
|
|
||||||
* Copyright (c) 2026. All rights reserved.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package ru.risdeveau.pixeldragon.ui.item
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Avatar(
|
|
||||||
modifier: Modifier,
|
|
||||||
url: String?,
|
|
||||||
fallbackName: String,
|
|
||||||
fallbackColor: Color = MaterialTheme.colorScheme.secondaryContainer,
|
|
||||||
) {
|
|
||||||
val avatarModifier = modifier.clip(CircleShape)
|
|
||||||
|
|
||||||
if (url != null) {
|
|
||||||
MXCImage(
|
|
||||||
mxcUrl = url,
|
|
||||||
modifier = avatarModifier,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
contentDescription = fallbackName,
|
|
||||||
showProgress = false,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
AvatarPlaceholder(
|
|
||||||
avatarModifier,
|
|
||||||
fallbackName,
|
|
||||||
fallbackColor,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AvatarPlaceholder(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
fallbackName: String,
|
|
||||||
fallbackColor: Color,
|
|
||||||
) {
|
|
||||||
val initial = fallbackName
|
|
||||||
.trim()
|
|
||||||
.firstOrNull()
|
|
||||||
?.uppercaseChar()
|
|
||||||
?.toString()
|
|
||||||
?: "?"
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier.background(fallbackColor),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = initial,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* Created by sweetbread
|
* Created by sweetbread
|
||||||
* Copyright (c) 2026. All rights reserved.
|
* Copyright (c) 2025. All rights reserved.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package ru.risdeveau.pixeldragon.ui.item
|
package ru.risdeveau.pixeldragon.ui.item
|
||||||
@@ -8,137 +8,73 @@ package ru.risdeveau.pixeldragon.ui.item
|
|||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
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.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import coil3.compose.AsyncImagePainter
|
||||||
import coil3.compose.rememberAsyncImagePainter
|
import coil3.compose.rememberAsyncImagePainter
|
||||||
|
import coil3.network.NetworkHeaders
|
||||||
|
import coil3.network.httpHeaders
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import de.connect2x.trixnity.client.media
|
import ru.risdeveau.pixeldragon.api.mxcToUrl
|
||||||
import de.connect2x.trixnity.clientserverapi.model.media.FileTransferProgress
|
import ru.risdeveau.pixeldragon.token
|
||||||
import io.github.rabehx.iconsax.Iconsax
|
import splitties.init.appCtx
|
||||||
import io.github.rabehx.iconsax.outline.Warning2
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import ru.risdeveau.pixeldragon.client
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
|
|
||||||
enum class ImageLoadState {
|
enum class ImageLoadState {
|
||||||
Loading, Success, Error
|
Loading, Success, Error
|
||||||
}
|
}
|
||||||
|
|
||||||
private val mxcImageByteCache = ConcurrentHashMap<String, ByteArray>()
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MXCImage(
|
fun MXCImage(
|
||||||
mxcUrl: String,
|
mxcUrl: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentScale: ContentScale = ContentScale.Fit,
|
contentScale: ContentScale = ContentScale.Fit,
|
||||||
contentDescription: String = "",
|
contentDescription: String = ""
|
||||||
showProgress: Boolean = true
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
mxcToUrl(mxcUrl)?.let { url ->
|
||||||
val cachedBytes = remember(mxcUrl) { mxcImageByteCache[mxcUrl] }
|
val loadState = remember { mutableStateOf(ImageLoadState.Loading) }
|
||||||
var imageLoadState by remember(mxcUrl) {
|
val painter = rememberAsyncImagePainter(
|
||||||
mutableStateOf(if (cachedBytes != null) ImageLoadState.Success else ImageLoadState.Loading)
|
model = ImageRequest.Builder(appCtx)
|
||||||
}
|
.data(url)
|
||||||
var imageBytes by remember(mxcUrl) { mutableStateOf(cachedBytes) }
|
.httpHeaders(
|
||||||
|
NetworkHeaders.Builder()
|
||||||
val progressFlow = remember(mxcUrl) { MutableStateFlow<FileTransferProgress?>(null) }
|
.set("Authorization", "Bearer $token")
|
||||||
|
.set("Cache-Control", "max-age=86400")
|
||||||
LaunchedEffect(mxcUrl) {
|
.build()
|
||||||
if (mxcUrl.isBlank()) {
|
)
|
||||||
imageLoadState = ImageLoadState.Error
|
.build(),
|
||||||
return@LaunchedEffect
|
onState = { state ->
|
||||||
}
|
loadState.value = when (state) {
|
||||||
|
is AsyncImagePainter.State.Loading -> ImageLoadState.Loading
|
||||||
mxcImageByteCache[mxcUrl]?.let { bytes ->
|
is AsyncImagePainter.State.Success -> ImageLoadState.Success
|
||||||
imageBytes = bytes
|
else -> ImageLoadState.Error
|
||||||
imageLoadState = ImageLoadState.Success
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
|
|
||||||
imageLoadState = ImageLoadState.Loading
|
|
||||||
progressFlow.value = null
|
|
||||||
|
|
||||||
val result = client!!.media.getMedia(
|
|
||||||
uri = mxcUrl,
|
|
||||||
progress = progressFlow.takeIf { showProgress },
|
|
||||||
saveToCache = true
|
|
||||||
)
|
|
||||||
|
|
||||||
imageLoadState = result.fold(
|
|
||||||
onSuccess = { media ->
|
|
||||||
val bytes = media.toByteArray()
|
|
||||||
if (bytes != null) {
|
|
||||||
mxcImageByteCache[mxcUrl] = bytes
|
|
||||||
imageBytes = bytes
|
|
||||||
ImageLoadState.Success
|
|
||||||
} else {
|
|
||||||
ImageLoadState.Error
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onFailure = {
|
|
||||||
ImageLoadState.Error
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
val progress by progressFlow.collectAsState()
|
Box(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
val showProgressIndicator = showProgress &&
|
contentAlignment = Alignment.Center
|
||||||
imageLoadState == ImageLoadState.Loading &&
|
) {
|
||||||
progress != null
|
|
||||||
|
|
||||||
val painter = rememberAsyncImagePainter(
|
|
||||||
model = ImageRequest.Builder(context)
|
|
||||||
.data(imageBytes)
|
|
||||||
.memoryCacheKey(mxcUrl)
|
|
||||||
.diskCacheKey(mxcUrl)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
|
||||||
if (imageBytes != null) {
|
|
||||||
Image(
|
Image(
|
||||||
painter = painter,
|
painter = painter,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
when {
|
when (loadState.value) {
|
||||||
showProgressIndicator -> {
|
ImageLoadState.Loading -> CircularProgressIndicator()
|
||||||
progress?.let { p ->
|
ImageLoadState.Error -> Icon(Icons.Outlined.Warning, "Error")
|
||||||
val percent = p.total?.let { p.transferred.toFloat() / it }
|
ImageLoadState.Success -> Unit
|
||||||
if (percent == null) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
|
||||||
} else {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
progress = percent,
|
|
||||||
modifier = Modifier.align(Alignment.Center)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} ?: CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
|
||||||
}
|
|
||||||
imageLoadState == ImageLoadState.Loading -> {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
|
||||||
}
|
|
||||||
imageLoadState == ImageLoadState.Error -> {
|
|
||||||
Icon(
|
|
||||||
Iconsax.Outline.Warning2,
|
|
||||||
contentDescription = "Error",
|
|
||||||
modifier = Modifier.align(Alignment.Center)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
/*
|
|
||||||
* Created by sweetbread
|
|
||||||
* Copyright (c) 2026. All rights reserved.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package ru.risdeveau.pixeldragon.ui.layout
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
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.material3.Button
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
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.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.connect2x.trixnity.clientserverapi.model.room.CreateRoom
|
|
||||||
import de.connect2x.trixnity.clientserverapi.model.room.DirectoryVisibility
|
|
||||||
import de.connect2x.trixnity.core.model.RoomAliasId
|
|
||||||
import de.connect2x.trixnity.core.model.RoomId
|
|
||||||
import de.connect2x.trixnity.core.model.events.m.room.CreateEventContent
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import ru.risdeveau.pixeldragon.client
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun CreateRoomScreen(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
isSpace: Boolean,
|
|
||||||
onCreated: (RoomId) -> Unit,
|
|
||||||
) {
|
|
||||||
val matrixClient = client!!
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
var name by remember { mutableStateOf("") }
|
|
||||||
var topic by remember { mutableStateOf("") }
|
|
||||||
var alias by remember { mutableStateOf("") }
|
|
||||||
var isPublic by remember { mutableStateOf(false) }
|
|
||||||
var isCreating by remember { mutableStateOf(false) }
|
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (isSpace) "New space" else "New room",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = if (isSpace) {
|
|
||||||
"Spaces are useful for grouping related rooms."
|
|
||||||
} else {
|
|
||||||
"Create a room, then invite people or publish it in the directory."
|
|
||||||
},
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
value = name,
|
|
||||||
onValueChange = { name = it },
|
|
||||||
singleLine = true,
|
|
||||||
label = { Text(if (isSpace) "Space name" else "Room name") },
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
value = topic,
|
|
||||||
onValueChange = { topic = it },
|
|
||||||
minLines = 2,
|
|
||||||
label = { Text("Topic") },
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
value = alias,
|
|
||||||
onValueChange = { alias = it.trim() },
|
|
||||||
singleLine = true,
|
|
||||||
label = { Text("Local alias, optional") },
|
|
||||||
supportingText = {
|
|
||||||
Text("Example: pixeldragon creates #pixeldragon:${matrixClient.userId.domain}")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text("Publish in room directory")
|
|
||||||
Text(
|
|
||||||
text = "People can find it in public search. Join rules are still controlled by the server.",
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Switch(
|
|
||||||
checked = isPublic,
|
|
||||||
onCheckedChange = { isPublic = it },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
error?.let {
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
enabled = !isCreating && name.isNotBlank(),
|
|
||||||
onClick = {
|
|
||||||
isCreating = true
|
|
||||||
error = null
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
val aliasValue = alias.trim()
|
|
||||||
val roomAliasId = aliasValue.toRoomAliasIdOrNull(matrixClient.userId.domain)
|
|
||||||
|
|
||||||
if (aliasValue.isNotEmpty() && roomAliasId == null) {
|
|
||||||
error = "Invalid alias"
|
|
||||||
isCreating = false
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val result = matrixClient.api.room.createRoom(
|
|
||||||
visibility = if (isPublic) DirectoryVisibility.PUBLIC else DirectoryVisibility.PRIVATE,
|
|
||||||
roomAliasId = roomAliasId,
|
|
||||||
name = name.trim(),
|
|
||||||
topic = topic.trim().takeIf { it.isNotEmpty() },
|
|
||||||
preset = if (isPublic) CreateRoom.Request.Preset.PUBLIC else CreateRoom.Request.Preset.PRIVATE,
|
|
||||||
creationContent = if (isSpace) {
|
|
||||||
CreateEventContent(type = CreateEventContent.RoomType.Space)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
result.fold(
|
|
||||||
onSuccess = { roomId -> onCreated(roomId) },
|
|
||||||
onFailure = { throwable ->
|
|
||||||
error = throwable.message ?: "Could not create ${if (isSpace) "space" else "room"}"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
isCreating = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
if (isCreating) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
} else {
|
|
||||||
Text(if (isSpace) "Create space" else "Create room")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.toRoomAliasIdOrNull(defaultDomain: String): RoomAliasId? {
|
|
||||||
val value = trim()
|
|
||||||
if (value.isEmpty()) return null
|
|
||||||
|
|
||||||
val fullAlias = when {
|
|
||||||
value.startsWith("#") && value.contains(":") -> value
|
|
||||||
value.startsWith("#") -> "$value:$defaultDomain"
|
|
||||||
value.contains(":") -> "#$value"
|
|
||||||
else -> "#$value:$defaultDomain"
|
|
||||||
}
|
|
||||||
|
|
||||||
return RoomAliasId(fullAlias).takeIf { it.isValid }
|
|
||||||
}
|
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
/*
|
|
||||||
* Created by sweetbread
|
|
||||||
* Copyright (c) 2026. All rights reserved.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package ru.risdeveau.pixeldragon.ui.layout
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.AssistChip
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
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.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.connect2x.trixnity.clientserverapi.model.room.GetPublicRoomsResponse
|
|
||||||
import de.connect2x.trixnity.clientserverapi.model.room.GetPublicRoomsWithFilter
|
|
||||||
import de.connect2x.trixnity.core.model.RoomAliasId
|
|
||||||
import de.connect2x.trixnity.core.model.RoomId
|
|
||||||
import de.connect2x.trixnity.core.model.events.m.room.CreateEventContent
|
|
||||||
import de.connect2x.trixnity.core.model.events.m.room.JoinRulesEventContent
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import ru.risdeveau.pixeldragon.client
|
|
||||||
import ru.risdeveau.pixeldragon.ui.item.Avatar
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun JoinRoomScreen(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onJoined: (RoomId) -> Unit,
|
|
||||||
) {
|
|
||||||
val matrixClient = client!!
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
var query by remember { mutableStateOf("") }
|
|
||||||
var server by remember { mutableStateOf(matrixClient.userId.domain) }
|
|
||||||
var results by remember { mutableStateOf<List<GetPublicRoomsResponse.PublicRoomsChunk>>(emptyList()) }
|
|
||||||
var nextBatch by remember { mutableStateOf<String?>(null) }
|
|
||||||
var isSearching by remember { mutableStateOf(false) }
|
|
||||||
var isJoining by remember { mutableStateOf(false) }
|
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
val directTarget = remember(query) { query.parseJoinTarget() }
|
|
||||||
|
|
||||||
fun searchPublicRooms(since: String? = null) {
|
|
||||||
isSearching = true
|
|
||||||
error = null
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
val searchTerm = query.trim().takeUnless { directTarget != null }
|
|
||||||
val result = matrixClient.api.room.getPublicRooms(
|
|
||||||
limit = 30,
|
|
||||||
server = server.trim().ifEmpty { null },
|
|
||||||
since = since,
|
|
||||||
filter = GetPublicRoomsWithFilter.Request.Filter(
|
|
||||||
genericSearchTerm = searchTerm?.takeIf { it.isNotBlank() },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
result.fold(
|
|
||||||
onSuccess = { response ->
|
|
||||||
results = if (since == null) response.chunk else results + response.chunk
|
|
||||||
nextBatch = response.nextBatch
|
|
||||||
},
|
|
||||||
onFailure = { throwable ->
|
|
||||||
if (since == null) results = emptyList()
|
|
||||||
nextBatch = null
|
|
||||||
error = throwable.message ?: "Could not search rooms"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
isSearching = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun joinTarget(target: JoinTarget) {
|
|
||||||
isJoining = true
|
|
||||||
error = null
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
val result = when (target) {
|
|
||||||
is JoinTarget.Alias -> matrixClient.api.room.joinRoom(
|
|
||||||
roomAliasId = RoomAliasId(target.alias),
|
|
||||||
via = target.via.takeIf { it.isNotEmpty() },
|
|
||||||
)
|
|
||||||
is JoinTarget.Room -> matrixClient.api.room.joinRoom(
|
|
||||||
roomId = RoomId(target.roomId),
|
|
||||||
via = target.via.takeIf { it.isNotEmpty() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.fold(
|
|
||||||
onSuccess = { roomId -> onJoined(roomId) },
|
|
||||||
onFailure = { throwable ->
|
|
||||||
error = throwable.message ?: "Could not join room"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
isJoining = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(query, server) {
|
|
||||||
delay(450)
|
|
||||||
if (query.parseJoinTarget() == null) {
|
|
||||||
searchPublicRooms()
|
|
||||||
} else {
|
|
||||||
results = emptyList()
|
|
||||||
nextBatch = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
modifier = modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
Text(
|
|
||||||
text = "Join room or space",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Search a public room directory, paste #alias:server, !roomId:server or a matrix.to link.",
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
value = query,
|
|
||||||
onValueChange = { query = it },
|
|
||||||
singleLine = true,
|
|
||||||
label = { Text("Search, alias or Matrix link") },
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
value = server,
|
|
||||||
onValueChange = { server = it.trim() },
|
|
||||||
singleLine = true,
|
|
||||||
label = { Text("Directory server") },
|
|
||||||
supportingText = { Text("Leave current homeserver or enter another server, for example matrix.org") },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
directTarget?.let { target ->
|
|
||||||
item {
|
|
||||||
ElevatedCard(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.elevatedCardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Direct join",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = target.id,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
if (target.via.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = "via ${target.via.joinToString()}",
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
enabled = !isJoining,
|
|
||||||
onClick = { joinTarget(target) },
|
|
||||||
) {
|
|
||||||
if (isJoining) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
} else {
|
|
||||||
Text("Join")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
error?.let {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (directTarget == null) {
|
|
||||||
item {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (query.isBlank()) "Public directory" else "Search results",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isSearching) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isSearching && results.isEmpty()) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
text = "No rooms found",
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
items(
|
|
||||||
items = results,
|
|
||||||
key = { it.roomId.full },
|
|
||||||
) { room ->
|
|
||||||
PublicRoomItem(
|
|
||||||
room = room,
|
|
||||||
isJoining = isJoining,
|
|
||||||
onJoin = {
|
|
||||||
joinTarget(
|
|
||||||
JoinTarget.Room(
|
|
||||||
roomId = room.roomId.full,
|
|
||||||
via = room.canonicalAlias?.domain?.let { setOf(it) }.orEmpty(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onKnock = {
|
|
||||||
isJoining = true
|
|
||||||
error = null
|
|
||||||
scope.launch {
|
|
||||||
val result = matrixClient.api.room.knockRoom(room.roomId)
|
|
||||||
result.fold(
|
|
||||||
onSuccess = { roomId -> onJoined(roomId) },
|
|
||||||
onFailure = { throwable -> error = throwable.message ?: "Could not knock room" },
|
|
||||||
)
|
|
||||||
isJoining = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
nextBatch?.let { since ->
|
|
||||||
item {
|
|
||||||
OutlinedButton(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
enabled = !isSearching,
|
|
||||||
onClick = { searchPublicRooms(since) },
|
|
||||||
) {
|
|
||||||
Text("Load more")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PublicRoomItem(
|
|
||||||
room: GetPublicRoomsResponse.PublicRoomsChunk,
|
|
||||||
isJoining: Boolean,
|
|
||||||
onJoin: () -> Unit,
|
|
||||||
onKnock: () -> Unit,
|
|
||||||
) {
|
|
||||||
val isSpace = room.roomType == CreateEventContent.RoomType.Space
|
|
||||||
val title = room.name ?: room.canonicalAlias?.full ?: room.roomId.full
|
|
||||||
val avatarShape = if (isSpace) RoundedCornerShape(12.dp) else CircleShape
|
|
||||||
val canKnock = room.joinRule == JoinRulesEventContent.JoinRule.Knock ||
|
|
||||||
room.joinRule == JoinRulesEventContent.JoinRule.KnockRestricted
|
|
||||||
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Avatar(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(48.dp)
|
|
||||||
.clip(avatarShape),
|
|
||||||
url = room.avatarUrl,
|
|
||||||
fallbackName = title,
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
room.canonicalAlias?.let {
|
|
||||||
Text(
|
|
||||||
text = it.full,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
room.topic?.takeIf { it.isNotBlank() }?.let {
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
maxLines = 3,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
AssistChip(
|
|
||||||
onClick = {},
|
|
||||||
label = { Text(if (isSpace) "space" else "room") },
|
|
||||||
)
|
|
||||||
AssistChip(
|
|
||||||
onClick = {},
|
|
||||||
label = { Text(room.joinRule.name) },
|
|
||||||
)
|
|
||||||
AssistChip(
|
|
||||||
onClick = {},
|
|
||||||
label = { Text("${room.joinedMembersCount} members") },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Button(
|
|
||||||
enabled = !isJoining,
|
|
||||||
onClick = onJoin,
|
|
||||||
) {
|
|
||||||
Text("Join")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canKnock) {
|
|
||||||
OutlinedButton(
|
|
||||||
enabled = !isJoining,
|
|
||||||
onClick = onKnock,
|
|
||||||
) {
|
|
||||||
Text("Knock")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed interface JoinTarget {
|
|
||||||
val id: String
|
|
||||||
val via: Set<String>
|
|
||||||
|
|
||||||
data class Alias(
|
|
||||||
val alias: String,
|
|
||||||
override val via: Set<String> = emptySet(),
|
|
||||||
) : JoinTarget {
|
|
||||||
override val id: String = alias
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Room(
|
|
||||||
val roomId: String,
|
|
||||||
override val via: Set<String> = emptySet(),
|
|
||||||
) : JoinTarget {
|
|
||||||
override val id: String = roomId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.parseJoinTarget(): JoinTarget? {
|
|
||||||
val value = trim()
|
|
||||||
if (value.isEmpty()) return null
|
|
||||||
|
|
||||||
if (value.startsWith("https://matrix.to/#/") || value.startsWith("http://matrix.to/#/")) {
|
|
||||||
return value.parseMatrixToTarget()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.startsWith("#") && RoomAliasId(value).isValid) {
|
|
||||||
return JoinTarget.Alias(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.startsWith("!") && RoomId(value).isReasonable) {
|
|
||||||
return JoinTarget.Room(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.parseMatrixToTarget(): JoinTarget? {
|
|
||||||
val fragment = Uri.parse(this).fragment.orEmpty().removePrefix("/")
|
|
||||||
if (fragment.isBlank()) return null
|
|
||||||
|
|
||||||
val idPart = fragment.substringBefore('?')
|
|
||||||
val queryPart = fragment.substringAfter('?', missingDelimiterValue = "")
|
|
||||||
val id = Uri.decode(idPart)
|
|
||||||
val via = Uri.parse("https://matrix.to/?$queryPart")
|
|
||||||
.getQueryParameters("via")
|
|
||||||
.filter { it.isNotBlank() }
|
|
||||||
.toSet()
|
|
||||||
|
|
||||||
return when {
|
|
||||||
id.startsWith("#") && RoomAliasId(id).isValid -> JoinTarget.Alias(id, via)
|
|
||||||
id.startsWith("!") && RoomId(id).isReasonable -> JoinTarget.Room(id, via)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,110 +0,0 @@
|
|||||||
/*
|
|
||||||
* Created by sweetbread
|
|
||||||
* Copyright (c) 2026. All rights reserved.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package ru.risdeveau.pixeldragon.ui.layout
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
object RoomActionsRoutes {
|
|
||||||
const val Rooms = "rooms"
|
|
||||||
const val JoinRoom = "join-room"
|
|
||||||
const val CreateRoom = "create-room"
|
|
||||||
const val CreateSpace = "create-space"
|
|
||||||
|
|
||||||
fun titleFor(route: String?): String? {
|
|
||||||
return when (route) {
|
|
||||||
JoinRoom -> "Join room or space"
|
|
||||||
CreateRoom -> "Create room"
|
|
||||||
CreateSpace -> "Create space"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun RoomActionsSheet(
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onJoinRoom: () -> Unit,
|
|
||||||
onCreateRoom: () -> Unit,
|
|
||||||
onCreateSpace: () -> Unit,
|
|
||||||
) {
|
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(24.dp),
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
RoomActionItem(
|
|
||||||
title = "Join room or space",
|
|
||||||
subtitle = "Search public directories or paste a Matrix link",
|
|
||||||
onClick = {
|
|
||||||
onDismiss()
|
|
||||||
onJoinRoom()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
|
|
||||||
|
|
||||||
RoomActionItem(
|
|
||||||
title = "Create room",
|
|
||||||
subtitle = "Start a public or private room",
|
|
||||||
onClick = {
|
|
||||||
onDismiss()
|
|
||||||
onCreateRoom()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
|
|
||||||
|
|
||||||
RoomActionItem(
|
|
||||||
title = "Create space",
|
|
||||||
subtitle = "Group related rooms together",
|
|
||||||
onClick = {
|
|
||||||
onDismiss()
|
|
||||||
onCreateSpace()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RoomActionItem(
|
|
||||||
title: String,
|
|
||||||
subtitle: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
|
||||||
headlineContent = { Text(title) },
|
|
||||||
supportingContent = { Text(subtitle) },
|
|
||||||
trailingContent = { Text("›") },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
/*
|
/*
|
||||||
* Created by sweetbread
|
* Created by sweetbread
|
||||||
* Copyright (c) 2026. All rights reserved.
|
* Copyright (c) 2025. All rights reserved.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package ru.risdeveau.pixeldragon.ui.layout
|
package ru.risdeveau.pixeldragon.ui.layout
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -23,27 +21,24 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
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.collectAsState
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.Dispatchers
|
||||||
import de.connect2x.trixnity.client.flattenValues
|
import kotlinx.coroutines.withContext
|
||||||
import de.connect2x.trixnity.client.room
|
import ru.risdeveau.pixeldragon.repo.Room
|
||||||
import de.connect2x.trixnity.client.store.Room
|
|
||||||
import de.connect2x.trixnity.client.store.type
|
|
||||||
import de.connect2x.trixnity.core.model.events.m.room.CreateEventContent
|
|
||||||
import ru.risdeveau.pixeldragon.client
|
|
||||||
import ru.risdeveau.pixeldragon.ui.item.MXCImage
|
import ru.risdeveau.pixeldragon.ui.item.MXCImage
|
||||||
|
|
||||||
@SuppressLint("FlowOperatorInvokedInComposition")
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomList(modifier: Modifier = Modifier, navController: NavController) {
|
fun RoomList(modifier: Modifier = Modifier, navController: NavController) {
|
||||||
val rooms by client!!.room.getAll().flattenValues().map { it.toList() }.collectAsState(initial = emptyList())
|
var list by remember { mutableStateOf(listOf<Room>()) }
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
// if (itemState.scrollToTop) {
|
// if (itemState.scrollToTop) {
|
||||||
@@ -58,12 +53,12 @@ fun RoomList(modifier: Modifier = Modifier, navController: NavController) {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
LazyColumn(modifier = modifier, state = listState) {
|
LazyColumn(modifier = modifier, state = listState) {
|
||||||
items(rooms) { room ->
|
items(list) { room ->
|
||||||
RoomItem(room = room, navController = navController )
|
RoomItem(room = room, navController = navController )
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
if (rooms.isEmpty()) {
|
if (list.isEmpty()) {
|
||||||
Text("You have no rooms")
|
Text("You have no rooms")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,9 +67,8 @@ fun RoomList(modifier: Modifier = Modifier, navController: NavController) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomItem(modifier: Modifier = Modifier, room: Room, navController: NavController) {
|
fun RoomItem(modifier: Modifier = Modifier, room: Room, navController: NavController) {
|
||||||
val name = room.name
|
val avatarUrl = room.avatarUrl ?: (room.direct?.avatarUrl ?: "")
|
||||||
val isSpace = room.type == CreateEventContent.RoomType.Space
|
val name = room.name ?: (room.direct?.name ?: "Unnamed")
|
||||||
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier
|
modifier
|
||||||
@@ -83,46 +77,42 @@ fun RoomItem(modifier: Modifier = Modifier, room: Room, navController: NavContro
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(
|
.background(
|
||||||
color =
|
color =
|
||||||
if (isSpace)
|
if (room.type == "m.space")
|
||||||
MaterialTheme.colorScheme.tertiary
|
MaterialTheme.colorScheme.tertiary
|
||||||
else
|
else
|
||||||
MaterialTheme.colorScheme.background
|
MaterialTheme.colorScheme.background
|
||||||
)
|
)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.clickable {
|
.clickable {
|
||||||
if (isSpace)
|
if (room.type == "m.space")
|
||||||
navController.navigate("space/${room.roomId}")
|
navController.navigate("space/${room.id}")
|
||||||
else
|
else
|
||||||
navController.navigate("room/${room.roomId}")
|
navController.navigate("room/${room.id}")
|
||||||
}
|
}
|
||||||
.padding(8.dp)
|
.padding(8.dp)
|
||||||
) {
|
) {
|
||||||
Log.v("RoomItem", room.avatarUrl.toString())
|
MXCImage(
|
||||||
room.avatarUrl?.let { mxc ->
|
avatarUrl,
|
||||||
MXCImage(
|
modifier = Modifier
|
||||||
mxc,
|
.padding(end = 4.dp)
|
||||||
modifier = Modifier
|
.height(52.dp)
|
||||||
.padding(end = 4.dp)
|
.width(52.dp)
|
||||||
.height(52.dp)
|
.let {
|
||||||
.width(52.dp)
|
if (room.type == "m.space")
|
||||||
.let {
|
it.clip(RoundedCornerShape(12.dp))
|
||||||
if (isSpace)
|
else
|
||||||
it.clip(RoundedCornerShape(12.dp))
|
it.clip(CircleShape)
|
||||||
else
|
},
|
||||||
it.clip(CircleShape)
|
ContentScale.Crop
|
||||||
},
|
)
|
||||||
ContentScale.Crop
|
Column {
|
||||||
|
Text(room.type)
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
maxLines = 1,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontSize = MaterialTheme.typography.titleLarge.fontSize
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column {
|
|
||||||
(name?.explicitName ?: name?.heroes?.firstNotNullOf {it.localpart})?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
maxLines = 1,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
fontSize = MaterialTheme.typography.titleLarge.fontSize
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +0,0 @@
|
|||||||
/*
|
|
||||||
* Created by sweetbread
|
|
||||||
* Copyright (c) 2026. All rights reserved.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package ru.risdeveau.pixeldragon.util
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.room.Room
|
|
||||||
import de.connect2x.trixnity.client.MediaStoreModule
|
|
||||||
import de.connect2x.trixnity.client.RepositoriesModule
|
|
||||||
import de.connect2x.trixnity.client.media.okio.okio
|
|
||||||
import de.connect2x.trixnity.client.store.repository.room.TrixnityRoomDatabase
|
|
||||||
import de.connect2x.trixnity.client.store.repository.room.room
|
|
||||||
import okio.Path.Companion.toPath
|
|
||||||
import splitties.init.appCtx
|
|
||||||
|
|
||||||
fun getMediaStore() = MediaStoreModule.okio(appCtx.filesDir.resolve("media").absolutePath.toPath())
|
|
||||||
fun getRoomStore(context: Context) = RepositoriesModule.room(
|
|
||||||
databaseBuilder = Room.databaseBuilder<TrixnityRoomDatabase>(
|
|
||||||
context,
|
|
||||||
"trixnity.db"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
+5
-3
@@ -1,6 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* Created by sweetbread
|
* Created by sweetbread on 22.02.2025, 15:45
|
||||||
* Copyright (c) 2026. All rights reserved.
|
* Copyright (c) 2025. All rights reserved.
|
||||||
|
* Last modified 21.02.2025, 12:21
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
@@ -8,4 +9,5 @@ plugins {
|
|||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.kotlin.android) apply false
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
}
|
id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false
|
||||||
|
}
|
||||||
+12
-20
@@ -1,24 +1,20 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "9.1.0"
|
agp = "8.13.0"
|
||||||
coil = "3.4.0"
|
coil = "3.1.0"
|
||||||
iconsaxCompose = "0.0.5"
|
jsoup = "1.20.1"
|
||||||
jsoup = "1.22.1"
|
kotlin = "2.0.21"
|
||||||
kotlin = "2.2.21"
|
|
||||||
coreKtx = "1.15.0"
|
coreKtx = "1.15.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.3.0"
|
junitVersion = "1.2.1"
|
||||||
espressoCore = "3.7.0"
|
espressoCore = "3.6.1"
|
||||||
kotlinxSerializationJson = "1.7.3"
|
kotlinxSerializationJson = "1.7.3"
|
||||||
ktor = "3.4.1"
|
ktor = "3.1.0"
|
||||||
lifecycleRuntimeKtx = "2.8.7"
|
lifecycleRuntimeKtx = "2.8.7"
|
||||||
activityCompose = "1.10.0"
|
activityCompose = "1.10.0"
|
||||||
composeBom = "2026.03.00"
|
composeBom = "2025.02.00"
|
||||||
navigationCompose = "2.9.7"
|
navigationCompose = "2.8.8"
|
||||||
room = "2.6.1"
|
room = "2.6.1"
|
||||||
splittiesFunPackAndroidBase = "3.0.0"
|
splittiesFunPackAndroidBase = "3.0.0"
|
||||||
trixnityClient = "5.5.2"
|
|
||||||
unifiedPushConnector = "3.3.2"
|
|
||||||
uiUnit = "1.10.6"
|
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -28,11 +24,11 @@ androidx-navigation-dynamic-features-fragment = { module = "androidx.navigation:
|
|||||||
androidx-navigation-fragment = { module = "androidx.navigation:navigation-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-testing = { module = "androidx.navigation:navigation-testing", version.ref = "navigationCompose" }
|
||||||
androidx-navigation-ui = { module = "androidx.navigation:navigation-ui", 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-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||||
androidx-room-runtime = { module = "androidx.room:room-runtime", 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-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" }
|
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
|
||||||
iconsax-compose = { module = "io.github.rabehx:iconsax-compose", version.ref = "iconsaxCompose" }
|
|
||||||
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
|
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
@@ -47,18 +43,14 @@ 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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
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-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||||
ktor-client-core = { module = "io.ktor:ktor-client-core", 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" }
|
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" }
|
splitties-base = { module = "com.louiscad.splitties:splitties-fun-pack-android-base", version.ref = "splittiesFunPackAndroidBase" }
|
||||||
trixnity-client = { module = "de.connect2x.trixnity:trixnity-client", version.ref = "trixnityClient" }
|
|
||||||
trixnity-client-media-okio = { module = "de.connect2x.trixnity:trixnity-client-media-okio", version.ref = "trixnityClient" }
|
|
||||||
trixnity-client-repository-room = { module = "de.connect2x.trixnity:trixnity-client-repository-room", version.ref = "trixnityClient" }
|
|
||||||
trixnity-client-cryptodriver-vodozemac = { module = "de.connect2x.trixnity:trixnity-client-cryptodriver-vodozemac", version.ref = "trixnityClient" }
|
|
||||||
unifiedpush-connector = { module = "org.unifiedpush.android:connector", version.ref = "unifiedPushConnector" }
|
|
||||||
androidx-compose-ui-unit = { group = "androidx.compose.ui", name = "ui-unit", version.ref = "uiUnit" }
|
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -6,6 +6,6 @@
|
|||||||
#Thu Feb 20 10:45:47 GMT 2025
|
#Thu Feb 20 10:45:47 GMT 2025
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
+3
-6
@@ -1,6 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* Created by sweetbread
|
* Created by sweetbread on 21.02.2025, 12:00
|
||||||
* Copyright (c) 2026. All rights reserved.
|
* Copyright (c) 2025. All rights reserved.
|
||||||
|
* Last modified 21.02.2025, 12:00
|
||||||
*/
|
*/
|
||||||
|
|
||||||
pluginManagement {
|
pluginManagement {
|
||||||
@@ -16,15 +17,11 @@ pluginManagement {
|
|||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
plugins {
|
|
||||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
|
||||||
}
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven("https://gitlab.com/api/v4/projects/47538655/packages/maven")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user