45 Commits

Author SHA1 Message Date
Sweetbread 2092de3e9a fix: round avatar 2026-05-08 20:37:45 +03:00
Sweetbread 098c9fe2aa feat: device verification 2026-05-08 07:03:27 +03:00
Sweetbread 6bf33a91c9 feat: join and create rooms 2026-05-07 06:54:43 +03:00
Sweetbread 7a5889a351 feat: Unified Push 2026-05-07 05:41:13 +03:00
Sweetbread 11ad22c818 wip: settings 2026-05-07 04:24:24 +03:00
Sweetbread 16fbd40907 fix: close message field after closing a keyboard,
change design
2026-04-23 22:26:14 +03:00
Sweetbread f2ab63887a refactor: Avatar function 2026-04-23 22:26:14 +03:00
Sweetbread 52ca540bca Fix message loading 2026-04-23 22:26:14 +03:00
Sweetbread a89b2c36a7 New TopBar 2026-04-23 22:26:14 +03:00
Sweetbread 56cb0ea39c fixup! Message bubbles 2026-04-23 22:26:14 +03:00
Sweetbread 6a8e1780d7 Sticky avatar 2026-04-23 22:26:13 +03:00
Sweetbread fc331f726e Sticky date 2026-04-23 22:13:28 +03:00
Sweetbread 06b5ce326c fixup! Message bubbles 2026-04-23 22:13:10 +03:00
Sweetbread 414eeae4f6 Message bubbles 2026-04-23 21:19:19 +03:00
Sweetbread 0001de3128 Display and send messages 2026-04-23 21:19:19 +03:00
Sweetbread a0185b2bb8 wip: show messages 2026-04-23 21:19:08 +03:00
Sweetbread 9159665301 update 2026-04-23 21:19:07 +03:00
Sweetbread 6adb462226 wip: migrate to Trixnity 2026-04-23 21:19:07 +03:00
Sweetbread b6e8c73758 ref: open Login from MainActivity, not vice versa 2025-11-08 20:40:56 +03:00
Sweetbread 28337b1306 ref: async and thread stuff 2025-11-05 01:23:35 +03:00
Sweetbread 05d3d739e2 feat: check for internet connection before sending API requests 2025-11-05 00:06:04 +03:00
Sweetbread 5c6cd29a05 fix: change mxc regex pattern 2025-11-04 23:32:22 +03:00
Sweetbread 70d9db6cbf ref: caching joined rooms 2025-11-04 23:24:20 +03:00
Sweetbread 7026acc229 fixup! ref: reformat caching data in Rooms 2025-11-04 23:23:52 +03:00
Sweetbread fa53e0ae86 ref: remove coroutineScope 2025-11-04 20:42:11 +03:00
Sweetbread ebbc9a4b1f ref: reformat caching data in Rooms 2025-11-04 20:30:22 +03:00
Sweetbread 05fbfbac07 feat: show room avatar when is DM 2025-11-04 16:01:14 +03:00
Sweetbread 5897d31a51 ref: SharedPref to Splitties 2025-11-04 15:19:50 +03:00
Sweetbread a0bfba23cf dev: Highlight m.spaces 2025-11-03 23:52:43 +03:00
Sweetbread 00f273c866 deps: update AGP 2025-11-03 23:44:59 +03:00
Sweetbread 0384626d83 feat: change message style 2025-06-06 01:23:41 +03:00
Sweetbread ea783c9f27 fixup! feat: add image display 2025-06-06 01:16:50 +03:00
Sweetbread 4236c4342b fixup! wip: feat: add formated text display 2025-06-06 01:09:06 +03:00
Sweetbread c40b13a7ea fixup! ref: add MXCImage 2025-06-05 22:15:07 +03:00
Sweetbread de205b739f feat: add image display 2025-06-05 21:50:39 +03:00
Sweetbread 8ac9ccfaca ref: add MXCImage 2025-06-05 21:50:19 +03:00
Sweetbread 7a2567f019 wip: feat: add formated text display 2025-06-05 19:11:43 +03:00
Sweetbread 26417b8072 fix: add a clip 2025-06-05 00:33:51 +03:00
Sweetbread 23780489f6 feat: Messages 2025-03-04 00:30:01 +03:00
Sweetbread bd87ca2729 fix: Finish LoginActivity 2025-03-03 19:15:04 +03:00
Sweetbread 7ba5876a71 impr: Change room elements style
Make room elements smaller and change background color
2025-03-03 18:55:38 +03:00
Sweetbread cab56d6329 feat: Delegate homeserver
Now if matrix homeserver on matrix.example.com, example.com will be correct too
2025-03-03 18:44:07 +03:00
Sweetbread e70049f1f5 feat: Round avatars 2025-02-25 00:39:22 +03:00
Sweetbread c0944ec0a8 wip: Get room info serially 2025-02-22 21:18:16 +03:00
Sweetbread 5fbffd8700 wip: Room list 2025-02-22 21:18:16 +03:00
40 changed files with 6361 additions and 206 deletions
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+1 -5
View File
@@ -1,7 +1,3 @@
<component name="CopyrightManager"> <component name="CopyrightManager">
<settings default="My"> <settings default="My" />
<module2copyright>
<element module="All" copyright="My" />
</module2copyright>
</settings>
</component> </component>
+8
View File
@@ -4,6 +4,14 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-02-22T11:46:39.159466074Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=22163a3c" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>
+3
View File
@@ -49,6 +49,9 @@
<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" />
+1 -1
View File
@@ -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="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="2.0.0" /> <option name="version" value="2.0.21" />
</component> </component>
</project> </project>
+4
View File
@@ -5,8 +5,12 @@
<set> <set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" /> <option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" /> <option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" /> <option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" /> <option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set> </set>
</option> </option>
</component> </component>
Generated Executable
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>
Generated
+1 -1
View File
@@ -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="$PROJECT_DIR$" vcs="Git" /> <mapping directory="" vcs="Git" />
</component> </component>
</project> </project>
+48 -14
View File
@@ -1,22 +1,28 @@
/* /*
* Created by sweetbread on 21.02.2025, 12:01 * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
* Last modified 21.02.2025, 12:01 */
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)
} }
android { android {
namespace = "ru.risdeveau.pixeldragon" namespace = "ru.risdeveau.pixeldragon"
compileSdk = 35 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "ru.risdeveau.pixeldragon" applicationId = "ru.risdeveau.pixeldragon"
minSdk = 24 minSdk = 28
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
@@ -34,17 +40,20 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "1.8"
} }
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)
@@ -54,6 +63,7 @@ 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)
@@ -62,14 +72,38 @@ 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.cio) implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.logging)
// Coil - image loader // Coil - image loader
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp) implementation(libs.coil.network.okhttp)
// Room - database
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.room.ktx)
// ksp(libs.androidx.room.compiler)
// Navigation Compose
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation.fragment)
implementation(libs.androidx.navigation.ui)
implementation(libs.androidx.navigation.dynamic.features.fragment)
androidTestImplementation(libs.androidx.navigation.testing)
// Others // Others
implementation(libs.splitties.base) // Syntax sugar implementation(libs.splitties.base) // Syntax sugar
implementation(libs.jsoup) // HTML parser
implementation(libs.iconsax.compose) // Material icons
implementation(libs.unifiedpush.connector) // UnifiedPush / ntfy notifications
} }
+16 -9
View File
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
~ Created by sweetbread on 21.02.2025, 12:08 ~ Created by sweetbread
~ Copyright (c) 2025. All rights reserved. ~ Copyright (c) 2026. All rights reserved.
~ Last modified 21.02.2025, 12:07
--> -->
<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"
@@ -20,15 +20,9 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.PixelDragon" android:theme="@style/Theme.PixelDragon"
tools:targetApi="31"> tools:targetApi="31">
<activity
android:name=".ui.activity.Login"
android:exported="false"
android:label="@string/title_activity_login"
android:theme="@style/Theme.PixelDragon" />
<activity <activity
android:name=".ui.activity.MainActivity" android:name=".ui.activity.MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.PixelDragon"> android:theme="@style/Theme.PixelDragon">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -36,6 +30,19 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".ui.activity.Login"
android:exported="false"
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,43 +1,29 @@
/* /*
* Created by sweetbread on 21.02.2025, 12:01 * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
* Last modified 21.02.2025, 12:01
*/ */
package ru.risdeveau.pixeldragon package ru.risdeveau.pixeldragon
import android.content.Context import android.util.Log
import de.connect2x.trixnity.client.MatrixClient
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.engine.cio.endpoint
import io.ktor.client.plugins.cache.HttpCache import io.ktor.client.plugins.cache.HttpCache
import ru.risdeveau.pixeldragon.api.getMe import io.ktor.client.plugins.logging.LogLevel
import splitties.init.appCtx import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
val client = HttpClient(CIO) { val webClient = HttpClient {
engine { install(Logging) {
endpoint { logger = object : Logger {
maxConnectionsPerRoute = 100 override fun log(message: String) {
pipelineMaxSize = 20 Log.i("Ktor", message)
keepAliveTime = 30000 }
connectTimeout = 15000
connectAttempts = 5
} }
level = LogLevel.ALL
} }
install(HttpCache) install(HttpCache)
} }
val accountData = appCtx.getSharedPreferences("settings", Context.MODE_PRIVATE) var client: MatrixClient? = null
lateinit var urlBase: String
lateinit var token: String
suspend fun initCheck(): Boolean {
if (!accountData.contains("token")) return false
if (!accountData.contains("homeserver")) return false
token = accountData.getString("token", "").toString()
urlBase = "https://${accountData.getString("homeserver", "")}/_matrix/client/v3"
return getMe() != null
}
+50
View File
@@ -0,0 +1,50 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon.api
import org.json.JSONObject
import java.time.Instant
class Event (
val id: String,
val rid: String,
val type: String,
val content: JSONObject,
val time: Instant,
val sender: String
) {
// constructor(json: JSONObject) : this(
// json.getString("event_id"),
// json.getString("room_id"),
// json.getString("sender"),
// json.getString("type"),
// json.getJSONObject("content")
// )
}
//data class EventsAround (
// val base: Event,
// val before: List<Event>,
// val after: List<Event>
//)
//
//suspend fun getEventsAround(room: String, event: String): EventsAround {
// val r = client.get("$baseUrl/rooms/$room/context/$event") {
// bearerAuth(token)
// parameter("limit", "50")
// }
// val json = JSONObject(r.bodyAsText())
//
// return EventsAround(
// Event(json.getJSONObject("event")),
// if (json.has("events_before")) json.getJSONArray("events_before").let {
// List<Event>(it.length()) { i -> Event(it.getJSONObject(i))}.reversed()
// } else listOf(),
// if (json.has("events_after")) json.getJSONArray("events_after").let {
// List<Event>(it.length()) { i -> Event(it.getJSONObject(i))}
// } else listOf()
// )
//}
+4
View File
@@ -0,0 +1,4 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
@@ -1,23 +1,44 @@
/* /*
* Created by sweetbread on 21.02.2025, 12:01 * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
* Last modified 21.02.2025, 12:01
*/ */
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.client import ru.risdeveau.pixeldragon.webClient
//import ru.risdeveau.pixeldragon.homeserver
suspend fun isMatrixServer(url: String): Boolean { suspend fun getHomeserver(url: String): String? {
val r = try { client.get("https://$url/.well-known/matrix/client") } val r = try { webClient.get("https://$url/.well-known/matrix/client") }
catch (_: Exception) { return false } catch (e: Exception) {
Log.w("getHomeserver", "Fail sending the request", e)
return null
}
try { JSONObject(r.bodyAsText()) } val json = try { JSONObject(r.bodyAsText()) }
catch (_: JSONException) { return false } catch (e: JSONException) {
Log.w("getHomeserver", "Fail parsing the JSON", e)
return null
}
return true if (!json.has("m.homeserver")) return null
}
var homeserver = json.getJSONObject("m.homeserver").getString("base_url")
if (homeserver.endsWith("/")) homeserver = homeserver.dropLast(1)
return homeserver
}
//fun mxcToUrl(mxc: String): String? {
// val pattern = Regex("mxc://([-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6})/([^#?]+)")
// val match = pattern.find(mxc)
//
// if ((match?.groupValues[1] == null) or (match?.groupValues[2] == null)) return null
//
// return "$homeserver/_matrix/client/v1/media/download/${match?.groupValues[1]}/${match?.groupValues[2]}"
//}
@@ -1,82 +1,49 @@
/* /*
* Created by sweetbread on 21.02.2025, 12:09 * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
* Last modified 21.02.2025, 12:01
*/ */
package ru.risdeveau.pixeldragon.api package ru.risdeveau.pixeldragon.api
import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import io.ktor.client.request.bearerAuth import de.connect2x.trixnity.client.CryptoDriverModule
import io.ktor.client.request.get import io.ktor.http.Url
import io.ktor.client.request.post import de.connect2x.trixnity.client.MatrixClient
import io.ktor.client.request.setBody import de.connect2x.trixnity.client.create
import io.ktor.client.statement.bodyAsText import de.connect2x.trixnity.client.cryptodriver.vodozemac.vodozemac
import io.ktor.http.ContentType import de.connect2x.trixnity.clientserverapi.client.MatrixClientAuthProviderData
import io.ktor.http.HttpStatusCode import de.connect2x.trixnity.clientserverapi.client.classicLogin
import io.ktor.http.contentType import de.connect2x.trixnity.clientserverapi.model.authentication.IdentifierType
import org.json.JSONObject
import ru.risdeveau.pixeldragon.accountData
import ru.risdeveau.pixeldragon.client import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.initCheck import ru.risdeveau.pixeldragon.util.getMediaStore
import ru.risdeveau.pixeldragon.token import ru.risdeveau.pixeldragon.util.getRoomStore
import ru.risdeveau.pixeldragon.urlBase import splitties.experimental.ExperimentalSplittiesApi
import splitties.init.appCtx import splitties.init.appCtx
data class Me (val userId: String, val deviceId: String)
/** @OptIn(ExperimentalSplittiesApi::class)
* This func is to validate the token suspend fun login(server: String, login: String, pass: String): Boolean {
*/ val hs = Url(getHomeserver(server)!!)
suspend fun getMe(): Me? {
val r = client.get("$urlBase/account/whoami") { bearerAuth(token) }
if (r.status != HttpStatusCode.OK) {
Log.e("getMe", r.bodyAsText())
return null
}
val json = JSONObject(r.bodyAsText()) val pinfo = appCtx.packageManager.getPackageInfo(appCtx.packageName, 0)
return Me(json.getString("user_id"), json.getString("device_id"))
}
@SuppressLint("ApplySharedPref") try {
suspend fun login(homeserver: String, login: String, pass: String): Boolean { client = MatrixClient.create(
val pinfo = appCtx.packageManager.getPackageInfo(appCtx.packageName, 0); repositoriesModule = getRoomStore(appCtx),
mediaStoreModule = getMediaStore(),
cryptoDriverModule = CryptoDriverModule.vodozemac(),
authProviderData = MatrixClientAuthProviderData.classicLogin(
baseUrl = hs,
identifier = IdentifierType.User(login),
password = pass,
initialDeviceDisplayName = "PixelDragon Android v${pinfo.versionName}",
).getOrThrow()
).getOrThrow()
val pattern = """ return true
{
"type": "m.login.password",
"identifier": {
"type": "m.id.user"
},
"initial_device_display_name": "PixelDragon Android v${pinfo.versionName}"
}
""".trimIndent()
val json = JSONObject(pattern)
json.getJSONObject("identifier").put("user", login)
json.put("password", pass)
val r = try {
client.post("https://$homeserver/_matrix/client/v3/login") {
setBody(json.toString())
contentType(ContentType.Application.Json)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e("login", e.toString()) Log.i("Login", "Failed to login", e)
return false
} }
if (r.status != HttpStatusCode.OK) { return false
Log.e("login", r.bodyAsText()) }
return false // TODO: Inform a user of error code
}
val res = JSONObject(r.bodyAsText())
val editor = accountData.edit()
editor.putString("token", res.getString("access_token"))
editor.putString("homeserver", res.getString("home_server"))
editor.commit()
return initCheck()
}
+4
View File
@@ -0,0 +1,4 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
+4
View File
@@ -0,0 +1,4 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
+4
View File
@@ -0,0 +1,4 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
+4
View File
@@ -0,0 +1,4 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
@@ -0,0 +1,128 @@
/*
* 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"
}
@@ -0,0 +1,74 @@
/*
* 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
}
@@ -0,0 +1,64 @@
/*
* 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()
}
}
@@ -0,0 +1,137 @@
/*
* 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"
}
+4
View File
@@ -0,0 +1,4 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
+4
View File
@@ -0,0 +1,4 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
@@ -1,7 +1,6 @@
/* /*
* Created by sweetbread on 21.02.2025, 12:08 * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2025. All rights reserved.
* Last modified 21.02.2025, 12:08
*/ */
package ru.risdeveau.pixeldragon.ui.activity package ru.risdeveau.pixeldragon.ui.activity
@@ -35,15 +34,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.risdeveau.pixeldragon.api.isMatrixServer import ru.risdeveau.pixeldragon.api.getHomeserver
import ru.risdeveau.pixeldragon.api.login import ru.risdeveau.pixeldragon.api.login
import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme
import splitties.activities.start import splitties.activities.start
class Login : ComponentActivity() { class Login : ComponentActivity() {
@OptIn(DelicateCoroutinesApi::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
PixelDragonTheme { PixelDragonTheme {
@@ -56,10 +58,16 @@ class Login : ComponentActivity() {
SnackbarHost(hostState = snackbarHostState) SnackbarHost(hostState = snackbarHostState)
} }
) { innerPadding -> ) { innerPadding ->
Box(Modifier.fillMaxSize().padding(innerPadding)) { Box(
Modifier
.fillMaxSize()
.padding(innerPadding)) {
LoginField( LoginField(
Modifier.align(Alignment.Center), Modifier.align(Alignment.Center),
{ start<MainActivity>() }, {
start<MainActivity>()
finish()
},
{ {
scope.launch { scope.launch {
snackbarHostState snackbarHostState
@@ -89,7 +97,7 @@ fun LoginField(modifier: Modifier = Modifier, ok: () -> Unit, err: () -> Unit) {
var login by remember { mutableStateOf("") } var login by remember { mutableStateOf("") }
var pass by remember { mutableStateOf("") } var pass by remember { mutableStateOf("") }
var hmsValid by remember { mutableStateOf(false) } var hmsValid: Boolean by remember { mutableStateOf(false) }
OutlinedTextField( OutlinedTextField(
modifier = Modifier.padding(4.dp), modifier = Modifier.padding(4.dp),
@@ -119,18 +127,17 @@ fun LoginField(modifier: Modifier = Modifier, ok: () -> Unit, err: () -> Unit) {
Button( Button(
enabled = hmsValid && !login.isEmpty() && !pass.isEmpty(), enabled = hmsValid && !login.isEmpty() && !pass.isEmpty(),
onClick = { onClick = {
var loginSuccess = false scope.launch {
scope.launch { loginSuccess = login(homeserver, login, pass) } if (login(homeserver, login, pass)) ok()
else err()
if (loginSuccess) ok() }
else err()
} }
) { ) {
Text("Login") Text("Login")
} }
LaunchedEffect(homeserver) { LaunchedEffect(homeserver) {
scope.launch { hmsValid = isMatrixServer(homeserver) } scope.launch { hmsValid = (getHomeserver(homeserver) != null) }
} }
} }
} }
@@ -1,81 +1,454 @@
/* /*
* Created by sweetbread on 21.02.2025, 12:08 * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
* Last modified 21.02.2025, 12:07
*/ */
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.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.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.tooling.preview.Preview import androidx.compose.ui.draw.clip
import kotlinx.coroutines.DelicateCoroutinesApi import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.GlobalScope import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
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.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.risdeveau.pixeldragon.initCheck import ru.risdeveau.pixeldragon.R
import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.push.MatrixUnifiedPushManager
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.RoomActionsRoutes
import ru.risdeveau.pixeldragon.ui.layout.RoomActionsSheet
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
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class, DelicateCoroutinesApi::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
GlobalScope.launch {
if (!initCheck()) start<Login>()
}
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
PixelDragonTheme { PixelDragonTheme {
Scaffold( var isClientReady by remember { mutableStateOf(false) }
modifier = Modifier.fillMaxSize(),
topBar = { if (!isClientReady || client == null) {
TopAppBar( Box(
colors = topAppBarColors( modifier = Modifier.fillMaxSize(),
containerColor = MaterialTheme.colorScheme.primaryContainer, contentAlignment = Alignment.Center,
titleContentColor = MaterialTheme.colorScheme.primary, ) {
), CircularProgressIndicator()
title = { }
Text("Top app bar") } else {
} 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 ->
Greeting( Scaffold(
name = "Android", modifier = Modifier.fillMaxSize(),
modifier = Modifier.padding(innerPadding) topBar = {
) PixelDragonTopBar(
navController = navController,
syncState = syncState,
)
},
floatingActionButton = {
if (route == RoomActionsRoutes.Rooms) {
FloatingActionButton(onClick = { showRoomActionsSheet = true }) {
Text("+")
}
}
},
) { 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>()
finish()
return@LaunchedEffect
}
}
Log.i("MainActivity", "Log in as ${client!!.userId}")
client!!.startSync()
MatrixUnifiedPushManager.reconcileRegisteredPusher(appCtx)
isClientReady = true
} }
} }
} }
} }
override fun onDestroy() {
super.onDestroy()
CoroutineScope(Dispatchers.Main).launch {
client?.stopSync()
}
}
} }
@SuppressLint("FlowOperatorInvokedInComposition")
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun Greeting(name: String, modifier: Modifier = Modifier) { private fun PixelDragonTopBar(
Text( navController: NavHostController,
text = "Hello $name!", syncState: SyncState,
modifier = modifier ) {
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
)
}
}
) )
} }
@Preview(showBackground = true) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun GreetingPreview() { private fun RoomTopBar(
PixelDragonTheme { room: TrixnityRoom?,
Greeting("Android") 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
}
}
@@ -0,0 +1,69 @@
/*
* 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,
)
}
}
+145
View File
@@ -0,0 +1,145 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon.ui.item
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil3.compose.rememberAsyncImagePainter
import coil3.request.ImageRequest
import de.connect2x.trixnity.client.media
import de.connect2x.trixnity.clientserverapi.model.media.FileTransferProgress
import io.github.rabehx.iconsax.Iconsax
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 {
Loading, Success, Error
}
private val mxcImageByteCache = ConcurrentHashMap<String, ByteArray>()
@Composable
fun MXCImage(
mxcUrl: String,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
contentDescription: String = "",
showProgress: Boolean = true
) {
val context = LocalContext.current
val cachedBytes = remember(mxcUrl) { mxcImageByteCache[mxcUrl] }
var imageLoadState by remember(mxcUrl) {
mutableStateOf(if (cachedBytes != null) ImageLoadState.Success else ImageLoadState.Loading)
}
var imageBytes by remember(mxcUrl) { mutableStateOf(cachedBytes) }
val progressFlow = remember(mxcUrl) { MutableStateFlow<FileTransferProgress?>(null) }
LaunchedEffect(mxcUrl) {
if (mxcUrl.isBlank()) {
imageLoadState = ImageLoadState.Error
return@LaunchedEffect
}
mxcImageByteCache[mxcUrl]?.let { bytes ->
imageBytes = bytes
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()
val showProgressIndicator = showProgress &&
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(
painter = painter,
contentDescription = contentDescription,
contentScale = contentScale,
modifier = Modifier.fillMaxSize()
)
}
when {
showProgressIndicator -> {
progress?.let { p ->
val percent = p.total?.let { p.transferred.toFloat() / it }
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)
)
}
}
}
}
@@ -0,0 +1,195 @@
/*
* 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 }
}
@@ -0,0 +1,450 @@
/*
* 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
@@ -0,0 +1,110 @@
/*
* 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("") },
)
}
@@ -0,0 +1,128 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon.ui.layout
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import kotlinx.coroutines.flow.map
import de.connect2x.trixnity.client.flattenValues
import de.connect2x.trixnity.client.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
@SuppressLint("FlowOperatorInvokedInComposition")
@Composable
fun RoomList(modifier: Modifier = Modifier, navController: NavController) {
val rooms by client!!.room.getAll().flattenValues().map { it.toList() }.collectAsState(initial = emptyList())
val listState = rememberLazyListState()
// if (itemState.scrollToTop) {
// LaunchedEffect(coroutineScope) {
// Log.e("TAG", "TopCoinsScreen: scrollToTop" )
// listState.scrollToItem(0)
// }
// }
// LaunchedEffect(Unit) {
// list = withContext(Dispatchers.IO) { Room.getJoined() }
// }
LazyColumn(modifier = modifier, state = listState) {
items(rooms) { room ->
RoomItem(room = room, navController = navController )
}
item {
if (rooms.isEmpty()) {
Text("You have no rooms")
}
}
}
}
@Composable
fun RoomItem(modifier: Modifier = Modifier, room: Room, navController: NavController) {
val name = room.name
val isSpace = room.type == CreateEventContent.RoomType.Space
Row(
modifier
.padding(8.dp)
.height((52 + 8 * 2).dp)
.fillMaxWidth()
.background(
color =
if (isSpace)
MaterialTheme.colorScheme.tertiary
else
MaterialTheme.colorScheme.background
)
.clip(RoundedCornerShape(12.dp))
.clickable {
if (isSpace)
navController.navigate("space/${room.roomId}")
else
navController.navigate("room/${room.roomId}")
}
.padding(8.dp)
) {
Log.v("RoomItem", room.avatarUrl.toString())
room.avatarUrl?.let { mxc ->
MXCImage(
mxc,
modifier = Modifier
.padding(end = 4.dp)
.height(52.dp)
.width(52.dp)
.let {
if (isSpace)
it.clip(RoundedCornerShape(12.dp))
else
it.clip(CircleShape)
},
ContentScale.Crop
)
}
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
@@ -0,0 +1,24 @@
/*
* 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"
)
)
+3 -4
View File
@@ -1,7 +1,6 @@
/* /*
* Created by sweetbread on 21.02.2025, 12:00 * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
* Last modified 21.02.2025, 12:00
*/ */
// 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.
@@ -9,4 +8,4 @@ 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
} }
+33 -9
View File
@@ -1,21 +1,39 @@
[versions] [versions]
agp = "8.7.3" agp = "9.1.0"
coil = "3.1.0" coil = "3.4.0"
kotlin = "2.0.0" iconsaxCompose = "0.0.5"
jsoup = "1.22.1"
kotlin = "2.2.21"
coreKtx = "1.15.0" coreKtx = "1.15.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.2.1" junitVersion = "1.3.0"
espressoCore = "3.6.1" espressoCore = "3.7.0"
ktor = "3.1.0" kotlinxSerializationJson = "1.7.3"
ktor = "3.4.1"
lifecycleRuntimeKtx = "2.8.7" lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.10.0" activityCompose = "1.10.0"
composeBom = "2025.02.00" composeBom = "2026.03.00"
navigationCompose = "2.9.7"
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" }
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "room" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
androidx-navigation-dynamic-features-fragment = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigationCompose" }
androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment", version.ref = "navigationCompose" }
androidx-navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "navigationCompose" }
androidx-navigation-ui = { module = "androidx.navigation:navigation-ui", version.ref = "navigationCompose" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-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" }
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" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
@@ -29,12 +47,18 @@ 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" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
splitties-base = { module = "com.louiscad.splitties:splitties-fun-pack-android-base", version.ref = "splittiesFunPackAndroidBase" } 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" }
+2 -3
View File
@@ -1,12 +1,11 @@
# #
# Created by sweetbread on 21.02.2025, 12:00 # Created by sweetbread
# Copyright (c) 2025. All rights reserved. # Copyright (c) 2025. All rights reserved.
# Last modified 21.02.2025, 12:00
# #
#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-8.9-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
+6 -3
View File
@@ -1,7 +1,6 @@
/* /*
* Created by sweetbread on 21.02.2025, 12:00 * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2026. All rights reserved.
* Last modified 21.02.2025, 12:00
*/ */
pluginManagement { pluginManagement {
@@ -17,11 +16,15 @@ 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")
} }
} }