This commit is contained in:
2026-02-20 13:46:31 +00:00
commit 42486ac5df
68 changed files with 4433 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(JAVA_HOME=/nix/store/3xf2cjni3xqn10xnsa0cyvjmnd8sqg7b-openjdk-17.0.18+8 ./gradlew:*)",
"Bash(TERM=dumb curl:*)",
"WebSearch",
"WebFetch(domain:trixnity.gitlab.io)",
"WebFetch(domain:github.com)",
"WebFetch(domain:central.sonatype.com)",
"WebFetch(domain:gitlab.com)",
"Bash(JAVA_HOME=/nix/store/3xf2cjni3xqn10xnsa0cyvjmnd8sqg7b-openjdk-17.0.18+8 jar tf:*)",
"Bash(unzip:*)"
]
}
}

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

6
.idea/AndroidProjectSystem.xml generated Normal file
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>

6
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

10
.idea/deploymentTargetSelector.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

19
.idea/gradle.xml generated Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

10
.idea/migrations.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

10
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

17
.idea/runConfigurations.xml generated Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

40
CLAUDE.md Normal file
View File

@@ -0,0 +1,40 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with Kotlin, targeting Android 14+ (minSdk 34, targetSdk 36, compileSdk 36).
## Build Commands
```bash
./gradlew assembleDebug # Build debug APK
./gradlew assembleRelease # Build release APK
./gradlew test # Run unit tests
./gradlew connectedAndroidTest # Run instrumented tests
./gradlew app:testDebugUnitTest # Run unit tests for debug variant only
```
## Architecture
- **Package**: `com.example.fluffytrix`
- **Build system**: Gradle with Kotlin DSL, version catalog at `gradle/libs.versions.toml`
- **AGP**: 9.0.1
- **Java compatibility**: 11 (configured in `app/build.gradle.kts`)
- Single module (`:app`)
## Key Files
- `gradle/libs.versions.toml` — all dependency versions and library declarations
- `app/build.gradle.kts` — app module build config
- `app/src/main/java/com/example/fluffytrix/` — Kotlin sources
- `app/src/main/res/` — Android resources (layouts, themes, drawables)
## Design Intent
- Discord-like layout: space sidebar → channel list → message area → member list
- Static channel ordering (never auto-sort by recency)
- Material You (Material 3 dynamic colors) theming
- Trixnity SDK for Matrix protocol
- Jetpack Compose for UI

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

120
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,120 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.example.fluffytrix"
compileSdk {
version = release(36)
}
defaultConfig {
applicationId = "com.example.fluffytrix"
minSdk = 34
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
debug {
// Compose is extremely slow in unoptimized debug builds.
// R8 with isDebuggable keeps debuggability but strips the massive
// material-icons-extended library and optimizes Compose codegen.
isMinifyEnabled = true
isShrinkResources = true
isDebuggable = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
packaging {
dex {
useLegacyPackaging = true
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
// Compose
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.icons.extended)
implementation(libs.compose.foundation)
implementation(libs.activity.compose)
implementation(libs.navigation.compose)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose)
debugImplementation(libs.compose.ui.tooling)
// Koin
implementation(libs.koin.android)
implementation(libs.koin.compose)
// DataStore
implementation(libs.datastore.preferences)
// Coroutines
implementation(libs.coroutines.core)
implementation(libs.coroutines.android)
// Trixnity
implementation(libs.trixnity.client) {
exclude(group = "net.folivo", module = "trixnity-olm-jvm")
}
implementation(libs.trixnity.clientserverapi.client) {
exclude(group = "net.folivo", module = "trixnity-olm-jvm")
}
implementation(libs.trixnity.olm)
implementation(libs.trixnity.client.repository.room) {
exclude(group = "net.folivo", module = "trixnity-olm-jvm")
}
// Ktor engine for Trixnity
implementation(libs.ktor.client.okhttp)
// Coil (image loading)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
// Media3 (video playback)
implementation(libs.media3.exoplayer)
implementation(libs.media3.ui)
// Markdown renderer
implementation(libs.markdown.renderer.core)
implementation(libs.markdown.renderer.m3)
implementation(libs.markdown.renderer.code)
implementation(libs.markdown.renderer.coil3)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}

53
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,53 @@
# Keep line numbers for debugging stack traces
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
# kotlinx.serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
-keepclassmembers class kotlinx.serialization.json.** { *** Companion; }
-keepclasseswithmembers class kotlinx.serialization.json.** {
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class com.example.fluffytrix.**$$serializer { *; }
-keepclassmembers class com.example.fluffytrix.** {
*** Companion;
}
-keepclasseswithmembers class com.example.fluffytrix.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Trixnity keep all SDK classes (uses reflection/serialization heavily)
-keep class net.folivo.trixnity.** { *; }
-dontwarn net.folivo.trixnity.**
# Ktor
-keep class io.ktor.** { *; }
-dontwarn io.ktor.**
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
# Koin
-keep class org.koin.** { *; }
# Coil
-keep class coil3.** { *; }
# JNA (used by Trixnity OLM bindings)
-keep class com.sun.jna.** { *; }
-keep class * implements com.sun.jna.** { *; }
-dontwarn com.sun.jna.**
# Media3 / ExoPlayer
-keep class androidx.media3.** { *; }
-dontwarn androidx.media3.**
# Markdown renderer & syntax highlighting
-keep class com.mikepenz.markdown.** { *; }
-keep class dev.snipme.highlights.** { *; }
-dontwarn dev.snipme.highlights.**
# Olm native library
-keep class org.matrix.olm.** { *; }

View File

@@ -0,0 +1,24 @@
package com.example.fluffytrix
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.fluffytrix", appContext.packageName)
}
}

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".FluffytrixApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Fluffytrix">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,62 @@
package com.example.fluffytrix
import android.app.Application
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.memory.MemoryCache
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.example.fluffytrix.data.repository.AuthRepository
import com.example.fluffytrix.di.appModule
import com.example.fluffytrix.di.dataModule
import okhttp3.OkHttpClient
import org.koin.android.ext.android.inject
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
class FluffytrixApplication : Application(), SingletonImageLoader.Factory {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@FluffytrixApplication)
modules(appModule, dataModule)
}
}
override fun newImageLoader(context: coil3.PlatformContext): ImageLoader {
val authRepository: AuthRepository by inject()
val okHttpClient = OkHttpClient.Builder()
.addInterceptor { chain ->
val request = chain.request()
val token = authRepository.getAccessToken()
if (token != null && request.url.encodedPath.contains("/_matrix/")) {
chain.proceed(
request.newBuilder()
.header("Authorization", "Bearer $token")
.build()
)
} else {
chain.proceed(request)
}
}
.build()
return ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }))
}
.memoryCache {
MemoryCache.Builder()
.maxSizePercent(context, 0.25)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(cacheDir.resolve("image_cache"))
.maxSizeBytes(50L * 1024 * 1024) // 50 MB
.build()
}
.build()
}
}

View File

@@ -0,0 +1,20 @@
package com.example.fluffytrix
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.example.fluffytrix.ui.navigation.FluffytrixNavigation
import com.example.fluffytrix.ui.theme.FluffytrixTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
FluffytrixTheme {
FluffytrixNavigation()
}
}
}
}

View File

@@ -0,0 +1,28 @@
package com.example.fluffytrix.data
object MxcUrlHelper {
fun mxcToDownloadUrl(
baseUrl: String,
mxcUri: String?,
): String? {
if (mxcUri == null || !mxcUri.startsWith("mxc://")) return null
val parts = mxcUri.removePrefix("mxc://").split("/", limit = 2)
if (parts.size != 2) return null
val (serverName, mediaId) = parts
val base = baseUrl.trimEnd('/')
return "$base/_matrix/client/v1/media/download/$serverName/$mediaId"
}
fun mxcToThumbnailUrl(
baseUrl: String,
mxcUri: String?,
size: Int = 64,
): String? {
if (mxcUri == null || !mxcUri.startsWith("mxc://")) return null
val parts = mxcUri.removePrefix("mxc://").split("/", limit = 2)
if (parts.size != 2) return null
val (serverName, mediaId) = parts
val base = baseUrl.trimEnd('/')
return "$base/_matrix/client/v1/media/thumbnail/$serverName/$mediaId?width=$size&height=$size&method=crop"
}
}

View File

@@ -0,0 +1,88 @@
package com.example.fluffytrix.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "fluffytrix_prefs")
class PreferencesManager(private val context: Context) {
companion object {
private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token")
private val KEY_USER_ID = stringPreferencesKey("user_id")
private val KEY_DEVICE_ID = stringPreferencesKey("device_id")
private val KEY_HOMESERVER_URL = stringPreferencesKey("homeserver_url")
private val KEY_USERNAME = stringPreferencesKey("username")
private val KEY_PASSWORD = stringPreferencesKey("password")
private val KEY_IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
private val KEY_CHANNEL_ORDER = stringPreferencesKey("channel_order")
}
val isLoggedIn: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[KEY_IS_LOGGED_IN] == true
}
val accessToken: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[KEY_ACCESS_TOKEN]
}
val userId: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[KEY_USER_ID]
}
val homeserverUrl: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[KEY_HOMESERVER_URL]
}
val username: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[KEY_USERNAME]
}
val password: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[KEY_PASSWORD]
}
suspend fun saveSession(
userId: String,
deviceId: String,
homeserverUrl: String,
) {
context.dataStore.edit { prefs ->
prefs[KEY_USER_ID] = userId
prefs[KEY_DEVICE_ID] = deviceId
prefs[KEY_HOMESERVER_URL] = homeserverUrl
prefs[KEY_IS_LOGGED_IN] = true
}
}
val channelOrder: Flow<Map<String, List<String>>> = context.dataStore.data.map { prefs ->
val raw = prefs[KEY_CHANNEL_ORDER] ?: return@map emptyMap()
try {
Json.decodeFromString<Map<String, List<String>>>(raw)
} catch (_: Exception) {
emptyMap()
}
}
suspend fun saveChannelOrder(spaceId: String, roomIds: List<String>) {
context.dataStore.edit { prefs ->
val existing = prefs[KEY_CHANNEL_ORDER]?.let {
try { Json.decodeFromString<Map<String, List<String>>>(it) } catch (_: Exception) { emptyMap() }
} ?: emptyMap()
prefs[KEY_CHANNEL_ORDER] = Json.encodeToString(existing + (spaceId to roomIds))
}
}
suspend fun clearSession() {
context.dataStore.edit { it.clear() }
}
}

View File

@@ -0,0 +1,8 @@
package com.example.fluffytrix.data.model
sealed class AuthState {
data object Idle : AuthState()
data object Loading : AuthState()
data class Success(val userId: String) : AuthState()
data class Error(val message: String) : AuthState()
}

View File

@@ -0,0 +1,106 @@
package com.example.fluffytrix.data.repository
import android.content.Context
import androidx.room.Room
import com.example.fluffytrix.data.local.PreferencesManager
import io.ktor.http.Url
import kotlinx.coroutines.flow.firstOrNull
import net.folivo.trixnity.client.MatrixClient
import net.folivo.trixnity.client.fromStore
import net.folivo.trixnity.client.loginWithPassword
import net.folivo.trixnity.client.media.createInMemoryMediaStoreModule
import net.folivo.trixnity.client.store.AccountStore
import net.folivo.trixnity.client.store.repository.room.TrixnityRoomDatabase
import net.folivo.trixnity.client.store.repository.room.createRoomRepositoriesModule
import net.folivo.trixnity.clientserverapi.model.authentication.IdentifierType
class AuthRepository(
private val preferencesManager: PreferencesManager,
private val context: Context,
) {
private var matrixClient: MatrixClient? = null
private var accessToken: String? = null
private fun createDatabaseBuilder() =
Room.databaseBuilder(context, TrixnityRoomDatabase::class.java, "trixnity")
.fallbackToDestructiveMigration(false)
suspend fun login(
homeserverUrl: String,
username: String,
password: String,
): Result<MatrixClient> {
val normalizedUrl = homeserverUrl.let {
if (!it.startsWith("http")) "https://$it" else it
}
val baseUrl = Url(normalizedUrl)
val result = MatrixClient.loginWithPassword(
baseUrl = baseUrl,
identifier = IdentifierType.User(username),
password = password,
initialDeviceDisplayName = "Fluffytrix Android",
repositoriesModule = createRoomRepositoriesModule(createDatabaseBuilder()),
mediaStoreModule = createInMemoryMediaStoreModule(),
)
result.onSuccess { client ->
matrixClient = client
try {
val accountStore = client.di.get<AccountStore>()
accessToken = accountStore.getAccount()?.accessToken
} catch (_: Exception) { }
preferencesManager.saveSession(
userId = client.userId.full,
deviceId = client.deviceId,
homeserverUrl = homeserverUrl,
)
client.startSync()
}
return result
}
suspend fun restoreSession(): Boolean {
if (matrixClient != null) return true
val isLoggedIn = preferencesManager.isLoggedIn.firstOrNull() ?: false
if (!isLoggedIn) return false
return try {
val client = MatrixClient.fromStore(
repositoriesModule = createRoomRepositoriesModule(createDatabaseBuilder()),
mediaStoreModule = createInMemoryMediaStoreModule(),
).getOrNull()
if (client != null) {
matrixClient = client
try {
val accountStore = client.di.get<AccountStore>()
accessToken = accountStore.getAccount()?.accessToken
} catch (_: Exception) { }
client.startSync()
true
} else {
// Store was empty or corrupt — clear saved state
preferencesManager.clearSession()
false
}
} catch (_: Exception) {
preferencesManager.clearSession()
false
}
}
fun getClient(): MatrixClient? = matrixClient
fun getAccessToken(): String? = accessToken
fun getBaseUrl(): String? = matrixClient?.baseUrl?.toString()?.trimEnd('/')
suspend fun logout() {
matrixClient?.logout()
matrixClient?.close()
matrixClient = null
accessToken = null
preferencesManager.clearSession()
}
}

View File

@@ -0,0 +1,20 @@
package com.example.fluffytrix.di
import com.example.fluffytrix.data.local.PreferencesManager
import com.example.fluffytrix.data.repository.AuthRepository
import com.example.fluffytrix.ui.screens.login.LoginViewModel
import com.example.fluffytrix.ui.screens.main.MainViewModel
import com.example.fluffytrix.ui.screens.verification.VerificationViewModel
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
val appModule = module {
viewModel { LoginViewModel(get()) }
viewModel { VerificationViewModel(get()) }
viewModel { MainViewModel(get(), get()) }
}
val dataModule = module {
single { PreferencesManager(get()) }
single { AuthRepository(get(), get()) }
}

View File

@@ -0,0 +1,150 @@
package com.example.fluffytrix.ui.navigation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.unit.dp
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.example.fluffytrix.data.local.PreferencesManager
import com.example.fluffytrix.data.repository.AuthRepository
import com.example.fluffytrix.ui.screens.login.LoginScreen
import com.example.fluffytrix.ui.screens.main.MainScreen
import com.example.fluffytrix.ui.screens.verification.VerificationScreen
import kotlinx.coroutines.flow.firstOrNull
import org.koin.compose.koinInject
@Composable
fun FluffytrixNavigation() {
val navController = rememberNavController()
val preferencesManager: PreferencesManager = koinInject()
val authRepository: AuthRepository = koinInject()
val isLoggedIn by preferencesManager.isLoggedIn.collectAsState(initial = false)
var isRestoring by remember { mutableStateOf(true) }
var restoreSucceeded by remember { mutableStateOf(false) }
var restoreFailed by remember { mutableStateOf(false) }
var retryTrigger by remember { mutableIntStateOf(0) }
LaunchedEffect(isLoggedIn, retryTrigger) {
if (isLoggedIn && authRepository.getClient() == null) {
isRestoring = true
restoreFailed = false
restoreSucceeded = authRepository.restoreSession()
if (!restoreSucceeded) {
// Don't clear credentials — let user retry or go to login
restoreFailed = true
}
} else {
restoreSucceeded = authRepository.getClient() != null
}
isRestoring = false
}
if (isRestoring && isLoggedIn) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
return
}
// Restore failed — show retry screen instead of wiping credentials
if (restoreFailed && !restoreSucceeded) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Failed to reconnect",
style = MaterialTheme.typography.titleMedium,
)
Text(
text = "Could not restore your session",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
Button(onClick = { retryTrigger++ }) {
Text("Retry")
}
TextButton(onClick = {
restoreFailed = false
restoreSucceeded = false
}) {
Text("Log in again")
}
}
}
return
}
val startDestination = if (restoreSucceeded || (isLoggedIn && authRepository.getClient() != null)) {
Screen.Main.route
} else {
Screen.Login.route
}
NavHost(
navController = navController,
startDestination = startDestination,
) {
composable(Screen.Login.route) {
LoginScreen(
onLoginSuccess = {
navController.navigate(Screen.Verification.route) {
popUpTo(Screen.Login.route) { inclusive = true }
}
}
)
}
composable(Screen.Verification.route) {
VerificationScreen(
onVerified = {
navController.navigate(Screen.Main.route) {
popUpTo(Screen.Verification.route) { inclusive = true }
}
},
onSkip = {
navController.navigate(Screen.Main.route) {
popUpTo(Screen.Verification.route) { inclusive = true }
}
},
)
}
composable(Screen.Main.route) {
MainScreen(
onLogout = {
navController.navigate(Screen.Login.route) {
popUpTo(Screen.Main.route) { inclusive = true }
}
}
)
}
}
}

View File

@@ -0,0 +1,7 @@
package com.example.fluffytrix.ui.navigation
sealed class Screen(val route: String) {
data object Login : Screen("login")
data object Verification : Screen("verification")
data object Main : Screen("main")
}

View File

@@ -0,0 +1,134 @@
package com.example.fluffytrix.ui.screens.login
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.foundation.layout.widthIn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.example.fluffytrix.data.model.AuthState
import org.koin.androidx.compose.koinViewModel
@Composable
fun LoginScreen(
onLoginSuccess: () -> Unit,
viewModel: LoginViewModel = koinViewModel(),
) {
val homeserverUrl by viewModel.homeserverUrl.collectAsState()
val username by viewModel.username.collectAsState()
val password by viewModel.password.collectAsState()
val authState by viewModel.authState.collectAsState()
LaunchedEffect(authState) {
if (authState is AuthState.Success) {
onLoginSuccess()
}
}
Scaffold { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.widthIn(max = 400.dp)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Fluffytrix",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = homeserverUrl,
onValueChange = { viewModel.homeserverUrl.value = it },
label = { Text("Homeserver URL") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next,
),
)
OutlinedTextField(
value = username,
onValueChange = { viewModel.username.value = it },
label = { Text("Username") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
)
OutlinedTextField(
value = password,
onValueChange = { viewModel.password.value = it },
label = { Text("Password") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { viewModel.login() }),
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { viewModel.login() },
modifier = Modifier.fillMaxWidth(),
enabled = authState !is AuthState.Loading,
) {
if (authState is AuthState.Loading) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Text("Log In")
}
}
if (authState is AuthState.Error) {
Text(
text = (authState as AuthState.Error).message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
}

View File

@@ -0,0 +1,42 @@
package com.example.fluffytrix.ui.screens.login
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.fluffytrix.data.model.AuthState
import com.example.fluffytrix.data.repository.AuthRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class LoginViewModel(
private val authRepository: AuthRepository,
) : ViewModel() {
val homeserverUrl = MutableStateFlow("matrix.org")
val username = MutableStateFlow("")
val password = MutableStateFlow("")
private val _authState = MutableStateFlow<AuthState>(AuthState.Idle)
val authState: StateFlow<AuthState> = _authState
fun login() {
if (username.value.isBlank() || password.value.isBlank()) {
_authState.value = AuthState.Error("Username and password are required")
return
}
_authState.value = AuthState.Loading
viewModelScope.launch {
val result = authRepository.login(
homeserverUrl = homeserverUrl.value,
username = username.value,
password = password.value,
)
_authState.value = result.fold(
onSuccess = { AuthState.Success(it.userId.full) },
onFailure = { AuthState.Error(it.message ?: "Login failed") },
)
}
}
}

View File

@@ -0,0 +1,99 @@
package com.example.fluffytrix.ui.screens.main
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
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.width
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.example.fluffytrix.ui.screens.main.components.ChannelList
import com.example.fluffytrix.ui.screens.main.components.MemberList
import com.example.fluffytrix.ui.screens.main.components.MessageTimeline
import com.example.fluffytrix.ui.screens.main.components.SpaceList
import org.koin.androidx.compose.koinViewModel
@Composable
fun MainScreen(
onLogout: () -> Unit,
viewModel: MainViewModel = koinViewModel(),
) {
val spaces by viewModel.spaces.collectAsState()
val channels by viewModel.channels.collectAsState()
val selectedSpace by viewModel.selectedSpace.collectAsState()
val selectedChannel by viewModel.selectedChannel.collectAsState()
val showChannelList by viewModel.showChannelList.collectAsState()
val showMemberList by viewModel.showMemberList.collectAsState()
val messages by viewModel.messages.collectAsState()
val members by viewModel.members.collectAsState()
val channelName by viewModel.channelName.collectAsState()
val isReorderMode by viewModel.isReorderMode.collectAsState()
Scaffold { padding ->
Box(modifier = Modifier.fillMaxSize()) {
// Main content: SpaceList + chat + members
Row(modifier = Modifier.fillMaxSize()) {
SpaceList(
spaces = spaces,
selectedSpace = selectedSpace,
onSpaceClick = { viewModel.selectSpace(it) },
onToggleChannelList = { viewModel.toggleChannelList() },
contentPadding = padding,
)
MessageTimeline(
selectedChannel = selectedChannel,
channelName = channelName,
messages = messages,
onToggleMemberList = { viewModel.toggleMemberList() },
onSendMessage = { viewModel.sendMessage(it) },
modifier = Modifier.weight(1f),
contentPadding = padding,
)
AnimatedVisibility(visible = showMemberList) {
MemberList(
members = members,
contentPadding = padding,
)
}
}
// Channel list overlays on top, slides in from left
AnimatedVisibility(
visible = showChannelList,
enter = slideInHorizontally { -it },
exit = slideOutHorizontally { -it },
modifier = Modifier.zIndex(1f),
) {
Row {
Spacer(modifier = Modifier.width(64.dp))
ChannelList(
channels = channels,
selectedChannel = selectedChannel,
onChannelClick = {
viewModel.selectChannel(it)
viewModel.toggleChannelList()
},
onLogout = {
viewModel.logout()
onLogout()
},
contentPadding = padding,
isReorderMode = isReorderMode,
onToggleReorderMode = { viewModel.toggleReorderMode() },
onMoveChannel = { from, to -> viewModel.moveChannel(from, to) },
)
}
}
}
}
}

View File

@@ -0,0 +1,538 @@
package com.example.fluffytrix.ui.screens.main
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.fluffytrix.data.MxcUrlHelper.mxcToDownloadUrl
import com.example.fluffytrix.data.MxcUrlHelper.mxcToThumbnailUrl
import com.example.fluffytrix.data.local.PreferencesManager
import com.example.fluffytrix.data.repository.AuthRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.folivo.trixnity.client.room
import net.folivo.trixnity.client.room.message.text
import net.folivo.trixnity.client.store.Room
import net.folivo.trixnity.client.store.isEncrypted
import net.folivo.trixnity.client.user
import net.folivo.trixnity.core.model.RoomId
import net.folivo.trixnity.core.model.UserId
import net.folivo.trixnity.core.model.events.m.room.CreateEventContent.RoomType
import net.folivo.trixnity.core.model.events.m.room.Membership
import net.folivo.trixnity.core.model.events.m.room.RoomMessageEventContent
import net.folivo.trixnity.core.model.events.m.space.ChildEventContent
data class SpaceItem(
val id: RoomId,
val name: String,
val avatarUrl: String?,
)
data class ChannelItem(
val id: RoomId,
val name: String,
val isEncrypted: Boolean,
val avatarUrl: String? = null,
)
sealed interface MessageContent {
data class Text(val body: String, val urls: List<String> = emptyList()) : MessageContent
data class Image(val body: String, val url: String, val width: Int? = null, val height: Int? = null) : MessageContent
data class Video(val body: String, val url: String? = null, val thumbnailUrl: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent
data class File(val body: String, val fileName: String? = null, val size: Long? = null) : MessageContent
}
private val urlRegex = Regex("""https?://[^\s<>"{}|\\^`\[\]]+""")
@Immutable
data class MessageItem(
val eventId: String,
val senderId: String,
val senderName: String,
val senderAvatarUrl: String? = null,
val content: MessageContent,
val timestamp: Long,
)
data class MemberItem(
val userId: UserId,
val displayName: String,
val avatarUrl: String? = null,
)
class MainViewModel(
private val authRepository: AuthRepository,
private val preferencesManager: PreferencesManager,
) : ViewModel() {
private val _spaces = MutableStateFlow<List<SpaceItem>>(emptyList())
val spaces: StateFlow<List<SpaceItem>> = _spaces
private val _channels = MutableStateFlow<List<ChannelItem>>(emptyList())
val channels: StateFlow<List<ChannelItem>> = _channels
private val _selectedSpace = MutableStateFlow<RoomId?>(null)
val selectedSpace: StateFlow<RoomId?> = _selectedSpace
private val _selectedChannel = MutableStateFlow<RoomId?>(null)
val selectedChannel: StateFlow<RoomId?> = _selectedChannel
private val _showChannelList = MutableStateFlow(true)
val showChannelList: StateFlow<Boolean> = _showChannelList
private val _showMemberList = MutableStateFlow(false)
val showMemberList: StateFlow<Boolean> = _showMemberList
private val _messages = MutableStateFlow<List<MessageItem>>(emptyList())
val messages: StateFlow<List<MessageItem>> = _messages
private val _members = MutableStateFlow<List<MemberItem>>(emptyList())
val members: StateFlow<List<MemberItem>> = _members
private val _channelName = MutableStateFlow<String?>(null)
val channelName: StateFlow<String?> = _channelName
private val _isReorderMode = MutableStateFlow(false)
val isReorderMode: StateFlow<Boolean> = _isReorderMode
private val _channelOrderMap = MutableStateFlow<Map<String, List<String>>>(emptyMap())
private val _allChannelRooms = MutableStateFlow<List<ChannelItem>>(emptyList())
private val _spaceChildren = MutableStateFlow<Set<RoomId>?>(null)
// Per-room caches
private val messageCache = mutableMapOf<RoomId, MutableList<MessageItem>>()
private val messageIds = mutableMapOf<RoomId, MutableSet<String>>()
private val memberCache = mutableMapOf<RoomId, List<MemberItem>>()
private val channelNameCache = mutableMapOf<RoomId, String>()
private val senderAvatarCache = mutableMapOf<String, String?>()
private val senderNameCache = mutableMapOf<String, String>()
// Room data cache — avoid re-resolving unchanged rooms
private var cachedRoomData = mapOf<RoomId, Room>()
private var timelineJob: Job? = null
private var membersJob: Job? = null
private var spaceChildrenJob: Job? = null
init {
loadRooms()
observeSelectedChannel()
observeSpaceFiltering()
viewModelScope.launch {
preferencesManager.channelOrder.collect { _channelOrderMap.value = it }
}
}
@OptIn(kotlinx.coroutines.FlowPreview::class)
private fun loadRooms() {
val client = authRepository.getClient() ?: return
val baseUrl = authRepository.getBaseUrl() ?: return
viewModelScope.launch {
client.room.getAll()
.debounce(1000)
.collect { roomMap ->
withContext(Dispatchers.Default) {
// Only resolve rooms whose keys changed
val currentKeys = roomMap.keys
val newRoomIds = currentKeys - cachedRoomData.keys
val removedRoomIds = cachedRoomData.keys - currentKeys
// Resolve only new/changed rooms
val newlyResolved = if (newRoomIds.isNotEmpty()) {
coroutineScope {
newRoomIds.mapNotNull { roomId ->
roomMap[roomId]?.let { flow ->
async { roomId to flow.firstOrNull() }
}
}.awaitAll()
.filter { it.second != null }
.associate { it.first to it.second!! }
}
} else emptyMap()
// On first load, resolve everything; after that, only new ones
val allResolved = if (cachedRoomData.isEmpty()) {
coroutineScope {
roomMap.entries.map { (roomId, flow) ->
async { roomId to flow.firstOrNull() }
}.awaitAll()
.filter { it.second != null }
.associate { it.first to it.second!! }
}
} else {
(cachedRoomData - removedRoomIds) + newlyResolved
}
cachedRoomData = allResolved
val joinedRooms = allResolved.values.filter { it.membership == Membership.JOIN }
val allSpaces = joinedRooms
.filter { it.createEventContent?.type is RoomType.Space }
// Collect child space IDs so we only show top-level spaces
val childSpaceIds = mutableSetOf<RoomId>()
val allSpaceIds = allSpaces.map { it.roomId }.toSet()
coroutineScope {
allSpaces.map { space ->
async {
try {
val children = client.room.getAllState(space.roomId, ChildEventContent::class)
.firstOrNull()?.keys?.map { RoomId(it) } ?: emptyList()
children.filter { it in allSpaceIds }
} catch (_: Exception) { emptyList() }
}
}.awaitAll().forEach { childSpaceIds.addAll(it) }
}
_spaces.value = allSpaces
.filter { it.roomId !in childSpaceIds }
.map { room ->
SpaceItem(
id = room.roomId,
name = room.name?.explicitName ?: room.roomId.full,
avatarUrl = mxcToThumbnailUrl(baseUrl, room.avatarUrl, 96),
)
}
_allChannelRooms.value = joinedRooms
.filter { it.createEventContent?.type !is RoomType.Space }
.map { room ->
ChannelItem(
id = room.roomId,
name = room.name?.explicitName ?: room.roomId.full,
isEncrypted = room.encrypted,
avatarUrl = mxcToThumbnailUrl(baseUrl, room.avatarUrl, 64),
)
}
}
}
}
}
private fun observeSpaceFiltering() {
viewModelScope.launch {
combine(_allChannelRooms, _spaceChildren, _selectedSpace, _channelOrderMap) { allChannels, children, spaceId, orderMap ->
val filtered = if (children == null) allChannels
else allChannels.filter { it.id in children }
val savedOrder = spaceId?.let { orderMap[it.full] }
if (savedOrder != null) {
val indexMap = savedOrder.withIndex().associate { (i, id) -> id to i }
filtered.sortedBy { indexMap[it.id.full] ?: Int.MAX_VALUE }
} else filtered
}.collect { _channels.value = it }
}
}
private fun observeSelectedChannel() {
viewModelScope.launch {
_selectedChannel.collect { roomId ->
timelineJob?.cancel()
membersJob?.cancel()
if (roomId == null) {
_messages.value = emptyList()
_members.value = emptyList()
_channelName.value = null
return@collect
}
// Restore from cache instantly
_messages.value = messageCache[roomId]?.toList() ?: emptyList()
_members.value = memberCache[roomId] ?: emptyList()
_channelName.value = channelNameCache[roomId]
loadChannelName(roomId)
timelineJob = loadTimeline(roomId)
membersJob = loadMembers(roomId)
}
}
}
private fun loadChannelName(roomId: RoomId) {
val client = authRepository.getClient() ?: return
viewModelScope.launch {
val room = client.room.getById(roomId).firstOrNull()
val name = room?.name?.explicitName ?: roomId.full
channelNameCache[roomId] = name
_channelName.value = name
}
}
private fun loadTimeline(roomId: RoomId): Job {
val client = authRepository.getClient() ?: return Job()
val baseUrl = authRepository.getBaseUrl() ?: return Job()
return viewModelScope.launch {
try {
val cached = messageCache.getOrPut(roomId) { mutableListOf() }
val ids = messageIds.getOrPut(roomId) { mutableSetOf() }
var dirty = false
var lastEmitTime = 0L
client.room.getLastTimelineEvents(roomId).collectLatest { outerFlow ->
if (outerFlow == null) return@collectLatest
coroutineScope {
outerFlow.collect { innerFlow ->
val firstEvent = innerFlow.firstOrNull() ?: return@collect
val eventId = firstEvent.event.id.full
if (eventId in ids) {
// Already have this event — but if encrypted, watch for decryption updates
if (firstEvent.event.isEncrypted && firstEvent.content?.getOrNull() == null) {
launch {
innerFlow.collect { updated ->
val decrypted = updated.content?.getOrNull() ?: return@collect
val msgContent = resolveContent(decrypted, baseUrl) ?: return@collect
updateCachedMessage(roomId, eventId, cached, msgContent)
}
}
}
return@collect
}
val contentResult = firstEvent.content
val content = contentResult?.getOrNull()
val msgContent: MessageContent = when {
content == null && firstEvent.event.isEncrypted -> MessageContent.Text(
body = if (contentResult?.isFailure == true) "\uD83D\uDD12 Unable to decrypt message"
else "\uD83D\uDD12 Waiting for decryption keys..."
)
content == null -> return@collect
else -> resolveContent(content, baseUrl) ?: return@collect
}
val senderId = firstEvent.event.sender.localpart
val senderName = senderNameCache[senderId] ?: senderId
val msg = MessageItem(
eventId = eventId,
senderId = senderId,
senderName = senderName,
senderAvatarUrl = senderAvatarCache[senderId],
content = msgContent,
timestamp = firstEvent.event.originTimestamp,
)
ids.add(eventId)
val insertIdx = cached.binarySearch { other ->
msg.timestamp.compareTo(other.timestamp)
}.let { if (it < 0) -(it + 1) else it }
cached.add(insertIdx, msg)
dirty = true
// For encrypted events still waiting, watch for decryption
if (firstEvent.event.isEncrypted && content == null) {
launch {
innerFlow.collect { updated ->
val decrypted = updated.content?.getOrNull() ?: return@collect
val resolved = resolveContent(decrypted, baseUrl) ?: return@collect
updateCachedMessage(roomId, eventId, cached, resolved)
}
}
}
// Throttle UI updates: max once per 200ms
val now = System.currentTimeMillis()
if (now - lastEmitTime > 200) {
if (_selectedChannel.value == roomId) {
_messages.value = ArrayList(cached)
}
dirty = false
lastEmitTime = now
}
}
if (dirty && _selectedChannel.value == roomId) {
_messages.value = ArrayList(cached)
}
}
}
} catch (_: Exception) { }
}
}
private fun resolveContent(content: net.folivo.trixnity.core.model.events.RoomEventContent, baseUrl: String): MessageContent? {
return when (content) {
is RoomMessageEventContent.FileBased.Image -> MessageContent.Image(
body = content.body,
url = mxcToDownloadUrl(baseUrl, content.url) ?: return null,
width = content.info?.width?.toInt(),
height = content.info?.height?.toInt(),
)
is RoomMessageEventContent.FileBased.Video -> MessageContent.Video(
body = content.body,
url = mxcToDownloadUrl(baseUrl, content.url),
thumbnailUrl = mxcToThumbnailUrl(baseUrl, content.info?.thumbnailUrl, 300),
width = content.info?.width?.toInt(),
height = content.info?.height?.toInt(),
)
is RoomMessageEventContent.FileBased.Audio -> MessageContent.File(
body = content.body,
fileName = content.fileName ?: content.body,
size = content.info?.size,
)
is RoomMessageEventContent.FileBased.File -> MessageContent.File(
body = content.body,
fileName = content.fileName ?: content.body,
size = content.info?.size,
)
is RoomMessageEventContent -> {
val body = content.body
MessageContent.Text(
body = body,
urls = urlRegex.findAll(body).map { it.value }.toList(),
)
}
else -> null
}
}
private fun updateCachedMessage(roomId: RoomId, eventId: String, cached: MutableList<MessageItem>, newContent: MessageContent) {
val idx = cached.indexOfFirst { it.eventId == eventId }
if (idx >= 0) {
cached[idx] = cached[idx].copy(content = newContent)
if (_selectedChannel.value == roomId) {
_messages.value = ArrayList(cached)
}
}
}
@OptIn(kotlinx.coroutines.FlowPreview::class)
private fun loadMembers(roomId: RoomId): Job {
val client = authRepository.getClient() ?: return Job()
val baseUrl = authRepository.getBaseUrl()
return viewModelScope.launch {
try {
client.user.loadMembers(roomId)
client.user.getAll(roomId)
.debounce(1000)
.collect { userMap ->
val memberList = withContext(Dispatchers.Default) {
coroutineScope {
userMap.values.map { userFlow ->
async { userFlow.firstOrNull() }
}.awaitAll().filterNotNull().map { user ->
MemberItem(
userId = user.userId,
displayName = user.name,
avatarUrl = baseUrl?.let {
mxcToThumbnailUrl(it, user.event.content.avatarUrl, 64)
},
)
}
}.sortedBy { it.displayName.lowercase() }
}
memberCache[roomId] = memberList
memberList.forEach { m ->
val localpart = m.userId.localpart
senderAvatarCache[localpart] = m.avatarUrl
senderNameCache[localpart] = m.displayName
}
// Backfill avatars and display names into already-cached messages
messageCache[roomId]?.let { cached ->
var patched = false
for (i in cached.indices) {
val msg = cached[i]
val newAvatar = if (msg.senderAvatarUrl == null) senderAvatarCache[msg.senderId] else null
val newName = senderNameCache[msg.senderId]
val nameChanged = newName != null && newName != msg.senderName
if (newAvatar != null || nameChanged) {
cached[i] = msg.copy(
senderAvatarUrl = newAvatar ?: msg.senderAvatarUrl,
senderName = newName ?: msg.senderName,
)
patched = true
}
}
if (patched && _selectedChannel.value == roomId) {
_messages.value = ArrayList(cached)
}
}
if (_selectedChannel.value == roomId) {
_members.value = memberList
}
}
} catch (_: Exception) {
_members.value = emptyList()
}
}
}
private fun loadSpaceChildren(spaceId: RoomId) {
val client = authRepository.getClient() ?: return
spaceChildrenJob?.cancel()
spaceChildrenJob = viewModelScope.launch {
try {
client.room.getAllState(spaceId, ChildEventContent::class).collect { stateMap ->
_spaceChildren.value = stateMap.keys.map { RoomId(it) }.toSet()
}
} catch (_: Exception) {
_spaceChildren.value = emptySet()
}
}
}
fun sendMessage(body: String) {
val roomId = _selectedChannel.value ?: return
val client = authRepository.getClient() ?: return
viewModelScope.launch {
try {
client.room.sendMessage(roomId) { text(body) }
} catch (_: Exception) { }
}
}
fun selectSpace(spaceId: RoomId) {
if (_selectedSpace.value == spaceId) {
_showChannelList.value = !_showChannelList.value
} else {
_selectedSpace.value = spaceId
_showChannelList.value = true
loadSpaceChildren(spaceId)
}
}
fun selectChannel(channelId: RoomId) {
_selectedChannel.value = channelId
}
fun toggleChannelList() {
_showChannelList.value = !_showChannelList.value
}
fun toggleMemberList() {
_showMemberList.value = !_showMemberList.value
}
fun toggleReorderMode() {
_isReorderMode.value = !_isReorderMode.value
}
fun moveChannel(from: Int, to: Int) {
val current = _channels.value.toMutableList()
if (from !in current.indices || to !in current.indices) return
val item = current.removeAt(from)
current.add(to, item)
_channels.value = current
val spaceId = _selectedSpace.value?.full ?: return
val roomIds = current.map { it.id.full }
_channelOrderMap.value = _channelOrderMap.value + (spaceId to roomIds)
viewModelScope.launch {
preferencesManager.saveChannelOrder(spaceId, roomIds)
}
}
fun logout() {
viewModelScope.launch {
authRepository.logout()
}
}
}

View File

@@ -0,0 +1,223 @@
package com.example.fluffytrix.ui.screens.main.components
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.SwapVert
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
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.draw.shadow
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.example.fluffytrix.ui.screens.main.ChannelItem
import net.folivo.trixnity.core.model.RoomId
import kotlin.math.roundToInt
@Composable
fun ChannelList(
channels: List<ChannelItem>,
selectedChannel: RoomId?,
onChannelClick: (RoomId) -> Unit,
onLogout: () -> Unit,
contentPadding: PaddingValues,
isReorderMode: Boolean = false,
onToggleReorderMode: () -> Unit = {},
onMoveChannel: (from: Int, to: Int) -> Unit = { _, _ -> },
) {
var showLogoutDialog by remember { mutableStateOf(false) }
// Drag state
var draggingIndex by remember { mutableIntStateOf(-1) }
var dragOffsetY by remember { mutableFloatStateOf(0f) }
val listState = rememberLazyListState()
if (showLogoutDialog) {
AlertDialog(
onDismissRequest = { showLogoutDialog = false },
title = { Text("Logout") },
text = { Text("Are you sure you want to logout?") },
confirmButton = {
TextButton(onClick = { showLogoutDialog = false; onLogout() }) {
Text("Logout")
}
},
dismissButton = {
TextButton(onClick = { showLogoutDialog = false }) {
Text("Cancel")
}
},
)
}
Column(
modifier = Modifier
.width(240.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surface)
.padding(top = contentPadding.calculateTopPadding()),
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Channels",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Row {
IconButton(onClick = onToggleReorderMode) {
Icon(
imageVector = Icons.Default.SwapVert,
contentDescription = "Reorder channels",
tint = if (isReorderMode) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { showLogoutDialog = true }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Logout,
contentDescription = "Logout",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
LazyColumn(
state = listState,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
itemsIndexed(channels, key = { _, ch -> ch.id.full }) { index, channel ->
val isSelected = channel.id == selectedChannel
val isDragging = draggingIndex == index
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp, label = "elevation")
Row(
modifier = Modifier
.fillMaxWidth()
.then(
if (isDragging) Modifier
.zIndex(1f)
.offset { IntOffset(0, dragOffsetY.roundToInt()) }
.shadow(elevation, RoundedCornerShape(4.dp))
else Modifier
)
.clip(RoundedCornerShape(4.dp))
.background(
when {
isDragging -> MaterialTheme.colorScheme.surfaceContainerHigh
isSelected -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.surface
}
)
.then(if (!isReorderMode) Modifier.clickable { onChannelClick(channel.id) } else Modifier)
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isReorderMode) {
val currentIndex by rememberUpdatedState(index)
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = "Drag to reorder",
modifier = Modifier
.size(20.dp)
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = {
draggingIndex = currentIndex
dragOffsetY = 0f
},
onDrag = { change, dragAmount ->
change.consume()
dragOffsetY += dragAmount.y
val itemHeight = 34.dp.toPx()
val draggedPositions = (dragOffsetY / itemHeight).roundToInt()
val targetIndex = (draggingIndex + draggedPositions).coerceIn(0, channels.lastIndex)
if (targetIndex != draggingIndex) {
onMoveChannel(draggingIndex, targetIndex)
dragOffsetY -= (targetIndex - draggingIndex) * itemHeight
draggingIndex = targetIndex
}
},
onDragEnd = {
draggingIndex = -1
dragOffsetY = 0f
},
onDragCancel = {
draggingIndex = -1
dragOffsetY = 0f
},
)
},
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(4.dp))
}
Icon(
imageVector = if (channel.isEncrypted) Icons.Default.Lock else Icons.Default.Tag,
contentDescription = null,
modifier = Modifier.height(18.dp),
tint = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = channel.name,
style = MaterialTheme.typography.bodyMedium,
color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}

View File

@@ -0,0 +1,99 @@
package com.example.fluffytrix.ui.screens.main.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.example.fluffytrix.ui.screens.main.MemberItem
@Composable
fun MemberList(
members: List<MemberItem>,
contentPadding: PaddingValues = PaddingValues(),
) {
Column(
modifier = Modifier
.width(240.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surface)
.padding(top = contentPadding.calculateTopPadding()),
) {
Text(
text = "Members — ${members.size}",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
LazyColumn(
contentPadding = PaddingValues(horizontal = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
items(members, key = { it.userId.full }) { member ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (member.avatarUrl != null) {
AsyncImage(
model = member.avatarUrl,
contentDescription = member.displayName,
modifier = Modifier
.size(32.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop,
)
} else {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = member.displayName.take(1).uppercase(),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
Text(
text = member.displayName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}

View File

@@ -0,0 +1,569 @@
package com.example.fluffytrix.ui.screens.main.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.coil3.Coil3ImageTransformerImpl
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.PlayCircleFilled
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.media3.common.MediaItem
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.PlayerView
import com.example.fluffytrix.data.repository.AuthRepository
import org.koin.compose.koinInject
import coil3.compose.AsyncImage
import com.example.fluffytrix.ui.screens.main.MessageContent
import com.example.fluffytrix.ui.screens.main.MessageItem
import net.folivo.trixnity.core.model.RoomId
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val LocalImageViewer = compositionLocalOf<(String) -> Unit> { {} }
private val LocalVideoPlayer = compositionLocalOf<(String) -> Unit> { {} }
private val senderColors = arrayOf(
Color(0xFF5865F2),
Color(0xFF57F287),
Color(0xFFFEE75C),
Color(0xFFEB459E),
Color(0xFFED4245),
Color(0xFFFF7F50),
Color(0xFF9B59B6),
Color(0xFF1ABC9C),
)
private fun colorForSender(name: String): Color {
return senderColors[name.hashCode().ushr(1) % senderColors.size]
}
private val todayFormat = SimpleDateFormat("h:mm a", Locale.US)
private val olderFormat = SimpleDateFormat("MM/dd/yyyy h:mm a", Locale.US)
private fun formatTimestamp(timestamp: Long): String {
if (timestamp == 0L) return ""
val diff = System.currentTimeMillis() - timestamp
return if (diff < 86_400_000) todayFormat.format(Date(timestamp))
else olderFormat.format(Date(timestamp))
}
@Composable
fun MessageTimeline(
selectedChannel: RoomId?,
channelName: String?,
messages: List<MessageItem>,
onToggleMemberList: () -> Unit,
onSendMessage: (String) -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(),
) {
var fullscreenImageUrl by remember { mutableStateOf<String?>(null) }
var fullscreenVideoUrl by remember { mutableStateOf<String?>(null) }
if (fullscreenImageUrl != null) {
FullscreenImageViewer(
url = fullscreenImageUrl!!,
onDismiss = { fullscreenImageUrl = null },
)
}
if (fullscreenVideoUrl != null) {
FullscreenVideoPlayer(
url = fullscreenVideoUrl!!,
onDismiss = { fullscreenVideoUrl = null },
)
}
CompositionLocalProvider(
LocalImageViewer provides { url -> fullscreenImageUrl = url },
LocalVideoPlayer provides { url -> fullscreenVideoUrl = url },
) {
Column(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(top = contentPadding.calculateTopPadding()),
) {
if (selectedChannel != null) {
TopBar(channelName ?: selectedChannel.full, onToggleMemberList)
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
}
if (selectedChannel == null) {
Box(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Text(
"Select a room to start chatting",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val isAtBottom by remember {
derivedStateOf {
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
}
}
// Auto-scroll when near bottom and new messages arrive
LaunchedEffect(messages.size) {
if (listState.firstVisibleItemIndex <= 2) {
listState.animateScrollToItem(0)
}
}
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
reverseLayout = true,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) {
val count = messages.size
items(
count = count,
key = { messages[it].eventId },
contentType = {
val msg = messages[it]
val next = if (it + 1 < count) messages[it + 1] else null
if (next == null || next.senderId != msg.senderId) 0 else 1
},
) { index ->
val message = messages[index]
val next = if (index + 1 < count) messages[index + 1] else null
val isFirstInGroup = next == null || next.senderName != message.senderName
if (isFirstInGroup) {
Spacer(modifier = Modifier.height(12.dp))
FullMessage(message)
} else {
CompactMessage(message.content)
}
}
}
// Jump to bottom button
if (!isAtBottom) {
IconButton(
onClick = { scope.launch { listState.animateScrollToItem(0) } },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
) {
Icon(
Icons.Default.KeyboardArrowDown,
"Jump to bottom",
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
MessageInput(channelName ?: "message", onSendMessage)
}
}
}
}
@Composable
private fun TopBar(name: String, onToggleMemberList: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.Tag, null, Modifier.size(20.dp), MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(6.dp))
Text(
name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onToggleMemberList) {
Icon(Icons.Default.People, "Toggle member list", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun FullMessage(message: MessageItem) {
val senderColor = remember(message.senderName) { colorForSender(message.senderName) }
val time = remember(message.timestamp) { formatTimestamp(message.timestamp) }
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
if (message.senderAvatarUrl != null) {
AsyncImage(
model = message.senderAvatarUrl,
contentDescription = null,
modifier = Modifier.size(40.dp).clip(CircleShape),
contentScale = ContentScale.Crop,
)
} else {
Box(
modifier = Modifier.size(40.dp).clip(CircleShape).background(senderColor.copy(alpha = 0.3f)),
contentAlignment = Alignment.Center,
) {
Text(
message.senderName.take(1).uppercase(),
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = senderColor,
)
}
}
Spacer(Modifier.width(12.dp))
Column {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(message.senderName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = senderColor)
Text(time, style = MaterialTheme.typography.labelSmall, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(Modifier.height(2.dp))
MessageContentView(message.content)
}
}
}
@Composable
private fun CompactMessage(content: MessageContent) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
Spacer(Modifier.width(52.dp))
MessageContentView(content)
}
}
@Composable
private fun MessageContentView(content: MessageContent) {
when (content) {
is MessageContent.Text -> TextContent(content)
is MessageContent.Image -> ImageContent(content)
is MessageContent.Video -> VideoContent(content)
is MessageContent.File -> FileContent(content)
}
}
@Composable
private fun TextContent(content: MessageContent.Text) {
Markdown(
content = content.body,
colors = markdownColor(
text = MaterialTheme.colorScheme.onBackground,
),
typography = markdownTypography(
text = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp),
),
imageTransformer = Coil3ImageTransformerImpl,
)
}
@Composable
private fun ImageContent(content: MessageContent.Image) {
val onViewImage = LocalImageViewer.current
val aspectRatio = if (content.width != null && content.height != null && content.height > 0)
content.width.toFloat() / content.height.toFloat() else null
AsyncImage(
model = content.url,
contentDescription = content.body,
modifier = Modifier
.let { if (aspectRatio != null) it.width((300.dp * aspectRatio).coerceAtMost(400.dp)) else it.fillMaxWidth(0.6f) }
.height(300.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { onViewImage(content.url) },
contentScale = ContentScale.Fit,
)
}
@Composable
private fun VideoContent(content: MessageContent.Video) {
val onPlayVideo = LocalVideoPlayer.current
Box(
modifier = Modifier
.height(200.dp)
.let { mod ->
val ar = if (content.width != null && content.height != null && content.height > 0)
content.width.toFloat() / content.height.toFloat() else 16f / 9f
mod.width((200.dp * ar).coerceAtMost(400.dp))
}
.clip(RoundedCornerShape(8.dp))
.clickable { content.url?.let { onPlayVideo(it) } },
contentAlignment = Alignment.Center,
) {
if (content.thumbnailUrl != null) {
AsyncImage(
model = content.thumbnailUrl,
contentDescription = content.body,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant))
}
Icon(
Icons.Default.PlayCircleFilled, "Play",
modifier = Modifier.size(48.dp),
tint = Color.White.copy(alpha = 0.85f),
)
}
}
@Composable
private fun FullscreenImageViewer(url: String, onDismiss: () -> Unit) {
var scale by remember { mutableStateOf(1f) }
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
) {
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(1f, 5f)
if (scale > 1f) {
offsetX += pan.x
offsetY += pan.y
} else {
offsetX = 0f
offsetY = 0f
}
}
}
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY,
),
contentScale = ContentScale.Fit,
)
IconButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
.size(40.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f)),
) {
Icon(Icons.Default.Close, "Close", tint = Color.White)
}
}
}
}
@Composable
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
private fun FullscreenVideoPlayer(url: String, onDismiss: () -> Unit) {
val context = LocalContext.current
val authRepository: AuthRepository = koinInject()
val exoPlayer = remember {
val token = authRepository.getAccessToken()
val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
if (token != null) {
setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
}
}
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(Uri.parse(url)))
ExoPlayer.Builder(context).build().apply {
setMediaSource(mediaSource)
prepare()
playWhenReady = true
}
}
DisposableEffect(Unit) {
onDispose { exoPlayer.release() }
}
Dialog(
onDismissRequest = {
exoPlayer.stop()
onDismiss()
},
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
useController = true
}
},
modifier = Modifier.fillMaxSize(),
)
IconButton(
onClick = {
exoPlayer.stop()
onDismiss()
},
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
.size(40.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f)),
) {
Icon(Icons.Default.Close, "Close", tint = Color.White)
}
}
}
}
@Composable
private fun FileContent(content: MessageContent.File) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Default.AttachFile, null, Modifier.size(20.dp), MaterialTheme.colorScheme.onSurfaceVariant)
Column {
Text(
content.fileName ?: content.body,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onBackground,
)
if (content.size != null) {
Text(
formatFileSize(content.size),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
private fun formatFileSize(bytes: Long): String {
return when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
else -> "${"%.1f".format(bytes / (1024.0 * 1024.0))} MB"
}
}
@Composable
private fun MessageInput(channelName: String, onSendMessage: (String) -> Unit) {
var text by remember { mutableStateOf("") }
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextField(
value = text,
onValueChange = { text = it },
placeholder = { Text("Message #$channelName", color = MaterialTheme.colorScheme.onSurfaceVariant) },
modifier = Modifier.weight(1f).clip(RoundedCornerShape(8.dp)),
singleLine = true,
colors = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
),
)
IconButton(
onClick = { if (text.isNotBlank()) { onSendMessage(text.trim()); text = "" } },
enabled = text.isNotBlank(),
) {
Icon(
Icons.AutoMirrored.Filled.Send, "Send",
tint = if (text.isNotBlank()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@@ -0,0 +1,134 @@
package com.example.fluffytrix.ui.screens.main.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.example.fluffytrix.ui.screens.main.SpaceItem
import net.folivo.trixnity.core.model.RoomId
@Composable
fun SpaceList(
spaces: List<SpaceItem>,
selectedSpace: RoomId?,
onSpaceClick: (RoomId) -> Unit,
onToggleChannelList: () -> Unit,
contentPadding: PaddingValues,
) {
LazyColumn(
modifier = Modifier
.width(64.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(top = contentPadding.calculateTopPadding()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp),
) {
// Channel list toggle
item {
IconButton(onClick = onToggleChannelList) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = "Toggle channel list",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Home button
item {
Box(
modifier = Modifier
.size(48.dp)
.clip(if (selectedSpace == null) RoundedCornerShape(16.dp) else CircleShape)
.background(
if (selectedSpace == null) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surface
)
.clickable { /* home/all rooms */ },
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.Home,
contentDescription = "Home",
tint = if (selectedSpace == null) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface,
)
}
}
items(spaces, key = { it.id.full }) { space ->
val isSelected = space.id == selectedSpace
Box(
modifier = Modifier
.size(48.dp)
.clip(if (isSelected) RoundedCornerShape(16.dp) else CircleShape)
.background(
if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surface
)
.clickable { onSpaceClick(space.id) },
contentAlignment = Alignment.Center,
) {
if (space.avatarUrl != null) {
var imageError by remember { mutableStateOf(false) }
if (!imageError) {
AsyncImage(
model = space.avatarUrl,
contentDescription = space.name,
modifier = Modifier.size(48.dp).clip(if (isSelected) RoundedCornerShape(16.dp) else CircleShape),
contentScale = ContentScale.Crop,
onError = { imageError = true },
)
} else {
SpaceInitial(space.name, isSelected)
}
} else {
SpaceInitial(space.name, isSelected)
}
}
}
}
}
@Composable
private fun SpaceInitial(name: String, isSelected: Boolean) {
Text(
text = name.take(2).uppercase(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
color = if (isSelected) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface,
)
}

View File

@@ -0,0 +1,539 @@
package com.example.fluffytrix.ui.screens.verification
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.koin.androidx.compose.koinViewModel
@Composable
fun VerificationScreen(
onVerified: () -> Unit,
onSkip: () -> Unit,
viewModel: VerificationViewModel = koinViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(uiState) {
if (uiState is VerificationUiState.AlreadyVerified ||
uiState is VerificationUiState.VerificationDone
) {
onVerified()
}
}
Scaffold { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.widthIn(max = 480.dp)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
when (val state = uiState) {
is VerificationUiState.Loading -> LoadingContent()
is VerificationUiState.NoCrossSigning -> NoCrossSigningContent(onSkip)
is VerificationUiState.AlreadyVerified -> {} // handled by LaunchedEffect
is VerificationUiState.VerificationDone -> VerificationDoneContent()
is VerificationUiState.MethodSelection -> MethodSelectionContent(
state = state,
onDeviceVerification = { viewModel.startDeviceVerification() },
onRecoveryKey = { viewModel.selectRecoveryKey() },
onPassphrase = { viewModel.selectPassphrase() },
onSkip = onSkip,
)
is VerificationUiState.RecoveryKeyInput -> RecoveryKeyInputContent(
onVerify = { viewModel.verifyWithRecoveryKey(it) },
onBack = { viewModel.goBack() },
)
is VerificationUiState.PassphraseInput -> PassphraseInputContent(
onVerify = { viewModel.verifyWithPassphrase(it) },
onBack = { viewModel.goBack() },
)
is VerificationUiState.WaitingForDevice -> WaitingForDeviceContent(
onCancel = { viewModel.goBack() },
)
is VerificationUiState.EmojiComparison -> EmojiComparisonContent(
emojis = state.emojis,
decimals = state.decimals,
onMatch = { viewModel.confirmEmojiMatch() },
onNoMatch = { viewModel.rejectEmojiMatch() },
)
is VerificationUiState.Error -> ErrorContent(
message = state.message,
onRetry = { viewModel.goBack() },
onSkip = onSkip,
)
}
}
}
}
}
@Composable
private fun LoadingContent() {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Checking verification status...",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun NoCrossSigningContent(onSkip: () -> Unit) {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = "Cross-signing not set up",
style = MaterialTheme.typography.headlineMedium,
)
Text(
text = "Your account doesn't have cross-signing enabled. Set it up from another client like Element to verify this device.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Button(onClick = onSkip) {
Text("Continue without verification")
}
}
@Composable
private fun MethodSelectionContent(
state: VerificationUiState.MethodSelection,
onDeviceVerification: () -> Unit,
onRecoveryKey: () -> Unit,
onPassphrase: () -> Unit,
onSkip: () -> Unit,
) {
Icon(
imageVector = Icons.Default.Verified,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = "Verify this device",
style = MaterialTheme.typography.headlineMedium,
)
Text(
text = "Confirm your identity to access encrypted messages.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
if (state.hasDeviceVerification) {
Card(
onClick = onDeviceVerification,
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.PhoneAndroid,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column {
Text(
text = "Verify with another device",
style = MaterialTheme.typography.titleMedium,
)
Text(
text = "Compare emojis on both devices",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
if (state.hasRecoveryKey) {
Card(
onClick = onRecoveryKey,
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Key,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column {
Text(
text = "Enter recovery key",
style = MaterialTheme.typography.titleMedium,
)
Text(
text = "Use your security key to verify",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
if (state.hasPassphrase) {
Card(
onClick = onPassphrase,
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column {
Text(
text = "Enter security phrase",
style = MaterialTheme.typography.titleMedium,
)
Text(
text = "Use your passphrase to verify",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = onSkip) {
Text("Skip for now")
}
}
@Composable
private fun RecoveryKeyInputContent(
onVerify: (String) -> Unit,
onBack: () -> Unit,
) {
var recoveryKey by remember { mutableStateOf("") }
Icon(
imageVector = Icons.Default.Key,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = "Enter recovery key",
style = MaterialTheme.typography.headlineMedium,
)
Text(
text = "Enter the recovery key you saved when setting up cross-signing.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
OutlinedTextField(
value = recoveryKey,
onValueChange = { recoveryKey = it },
label = { Text("Recovery key") },
modifier = Modifier.fillMaxWidth(),
singleLine = false,
minLines = 2,
)
Button(
onClick = { onVerify(recoveryKey.trim()) },
modifier = Modifier.fillMaxWidth(),
enabled = recoveryKey.isNotBlank(),
) {
Text("Verify")
}
TextButton(onClick = onBack) {
Text("Back")
}
}
@Composable
private fun PassphraseInputContent(
onVerify: (String) -> Unit,
onBack: () -> Unit,
) {
var passphrase by remember { mutableStateOf("") }
Icon(
imageVector = Icons.Default.Lock,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = "Enter security phrase",
style = MaterialTheme.typography.headlineMedium,
)
Text(
text = "Enter the passphrase you set when configuring cross-signing.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
OutlinedTextField(
value = passphrase,
onValueChange = { passphrase = it },
label = { Text("Security phrase") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Button(
onClick = { onVerify(passphrase) },
modifier = Modifier.fillMaxWidth(),
enabled = passphrase.isNotBlank(),
) {
Text("Verify")
}
TextButton(onClick = onBack) {
Text("Back")
}
}
@Composable
private fun WaitingForDeviceContent(onCancel: () -> Unit) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Waiting for other device",
style = MaterialTheme.typography.headlineMedium,
)
Text(
text = "Accept the verification request on your other device.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
OutlinedButton(onClick = onCancel) {
Text("Cancel")
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun EmojiComparisonContent(
emojis: List<Pair<Int, String>>,
decimals: List<Int>,
onMatch: () -> Unit,
onNoMatch: () -> Unit,
) {
Text(
text = "Compare emojis",
style = MaterialTheme.typography.headlineMedium,
)
Text(
text = "Confirm the emojis below match on your other device.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
// Emoji grid
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
emojis.forEach { (code, description) ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(8.dp)
)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(
text = emojiForCode(code),
fontSize = 32.sp,
)
Text(
text = description,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
// Decimal comparison
Text(
text = decimals.joinToString(" "),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium,
)
Spacer(modifier = Modifier.height(16.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
OutlinedButton(
onClick = onNoMatch,
modifier = Modifier.weight(1f),
) {
Text("They don't match")
}
Button(
onClick = onMatch,
modifier = Modifier.weight(1f),
) {
Text("They match")
}
}
}
@Composable
private fun VerificationDoneContent() {
Icon(
imageVector = Icons.Default.Verified,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = "Device verified!",
style = MaterialTheme.typography.headlineMedium,
)
}
@Composable
private fun ErrorContent(
message: String,
onRetry: () -> Unit,
onSkip: () -> Unit,
) {
Text(
text = "Verification failed",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.error,
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Button(onClick = onRetry) {
Text("Try again")
}
TextButton(onClick = onSkip) {
Text("Skip for now")
}
}
// SAS emoji mapping per Matrix spec
private fun emojiForCode(code: Int): String {
val emojiMap = arrayOf(
"\uD83D\uDC36", "\uD83D\uDC31", "\uD83E\uDD81", "\uD83D\uDC34", "\uD83E\uDD84", // dog, cat, lion, horse, unicorn
"\uD83D\uDC37", "\uD83D\uDC18", "\uD83D\uDC30", "\uD83D\uDC3C", "\uD83D\uDC14", // pig, elephant, rabbit, panda, rooster
"\uD83D\uDC27", "\uD83D\uDC22", "\uD83D\uDC1F", "\uD83D\uDC19", "\uD83E\uDD8B", // penguin, turtle, fish, octopus, butterfly
"\uD83C\uDF3B", "\uD83C\uDF35", "\uD83C\uDF44", "\uD83C\uDF3E", "\uD83C\uDF3A", // sunflower, cactus, mushroom, globe, rose (using rice/hibiscus)
"\uD83C\uDF39", "\uD83C\uDF3C", "\uD83C\uDF34", "\uD83C\uDF32", "\uD83C\uDF33", // rose, daisy, palm, evergreen, deciduous
"\uD83C\uDFE0", "\uD83C\uDFE2", "\uD83C\uDFE9", "\uD83D\uDC69", "\uD83D\uDE3A", // house, office, church (approximated), woman, smiley cat (approximated)
"\uD83D\uDE38", "\uD83C\uDF0D", "\uD83C\uDF19", "\u2601\uFE0F", "\uD83D\uDD25", // grinning cat, globe, moon, cloud, fire
"\uD83C\uDF4C", "\uD83C\uDF4E", "\uD83C\uDF53", "\uD83C\uDF5E", "\uD83C\uDF54", // banana, apple, strawberry, bread (approximated), hamburger (approximated)
"\uD83C\uDF55", "\u2615", "\uD83C\uDF70", "\uD83D\uDC53", "\uD83D\uDC54", // pizza, coffee (approximated), cake (approximated), glasses, tie (approximated)
"\uD83C\uDFA9", "\uD83D\uDD11", "\uD83C\uDFC6", "\u2764\uFE0F", "\uD83D\uDC8D", // top hat (approximated), key, trophy, heart, ring (approximated)
"\uD83D\uDCF1", "\uD83D\uDCBB", "\uD83D\uDCE1", "\u2708\uFE0F", "\uD83D\uDE80", // phone, laptop, satellite, plane, rocket
"\uD83D\uDE82", "\u2693", "\uD83D\uDE97", "\u26BD", "\uD83C\uDFB5", // train, anchor, car, soccer, music (approximated)
"\uD83C\uDFB8", "\uD83C\uDFBA", "\uD83D\uDD14", "\u2648", "\u2649", // guitar, trumpet, bell (approximated), aries (approximated), taurus (approximated)
"\u264A", "\u264B", // gemini, cancer (approximated)
)
return if (code in emojiMap.indices) emojiMap[code] else "\u2753" // question mark fallback
}

View File

@@ -0,0 +1,248 @@
package com.example.fluffytrix.ui.screens.verification
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.fluffytrix.data.repository.AuthRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import net.folivo.trixnity.client.verification
import net.folivo.trixnity.client.verification.ActiveDeviceVerification
import net.folivo.trixnity.client.verification.ActiveSasVerificationState
import net.folivo.trixnity.client.verification.ActiveVerificationState
import net.folivo.trixnity.client.verification.SelfVerificationMethod
import net.folivo.trixnity.client.verification.VerificationService.SelfVerificationMethods
sealed class VerificationUiState {
data object Loading : VerificationUiState()
data object AlreadyVerified : VerificationUiState()
data object NoCrossSigning : VerificationUiState()
data class MethodSelection(
val hasDeviceVerification: Boolean,
val hasRecoveryKey: Boolean,
val hasPassphrase: Boolean,
) : VerificationUiState()
data object RecoveryKeyInput : VerificationUiState()
data object PassphraseInput : VerificationUiState()
data object WaitingForDevice : VerificationUiState()
data class EmojiComparison(
val emojis: List<Pair<Int, String>>,
val decimals: List<Int>,
) : VerificationUiState()
data object VerificationDone : VerificationUiState()
data class Error(val message: String) : VerificationUiState()
}
class VerificationViewModel(
private val authRepository: AuthRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow<VerificationUiState>(VerificationUiState.Loading)
val uiState: StateFlow<VerificationUiState> = _uiState
private var selfVerificationMethods: SelfVerificationMethods? = null
private var activeDeviceVerification: ActiveDeviceVerification? = null
init {
loadVerificationMethods()
}
private fun loadVerificationMethods() {
val client = authRepository.getClient() ?: run {
_uiState.value = VerificationUiState.Error("Not logged in")
return
}
viewModelScope.launch {
client.verification.getSelfVerificationMethods().collectLatest { methods ->
selfVerificationMethods = methods
when (methods) {
is SelfVerificationMethods.PreconditionsNotMet ->
_uiState.value = VerificationUiState.Loading
is SelfVerificationMethods.NoCrossSigningEnabled ->
_uiState.value = VerificationUiState.NoCrossSigning
is SelfVerificationMethods.AlreadyCrossSigned ->
_uiState.value = VerificationUiState.AlreadyVerified
is SelfVerificationMethods.CrossSigningEnabled -> {
if (methods.methods.isEmpty()) {
_uiState.value = VerificationUiState.NoCrossSigning
} else {
_uiState.value = VerificationUiState.MethodSelection(
hasDeviceVerification = methods.methods.any {
it is SelfVerificationMethod.CrossSignedDeviceVerification
},
hasRecoveryKey = methods.methods.any {
it is SelfVerificationMethod.AesHmacSha2RecoveryKey
},
hasPassphrase = methods.methods.any {
it is SelfVerificationMethod.AesHmacSha2RecoveryKeyWithPbkdf2Passphrase
},
)
}
}
}
}
}
}
fun startDeviceVerification() {
val methods = selfVerificationMethods as? SelfVerificationMethods.CrossSigningEnabled ?: return
val deviceMethod = methods.methods.filterIsInstance<SelfVerificationMethod.CrossSignedDeviceVerification>()
.firstOrNull() ?: return
_uiState.value = VerificationUiState.WaitingForDevice
viewModelScope.launch {
deviceMethod.createDeviceVerification()
.onSuccess { verification ->
activeDeviceVerification = verification
observeVerificationState(verification)
}
.onFailure {
_uiState.value = VerificationUiState.Error(it.message ?: "Failed to start verification")
}
}
}
private fun observeVerificationState(verification: ActiveDeviceVerification) {
viewModelScope.launch {
verification.state.collectLatest { state ->
when (state) {
is ActiveVerificationState.TheirRequest -> {
state.ready()
}
is ActiveVerificationState.Ready -> {
state.start(net.folivo.trixnity.core.model.events.m.key.verification.VerificationMethod.Sas)
}
is ActiveVerificationState.Start -> {
val method = state.method
if (method is net.folivo.trixnity.client.verification.ActiveSasVerificationMethod) {
observeSasState(method)
}
}
is ActiveVerificationState.Done -> {
_uiState.value = VerificationUiState.VerificationDone
}
is ActiveVerificationState.Cancel -> {
_uiState.value = VerificationUiState.Error(
"Verification cancelled: ${state.content.reason}"
)
}
else -> { /* OwnRequest, WaitForDone, etc - keep current state */ }
}
}
}
}
private fun observeSasState(method: net.folivo.trixnity.client.verification.ActiveSasVerificationMethod) {
viewModelScope.launch {
method.state.collectLatest { sasState ->
when (sasState) {
is ActiveSasVerificationState.TheirSasStart -> {
sasState.accept()
}
is ActiveSasVerificationState.ComparisonByUser -> {
_uiState.value = VerificationUiState.EmojiComparison(
emojis = sasState.emojis,
decimals = sasState.decimal,
)
}
else -> { /* OwnSasStart, Accept, WaitForKeys, WaitForMacs */ }
}
}
}
}
fun confirmEmojiMatch() {
val state = (_uiState.value as? VerificationUiState.EmojiComparison) ?: return
viewModelScope.launch {
val verification = activeDeviceVerification ?: return@launch
val verState = verification.state.value
if (verState is ActiveVerificationState.Start) {
val method = verState.method
if (method is net.folivo.trixnity.client.verification.ActiveSasVerificationMethod) {
val sasState = method.state.value
if (sasState is ActiveSasVerificationState.ComparisonByUser) {
sasState.match()
}
}
}
}
}
fun rejectEmojiMatch() {
viewModelScope.launch {
val verification = activeDeviceVerification ?: return@launch
val verState = verification.state.value
if (verState is ActiveVerificationState.Start) {
val method = verState.method
if (method is net.folivo.trixnity.client.verification.ActiveSasVerificationMethod) {
val sasState = method.state.value
if (sasState is ActiveSasVerificationState.ComparisonByUser) {
sasState.noMatch()
}
}
}
}
}
fun selectRecoveryKey() {
_uiState.value = VerificationUiState.RecoveryKeyInput
}
fun selectPassphrase() {
_uiState.value = VerificationUiState.PassphraseInput
}
fun verifyWithRecoveryKey(recoveryKey: String) {
val methods = selfVerificationMethods as? SelfVerificationMethods.CrossSigningEnabled ?: return
val keyMethod = methods.methods.filterIsInstance<SelfVerificationMethod.AesHmacSha2RecoveryKey>()
.firstOrNull() ?: return
viewModelScope.launch {
keyMethod.verify(recoveryKey)
.onSuccess {
_uiState.value = VerificationUiState.VerificationDone
}
.onFailure {
_uiState.value = VerificationUiState.Error(
it.message ?: "Invalid recovery key"
)
}
}
}
fun verifyWithPassphrase(passphrase: String) {
val methods = selfVerificationMethods as? SelfVerificationMethods.CrossSigningEnabled ?: return
val passphraseMethod = methods.methods
.filterIsInstance<SelfVerificationMethod.AesHmacSha2RecoveryKeyWithPbkdf2Passphrase>()
.firstOrNull() ?: return
viewModelScope.launch {
passphraseMethod.verify(passphrase)
.onSuccess {
_uiState.value = VerificationUiState.VerificationDone
}
.onFailure {
_uiState.value = VerificationUiState.Error(
it.message ?: "Invalid passphrase"
)
}
}
}
fun goBack() {
loadVerificationMethods()
}
}

View File

@@ -0,0 +1,33 @@
package com.example.fluffytrix.ui.theme
import androidx.compose.ui.graphics.Color
// Discord-inspired dark fallback palette
val DarkPrimary = Color(0xFF5865F2)
val DarkOnPrimary = Color(0xFFFFFFFF)
val DarkPrimaryContainer = Color(0xFF4752C4)
val DarkOnPrimaryContainer = Color(0xFFDDE1FF)
val DarkSecondary = Color(0xFF72767D)
val DarkOnSecondary = Color(0xFFFFFFFF)
val DarkBackground = Color(0xFF313338)
val DarkOnBackground = Color(0xFFDBDEE1)
val DarkSurface = Color(0xFF2B2D31)
val DarkOnSurface = Color(0xFFDBDEE1)
val DarkSurfaceVariant = Color(0xFF1E1F22)
val DarkOnSurfaceVariant = Color(0xFFB5BAC1)
val DarkError = Color(0xFFED4245)
// Light fallback palette
val LightPrimary = Color(0xFF5865F2)
val LightOnPrimary = Color(0xFFFFFFFF)
val LightPrimaryContainer = Color(0xFFE3E5FC)
val LightOnPrimaryContainer = Color(0xFF2D3180)
val LightSecondary = Color(0xFF4F5660)
val LightOnSecondary = Color(0xFFFFFFFF)
val LightBackground = Color(0xFFFFFFFF)
val LightOnBackground = Color(0xFF060607)
val LightSurface = Color(0xFFF2F3F5)
val LightOnSurface = Color(0xFF060607)
val LightSurfaceVariant = Color(0xFFE3E5E8)
val LightOnSurfaceVariant = Color(0xFF4E5058)
val LightError = Color(0xFFED4245)

View File

@@ -0,0 +1,65 @@
package com.example.fluffytrix.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = DarkPrimary,
onPrimary = DarkOnPrimary,
primaryContainer = DarkPrimaryContainer,
onPrimaryContainer = DarkOnPrimaryContainer,
secondary = DarkSecondary,
onSecondary = DarkOnSecondary,
background = DarkBackground,
onBackground = DarkOnBackground,
surface = DarkSurface,
onSurface = DarkOnSurface,
surfaceVariant = DarkSurfaceVariant,
onSurfaceVariant = DarkOnSurfaceVariant,
error = DarkError,
)
private val LightColorScheme = lightColorScheme(
primary = LightPrimary,
onPrimary = LightOnPrimary,
primaryContainer = LightPrimaryContainer,
onPrimaryContainer = LightOnPrimaryContainer,
secondary = LightSecondary,
onSecondary = LightOnSecondary,
background = LightBackground,
onBackground = LightOnBackground,
surface = LightSurface,
onSurface = LightOnSurface,
surfaceVariant = LightSurfaceVariant,
onSurfaceVariant = LightOnSurfaceVariant,
error = LightError,
)
@Composable
fun FluffytrixTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = FluffytrixTypography,
content = content,
)
}

View File

@@ -0,0 +1,49 @@
package com.example.fluffytrix.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val FluffytrixTypography = Typography(
headlineLarge = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 34.sp,
),
headlineMedium = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
),
titleLarge = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
lineHeight = 24.sp,
),
titleMedium = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 22.sp,
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 22.sp,
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
),
labelLarge = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
),
labelMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
),
)

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.Fluffytrix" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your dark theme here. -->
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
</style>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Fluffytrix</string>
</resources>

View File

@@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.Fluffytrix" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
</style>
<style name="Theme.Fluffytrix" parent="Base.Theme.Fluffytrix" />
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,17 @@
package com.example.fluffytrix
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

5
build.gradle.kts Normal file
View File

@@ -0,0 +1,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
}

23
gradle.properties Normal file
View File

@@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

85
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,85 @@
[versions]
agp = "9.0.1"
kotlin = "2.2.10"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
appcompat = "1.6.1"
material = "1.10.0"
activity = "1.12.4"
constraintlayout = "2.1.4"
composeBom = "2025.06.00"
navigationCompose = "2.9.0"
lifecycleViewModel = "2.9.1"
koin = "4.1.1"
datastore = "1.1.7"
coroutines = "1.10.2"
trixnity = "4.22.7"
ktor = "3.3.0"
coil = "3.2.0"
media3 = "1.6.0"
markdownRenderer = "0.37.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
# Compose
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" }
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewModel" }
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleViewModel" }
# Koin
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
# DataStore
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
# Coroutines
coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
# Trixnity (using -jvm variants for Android)
trixnity-client = { group = "net.folivo", name = "trixnity-client-jvm", version.ref = "trixnity" }
trixnity-clientserverapi-client = { group = "net.folivo", name = "trixnity-clientserverapi-client-jvm", version.ref = "trixnity" }
trixnity-olm = { group = "net.folivo", name = "trixnity-olm-android", version.ref = "trixnity" }
trixnity-client-repository-room = { group = "net.folivo", name = "trixnity-client-repository-room-jvm", version.ref = "trixnity" }
# Ktor (needed by Trixnity on Android)
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
# Coil (image loading)
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
# Media3 (ExoPlayer)
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
# Markdown renderer
markdown-renderer-core = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-android", version.ref = "markdownRenderer" }
markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3-android", version.ref = "markdownRenderer" }
markdown-renderer-code = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-code-android", version.ref = "markdownRenderer" }
markdown-renderer-coil3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-coil3-android", version.ref = "markdownRenderer" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,9 @@
#Thu Feb 19 19:08:43 GMT 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

24
settings.gradle.kts Normal file
View File

@@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Fluffytrix"
include(":app")