Initial commit

This commit is contained in:
ltadeu6 2026-05-14 23:22:06 -03:00
commit 71af5efc4e
33 changed files with 3598 additions and 0 deletions

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
app/release/

106
AGENTS.md Normal file
View file

@ -0,0 +1,106 @@
# AGENTS.md — matrix-android
Progress log and architecture notes for AI agents working on this project.
---
## Status
| Component | Status |
|---|---|
| Build infrastructure (Gradle, version catalog) | done |
| Matrix networking (Retrofit API + Session) | done |
| SSO / password login flow | done |
| Room list screen | done |
| Chat screen (send + receive) | done |
| Terminal theme (green on black, monospace) | done |
| Launcher icon (vector adaptive) | done |
| README | done |
---
## Design decisions
### No heavy Matrix SDK
Instead of importing the full `matrix-android-sdk2` (used by Element), we call the Matrix REST API directly with Retrofit. This keeps the APK small and the code easy to follow. The trade-off is no E2E encryption support.
### SSO via Custom Tabs
Login opens the homeserver's `/login/sso/redirect` endpoint in a Chrome Custom Tab. After auth the homeserver redirects to `matrixandroid://sso?loginToken=<token>`. The app intercepts this URI via an `<intent-filter>` in `AndroidManifest.xml` and exchanges the token for an access token.
### Single-activity Compose navigation
`MainActivity``AppNavigation` hosts all three screens (Login, Rooms, Chat) inside a single `NavHost`. No fragments.
### MatrixSession singleton
`MatrixSession` is a process-scoped singleton (companion object). It holds the Retrofit client, persists the access token in `SharedPreferences`, and runs the `/sync` long-polling loop in a coroutine scope. All screens observe `syncState: StateFlow<SyncState>`.
### Sync filter
Initial and incremental syncs use a compact filter:
- Timeline limited to 50 events per room
- State events use lazy member loading
- Presence and account data stripped
This prevents the first sync from being huge on accounts with many rooms.
---
## Known issues / future work
- **E2E encryption**: Matrix Olm/Megolm is not implemented. Encrypted rooms will show blank messages.
- **Push notifications**: Not implemented. The app only receives messages while open.
- **Pagination**: No scroll-back pagination for old messages.
- **Room member display names**: Members are shown as their MXID, not display name.
- **Image/file messages**: Silently dropped (only `m.text` is rendered).
- **Error handling**: Network errors on send are silently swallowed; add a snackbar or retry UI.
- **Direct messages**: DMs appear in the room list using the room ID; should resolve the other user's display name.
---
## File map
```
matrix-android/
├── settings.gradle.kts
├── build.gradle.kts
├── gradle/
│ ├── libs.versions.toml ← version catalog
│ └── wrapper/
│ └── gradle-wrapper.properties
├── app/
│ ├── build.gradle.kts
│ ├── proguard-rules.pro
│ └── src/main/
│ ├── AndroidManifest.xml
│ ├── java/com/ltadeu6/matrix/
│ │ ├── MainActivity.kt
│ │ ├── navigation/AppNavigation.kt
│ │ ├── auth/LoginScreen.kt
│ │ ├── auth/LoginViewModel.kt
│ │ ├── rooms/RoomListScreen.kt
│ │ ├── chat/ChatScreen.kt
│ │ ├── chat/ChatViewModel.kt
│ │ ├── matrix/MatrixApi.kt
│ │ ├── matrix/MatrixModels.kt
│ │ ├── matrix/MatrixSession.kt
│ │ └── ui/theme/Theme.kt
│ └── res/
│ ├── drawable/ic_launcher_foreground.xml
│ ├── mipmap-anydpi-v26/ic_launcher.xml
│ ├── mipmap-anydpi-v26/ic_launcher_round.xml
│ └── values/{strings,colors,themes}.xml
├── README.md
└── AGENTS.md
```
---
## Session 1 — initial build (2026-05-14)
- Inspected `matrix-message` terminal app for UI reference:
- `app.py`: curses chat with `< ` prefix for incoming, `> ` for outgoing
- `font.py`: big pixel ASCII font for incoming banners
- Color scheme: `COLOR_GREEN` on black
- Created full Android project from scratch (no fork)
- Chose Retrofit + kotlinx.serialization over matrix-android-sdk2 for simplicity
- SSO flow: Custom Tabs → homeserver → `matrixandroid://sso` deep link → token exchange
- Compose UI with monospace font, #00FF41 green, #0A0A0A background
- All screens text-only, no images/icons/avatars

63
CLAUDE.md Normal file
View file

@ -0,0 +1,63 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build commands
```bash
# First-time setup: generate the Gradle wrapper (requires Gradle installed)
gradle wrapper --gradle-version 8.9
# Build debug APK
./gradlew assembleDebug
# Build release APK
./gradlew assembleRelease
# Install on connected device/emulator
./gradlew installDebug
# Run lint
./gradlew lint
# Clean
./gradlew clean
```
The project has no unit tests yet. Android Studio is the recommended way to open, sync, and run the project.
## Architecture
Single-activity Compose app. All screens live inside one `NavHost` in `AppNavigation.kt`. Navigation flow: **Login → Rooms → Chat**.
### State ownership
`MatrixSession` (singleton, `matrix/MatrixSession.kt`) is the single source of truth. It:
- Persists the access token in `SharedPreferences`
- Owns and runs the `/sync` long-polling coroutine loop
- Exposes `authState: StateFlow<AuthState>` and `syncState: StateFlow<SyncState>`
All ViewModels and screens observe these flows directly — there is no local room/message database.
### Matrix transport
Direct REST API via Retrofit (`MatrixApi.kt`). No Matrix SDK dependency. Only `m.room.message` text events are rendered; encrypted events are silently dropped.
### SSO login flow
1. `LoginViewModel.buildSsoUrl()` constructs `<homeserver>/_matrix/client/v3/login/sso/redirect?redirectUrl=matrixandroid://sso`
2. The URL opens in a Chrome Custom Tab
3. Homeserver redirects to `matrixandroid://sso?loginToken=<token>`
4. `MainActivity.onNewIntent` (hot) or `onCreate` (cold) reads the token and calls `MatrixSession.handleSsoCallback(token)`
5. `LoginScreen` observes `session.pendingSsoToken` and calls `vm.loginWithSsoToken(token)` which POSTs to `/_matrix/client/v3/login`
### Theme
`ui/theme/Theme.kt``MatrixGreen` (`#00FF41`) on `MatrixBackground` (`#0A0A0A`). All text uses `TerminalFont` (`FontFamily.Monospace`). No Material components with default colors — always pass explicit `colors =` overrides to `OutlinedTextField` and `Button`.
## Key conventions
- `MatrixSession` is always obtained via `MatrixSession.getInstance(context)` — never constructed directly
- Room IDs (`!abc:homeserver`) are URL-encoded when passed as nav arguments (`Route.chat(roomId)`)
- `SyncState.messages[roomId]` is append-only and deduped by `eventId`; ordering is by `origin_server_ts`
- The sync filter (`MatrixSession.syncFilter`) limits timeline to 50 events and strips presence/account data — change it here if more history is needed

125
README.md Normal file
View file

@ -0,0 +1,125 @@
# matrix-android
A minimal Android Matrix chat client with a terminal aesthetic — dark background, green monospace text, `<` / `>` message prefixes — inspired by the [matrix-message](../matrix-message) living-room terminal app.
## Features
- Browser-based SSO login (Custom Tabs — no password stored by the app)
- Password login fallback
- Room list after login
- Text-only chat (no images, no avatars)
- Real-time sync via Matrix `/sync` long-polling
- Persistent session (survives app restart)
## Screenshots / UI description
```
> MATRIX LOGIN (login screen)
────────────────────────
homeserver: matrix.org
[ LOGIN WITH BROWSER (SSO) ]
──── or password login ────
username: @user:matrix.org
password: ••••••••
[ LOGIN ]
> ROOMS (room list)
────────────────────────────────────────
> My Room
!abc123:matrix.org
> Family chat
!xyz456:example.com
[logout]
< back My Room (chat screen)
──────────────────────────────────────
< alice
hello!
> hey there >
< alice
how are you?
──────────────────────────────────────
> message...
```
## Requirements
- Android Studio Ladybug (2024.2) or later
- Android SDK 35
- minSdk 26 (Android 8.0)
## Build
1. Clone the repo
2. Open the `matrix-android/` folder in Android Studio
3. Let Gradle sync (it downloads all dependencies automatically)
4. Run on a device or emulator
If you prefer the command line, first generate the Gradle wrapper:
```bash
cd matrix-android
gradle wrapper --gradle-version 8.9
./gradlew assembleDebug
```
The APK is at `app/build/outputs/apk/debug/app-debug.apk`.
## Login
### SSO (recommended)
1. Enter your homeserver (default: `matrix.org`)
2. Tap **LOGIN WITH BROWSER (SSO)**
3. A browser tab opens to the homeserver's login/SSO page
4. After authenticating, you are redirected back to the app automatically
The app registers the `matrixandroid://sso` URI scheme for this redirect.
### Password
Enter your full Matrix username (`@user:homeserver.tld`) and password directly.
## Architecture
```
app/src/main/java/com/ltadeu6/matrix/
├── MainActivity.kt — single Activity; handles SSO deep link
├── navigation/
│ └── AppNavigation.kt — Compose NavHost (Login → Rooms → Chat)
├── auth/
│ ├── LoginScreen.kt — login UI
│ └── LoginViewModel.kt — SSO URL builder, login calls
├── rooms/
│ └── RoomListScreen.kt — joined rooms list
├── chat/
│ ├── ChatScreen.kt — message list + input bar
│ └── ChatViewModel.kt — sends messages, maps sync state
├── matrix/
│ ├── MatrixApi.kt — Retrofit interface (Matrix REST v3)
│ ├── MatrixModels.kt — serialization data classes + local models
│ └── MatrixSession.kt — singleton: auth, sync loop, state flows
└── ui/theme/
└── Theme.kt — terminal color scheme (green on black)
```
**State flow**: `MatrixSession` owns a `syncState: StateFlow<SyncState>` that all screens observe. The background sync loop continuously updates it via `/sync` long-polling.
## Dependencies
| Library | Purpose |
|---|---|
| Jetpack Compose + Material3 | UI |
| Navigation Compose | Screen routing |
| Retrofit 2 + OkHttp | Matrix HTTP API |
| kotlinx.serialization | JSON parsing |
| retrofit2-kotlinx-serialization-converter | Retrofit ↔ kotlinx bridge |
| kotlinx.coroutines | Async / sync loop |
| androidx.browser (Custom Tabs) | SSO browser login |
## Known limitations
- No E2E encryption (plain-text transport only; suitable for unencrypted rooms)
- No push notifications
- No media/file messages (text only)
- Room list is not paginated (loads whatever `/sync` returns on first sync)

69
app/build.gradle.kts Normal file
View file

@ -0,0 +1,69 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.ltadeu6.matrix"
compileSdk = 35
defaultConfig {
applicationId = "com.ltadeu6.matrix"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
packaging {
jniLibs.pickFirsts += "**/*.so"
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.navigation.compose)
implementation(libs.retrofit)
implementation(libs.okhttp)
implementation(libs.kotlinx.serialization.json)
implementation(libs.retrofit.converter.kotlinx.serialization)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.browser)
implementation(libs.androidx.appcompat)
implementation(libs.olm.sdk)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.okhttp.logging)
}

18
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,18 @@
# Retrofit
-keepattributes Signature
-keepattributes *Annotation*
-keep class retrofit2.** { *; }
-keepclasseswithmembers class * {
@retrofit2.http.* <methods>;
}
# kotlinx.serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keep,includedescriptorclasses class com.ltadeu6.matrix.**$$serializer { *; }
-keepclassmembers class com.ltadeu6.matrix.** {
*** Companion;
}
-keepclasseswithmembers class com.ltadeu6.matrix.** {
kotlinx.serialization.KSerializer serializer(...);
}

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="false"
android:theme="@style/Theme.MatrixAndroid">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<!-- Launcher -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- SSO callback: matrixandroid://sso?loginToken=... -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="matrixandroid"
android:host="sso" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,38 @@
package com.ltadeu6.matrix
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.ltadeu6.matrix.matrix.MatrixSession
import com.ltadeu6.matrix.navigation.AppNavigation
import com.ltadeu6.matrix.ui.theme.MatrixTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Token may arrive cold (app launched from SSO redirect)
val loginToken = intent.data
?.takeIf { it.scheme == "matrixandroid" }
?.getQueryParameter("loginToken")
setContent {
MatrixTheme {
AppNavigation(initialLoginToken = loginToken)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Token may arrive hot (app already running when SSO redirect fires)
val token = intent.data
?.takeIf { it.scheme == "matrixandroid" }
?.getQueryParameter("loginToken") ?: return
MatrixSession.getInstance(this).handleSsoCallback(token)
}
}

View file

@ -0,0 +1,222 @@
package com.ltadeu6.matrix.auth
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
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.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
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.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ltadeu6.matrix.matrix.MatrixSession
import com.ltadeu6.matrix.ui.theme.MatrixBackground
import com.ltadeu6.matrix.ui.theme.MatrixBorder
import com.ltadeu6.matrix.ui.theme.MatrixGreen
import com.ltadeu6.matrix.ui.theme.MatrixGreenDim
import com.ltadeu6.matrix.ui.theme.TerminalFont
@Composable
fun LoginScreen(
initialToken: String? = null,
onLoggedIn: () -> Unit
) {
val context = LocalContext.current
val session = MatrixSession.getInstance(context)
val vm: LoginViewModel = viewModel(factory = LoginViewModelFactory(session))
val state by vm.state.collectAsState()
val pendingSsoToken by session.pendingSsoToken.collectAsState()
val scope = rememberCoroutineScope()
// SSO token that arrived while app was already open
LaunchedEffect(pendingSsoToken) {
val token = pendingSsoToken ?: return@LaunchedEffect
session.consumeSsoToken()
vm.loginWithSsoToken(token)
}
// SSO token that launched the app cold
LaunchedEffect(Unit) {
if (initialToken != null) vm.loginWithSsoToken(initialToken)
}
LaunchedEffect(state.loggedIn) {
if (state.loggedIn) onLoggedIn()
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp, vertical = 48.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start
) {
Text(
text = "> MATRIX",
style = TextStyle(fontFamily = TerminalFont, fontSize = 22.sp, color = MatrixGreen)
)
Spacer(Modifier.height(2.dp))
Text(
text = "".repeat(28),
style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixBorder)
)
Spacer(Modifier.height(28.dp))
// Homeserver
TerminalLabel("homeserver:")
TerminalField(
value = state.homeserver,
onValueChange = vm::setHomeserver,
placeholder = "matrix.org",
imeAction = ImeAction.Next
)
Spacer(Modifier.height(20.dp))
// Browser SSO button
TerminalButton(
text = if (state.loading) "> CHECKING..." else "> LOGIN WITH BROWSER (SSO)",
enabled = !state.loading,
onClick = {
scope.launch {
val error = vm.checkSsoSupport()
if (error != null) {
// Surface the error through the VM state so the existing error text shows it
vm.setError(error)
} else {
CustomTabsIntent.Builder()
.build()
.launchUrl(context, Uri.parse(vm.buildSsoUrl()))
}
}
}
)
Spacer(Modifier.height(24.dp))
Text(
text = "──── or password login ────",
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder),
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Spacer(Modifier.height(16.dp))
// Username
TerminalLabel("username:")
TerminalField(
value = state.username,
onValueChange = vm::setUsername,
placeholder = "@user:matrix.org",
imeAction = ImeAction.Next
)
Spacer(Modifier.height(10.dp))
// Password
TerminalLabel("password:")
TerminalField(
value = state.password,
onValueChange = vm::setPassword,
placeholder = "••••••••",
imeAction = ImeAction.Done,
obscure = true,
onDone = vm::loginWithPassword
)
Spacer(Modifier.height(14.dp))
TerminalButton(
text = if (state.loading) "> CONNECTING..." else "> LOGIN",
enabled = !state.loading,
onClick = vm::loginWithPassword
)
state.error?.let { err ->
Spacer(Modifier.height(16.dp))
Text(
text = "! $err",
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = androidx.compose.ui.graphics.Color(0xFFFF4444))
)
}
}
}
@Composable
private fun TerminalLabel(text: String) {
Text(
text = text,
style = TextStyle(fontFamily = TerminalFont, fontSize = 13.sp, color = MatrixGreenDim)
)
Spacer(Modifier.height(4.dp))
}
@Composable
private fun TerminalField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
imeAction: ImeAction,
obscure: Boolean = false,
onDone: (() -> Unit)? = null
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(placeholder, style = TextStyle(fontFamily = TerminalFont, color = MatrixBorder))
},
textStyle = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreen),
singleLine = true,
visualTransformation = if (obscure) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,
keyboardOptions = KeyboardOptions(imeAction = imeAction),
keyboardActions = KeyboardActions(onDone = { onDone?.invoke() }),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MatrixGreen,
unfocusedBorderColor = MatrixBorder,
cursorColor = MatrixGreen,
focusedTextColor = MatrixGreen,
unfocusedTextColor = MatrixGreen,
focusedContainerColor = MatrixBackground,
unfocusedContainerColor = MatrixBackground
)
)
Spacer(Modifier.height(4.dp))
}
@Composable
private fun TerminalButton(text: String, enabled: Boolean = true, onClick: () -> Unit) {
Button(
onClick = onClick,
enabled = enabled,
modifier = Modifier.fillMaxWidth(),
border = BorderStroke(1.dp, if (enabled) MatrixGreen else MatrixBorder),
colors = ButtonDefaults.buttonColors(
containerColor = MatrixBackground,
contentColor = MatrixGreen,
disabledContainerColor = MatrixBackground,
disabledContentColor = MatrixBorder
)
) {
Text(text, style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp))
}
}

View file

@ -0,0 +1,83 @@
package com.ltadeu6.matrix.auth
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ltadeu6.matrix.matrix.MatrixSession
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class LoginState(
val homeserver: String = "matrix.org",
val username: String = "",
val password: String = "",
val loading: Boolean = false,
val error: String? = null,
val loggedIn: Boolean = false
)
class LoginViewModel(private val session: MatrixSession) : ViewModel() {
private val _state = MutableStateFlow(LoginState())
val state: StateFlow<LoginState> = _state.asStateFlow()
fun setHomeserver(v: String) { _state.value = _state.value.copy(homeserver = v, error = null) }
fun setUsername(v: String) { _state.value = _state.value.copy(username = v, error = null) }
fun setPassword(v: String) { _state.value = _state.value.copy(password = v, error = null) }
fun setError(msg: String) { _state.value = _state.value.copy(error = msg, loading = false) }
/** Returns the full SSO redirect URL to open in the browser. */
fun buildSsoUrl(): String {
val hs = _state.value.homeserver.trim().ifBlank { "matrix.org" }
val base = if (hs.startsWith("http")) hs else "https://$hs"
val redirect = Uri.encode("matrixandroid://sso")
return "$base/_matrix/client/v3/login/sso/redirect?redirectUrl=$redirect"
}
/** Checks if the homeserver supports SSO; returns null if supported, error message if not. */
suspend fun checkSsoSupport(): String? {
val hs = _state.value.homeserver.trim().ifBlank { "matrix.org" }
return session.checkSsoSupported(hs)
}
fun loginWithSsoToken(token: String) {
val hs = _state.value.homeserver.trim().ifBlank { "matrix.org" }
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
val result = session.loginWithToken(hs, token)
_state.value = if (result.isSuccess) {
_state.value.copy(loading = false, loggedIn = true)
} else {
val msg = result.exceptionOrNull()?.message ?: "SSO login failed"
_state.value.copy(loading = false, error = msg)
}
}
}
fun loginWithPassword() {
val s = _state.value
if (s.username.isBlank() || s.password.isBlank()) {
_state.value = s.copy(error = "username and password required")
return
}
viewModelScope.launch {
_state.value = s.copy(loading = true, error = null)
val hs = s.homeserver.trim().ifBlank { "matrix.org" }
val result = session.loginWithPassword(hs, s.username, s.password)
_state.value = if (result.isSuccess) {
_state.value.copy(loading = false, loggedIn = true)
} else {
_state.value.copy(loading = false, error = result.exceptionOrNull()?.message ?: "Login failed")
}
}
}
}
class LoginViewModelFactory(private val session: MatrixSession) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
LoginViewModel(session) as T
}

View file

@ -0,0 +1,197 @@
package com.ltadeu6.matrix.chat
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ltadeu6.matrix.matrix.MatrixSession
import com.ltadeu6.matrix.matrix.Message
import com.ltadeu6.matrix.ui.theme.MatrixBackground
import com.ltadeu6.matrix.ui.theme.MatrixBorder
import com.ltadeu6.matrix.ui.theme.MatrixGreen
import com.ltadeu6.matrix.ui.theme.MatrixGreenDim
import com.ltadeu6.matrix.ui.theme.TerminalFont
@Composable
fun ChatScreen(
roomId: String,
onBack: () -> Unit
) {
val context = LocalContext.current
val session = MatrixSession.getInstance(context)
val vm: ChatViewModel = viewModel(
key = roomId,
factory = ChatViewModelFactory(session, roomId)
)
val messages by vm.messages.collectAsState()
val roomName by vm.roomName.collectAsState()
val inputText by vm.inputText.collectAsState()
val listState = rememberLazyListState()
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(messages.size - 1)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onBack, contentPadding = PaddingValues(0.dp)) {
Text(
text = "<back",
style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreenDim)
)
}
Spacer(Modifier.weight(1f))
Text(
text = roomName,
style = TextStyle(fontFamily = TerminalFont, fontSize = 15.sp, color = MatrixGreen),
maxLines = 1
)
}
Text(
text = "".repeat(50),
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder),
modifier = Modifier.padding(horizontal = 8.dp)
)
// Message list
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp),
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
items(
items = messages,
key = { it.eventId.ifEmpty { "${it.sender}:${it.timestamp}" } }
) { msg ->
MessageRow(msg)
}
}
// Input separator
Text(
text = "".repeat(50),
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder),
modifier = Modifier.padding(horizontal = 8.dp)
)
// Input bar
Row(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.imePadding()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "> ",
style = TextStyle(fontFamily = TerminalFont, fontSize = 16.sp, color = MatrixGreen)
)
OutlinedTextField(
value = inputText,
onValueChange = vm::setInput,
modifier = Modifier
.weight(1f)
.padding(start = 4.dp),
placeholder = {
Text(
"message...",
style = TextStyle(fontFamily = TerminalFont, color = MatrixBorder)
)
},
textStyle = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreen),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { vm.sendMessage() }),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MatrixGreen,
unfocusedBorderColor = MatrixBorder,
cursorColor = MatrixGreen,
focusedTextColor = MatrixGreen,
unfocusedTextColor = MatrixGreen,
focusedContainerColor = MatrixBackground,
unfocusedContainerColor = MatrixBackground
)
)
}
}
}
@Composable
private fun MessageRow(msg: Message) {
val shortSender = msg.sender.removePrefix("@").substringBefore(":")
if (msg.isOutgoing) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Text(
text = "> ${msg.body}",
style = TextStyle(
fontFamily = TerminalFont,
fontSize = 14.sp,
color = MatrixGreenDim
),
modifier = Modifier.widthIn(max = 300.dp)
)
}
} else {
Column(modifier = Modifier.widthIn(max = 300.dp)) {
Text(
text = "< $shortSender",
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder)
)
Text(
text = " ${msg.body}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreen)
)
}
}
}

View file

@ -0,0 +1,53 @@
package com.ltadeu6.matrix.chat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ltadeu6.matrix.matrix.MatrixSession
import com.ltadeu6.matrix.matrix.Message
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class ChatViewModel(
private val session: MatrixSession,
val roomId: String
) : ViewModel() {
val messages: StateFlow<List<Message>> = session.syncState
.map { it.messages[roomId] ?: emptyList() }
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val roomName: StateFlow<String> = session.syncState
.map { it.rooms[roomId]?.name ?: roomId }
.stateIn(viewModelScope, SharingStarted.Eagerly, roomId)
private val _inputText = MutableStateFlow("")
val inputText: StateFlow<String> = _inputText.asStateFlow()
fun setInput(text: String) {
_inputText.value = text
}
fun sendMessage() {
val text = _inputText.value.trim()
if (text.isBlank()) return
_inputText.value = ""
viewModelScope.launch {
session.sendMessage(roomId, text)
}
}
}
class ChatViewModelFactory(
private val session: MatrixSession,
private val roomId: String
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
ChatViewModel(session, roomId) as T
}

View file

@ -0,0 +1,342 @@
package com.ltadeu6.matrix.matrix
import android.content.Context
import android.util.Base64
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import org.matrix.olm.OlmAccount
import org.matrix.olm.OlmInboundGroupSession
import org.matrix.olm.OlmManager
import org.matrix.olm.OlmMessage
import org.matrix.olm.OlmOutboundGroupSession
import org.matrix.olm.OlmSession
/**
* Encapsulates all Olm/Megolm cryptographic state for the local device.
*
* - [buildKeysUploadPayload] + [onKeysUploaded] called once after login to register keys.
* - [processToDeviceEvents] called every sync to receive incoming room keys.
* - [decryptRoomEvent] / [encryptRoomEvent] decrypt/encrypt room traffic.
* - [encryptOlmPayload] encrypt room-key to-device messages for other devices.
*/
class CryptoService(
context: Context,
private val userId: String,
private val deviceId: String
) {
private val prefs = context.getSharedPreferences("olm_crypto", Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true }
private val olmAccount: OlmAccount
private val inboundSessions = mutableMapOf<String, OlmInboundGroupSession>()
private val outboundSessions = mutableMapOf<String, OlmOutboundGroupSession>()
private val olmSessions = mutableMapOf<String, MutableList<OlmSession>>()
init {
OlmManager() // loads the native libolm .so
olmAccount = restoreAccount() ?: OlmAccount().also { persistAccount(it) }
restoreInboundSessions()
}
val curve25519Key: String
get() = try { olmAccount.identityKeys()[OlmAccount.JSON_KEY_IDENTITY_KEY] ?: "" } catch (_: Exception) { "" }
val ed25519Key: String
get() = try { olmAccount.identityKeys()[OlmAccount.JSON_KEY_FINGER_PRINT_KEY] ?: "" } catch (_: Exception) { "" }
// -------------------------------------------------------------------------
// Key upload
// -------------------------------------------------------------------------
fun buildKeysUploadPayload(): JsonObject {
olmAccount.generateOneTimeKeys(MAX_ONE_TIME_KEYS)
val otks = olmAccount.oneTimeKeys() // Map<String, Map<String, String>>
val deviceKeysBody = buildJsonObject {
put("user_id", userId)
put("device_id", deviceId)
put("algorithms", buildJsonArray {
add(JsonPrimitive("m.olm.v1.curve25519-aes-sha2"))
add(JsonPrimitive("m.megolm.v1.aes-sha2"))
})
put("keys", buildJsonObject {
put("curve25519:$deviceId", curve25519Key)
put("ed25519:$deviceId", ed25519Key)
})
}
val deviceKeysSig = olmAccount.signMessage(deviceKeysBody.toCanonicalJson())
val signedDeviceKeys = buildJsonObject {
deviceKeysBody.entries.forEach { (k, v) -> put(k, v) }
put("signatures", buildJsonObject {
put(userId, buildJsonObject { put("ed25519:$deviceId", deviceKeysSig) })
})
}
val curve25519Otks: Map<String, String> = otks["curve25519"] ?: emptyMap()
val oneTimeKeys = buildJsonObject {
curve25519Otks.forEach { (keyId, keyValue) ->
val keyObj = buildJsonObject { put("key", keyValue) }
val sig = olmAccount.signMessage(keyObj.toCanonicalJson())
put("signed_curve25519:$keyId", buildJsonObject {
put("key", keyValue)
put("signatures", buildJsonObject {
put(userId, buildJsonObject { put("ed25519:$deviceId", sig) })
})
})
}
}
return buildJsonObject {
put("device_keys", signedDeviceKeys)
put("one_time_keys", oneTimeKeys)
}
}
/** Call after a successful `/keys/upload` to mark one-time keys as consumed. */
fun onKeysUploaded() {
olmAccount.markOneTimeKeysAsPublished()
persistAccount(olmAccount)
}
// -------------------------------------------------------------------------
// To-device event processing (receive incoming room keys)
// -------------------------------------------------------------------------
fun processToDeviceEvents(events: List<JsonObject>) {
for (event in events) {
try { processToDeviceEvent(event) } catch (_: Exception) {}
}
}
private fun processToDeviceEvent(event: JsonObject) {
val type = (event["type"] as? JsonPrimitive)?.content ?: return
val content = event["content"]?.jsonObject ?: return
when (type) {
"m.room.encrypted" -> {
val plaintext = decryptOlmToDevice(content) ?: return
val inner = json.parseToJsonElement(plaintext).jsonObject
val innerType = (inner["type"] as? JsonPrimitive)?.content
val innerContent = inner["content"]?.jsonObject ?: return
when (innerType) {
"m.room_key" -> handleRoomKey(innerContent)
"m.forwarded_room_key" -> handleForwardedRoomKey(innerContent)
}
}
"m.room_key" -> handleRoomKey(content)
"m.forwarded_room_key" -> handleForwardedRoomKey(content)
}
}
private fun decryptOlmToDevice(content: JsonObject): String? {
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.olm.v1.curve25519-aes-sha2") return null
val senderKey = (content["sender_key"] as? JsonPrimitive)?.content ?: return null
val ciphertext = content["ciphertext"]?.jsonObject ?: return null
val ourMsg = ciphertext[curve25519Key]?.jsonObject ?: return null
val type = (ourMsg["type"] as? JsonPrimitive)?.content?.toIntOrNull() ?: return null
val body = (ourMsg["body"] as? JsonPrimitive)?.content ?: return null
// Try existing sessions first (type 1 — regular message)
if (type == OlmMessage.MESSAGE_TYPE_MESSAGE) {
olmSessions[senderKey]?.forEach { session ->
return try {
val olmMsg = OlmMessage().apply { mCipherText = body; mType = type.toLong() }
session.decryptMessage(olmMsg)
} catch (_: Exception) { null }
}
}
// Pre-key message (type 0) — create a new inbound Olm session
if (type == OlmMessage.MESSAGE_TYPE_PRE_KEY) {
return try {
val session = OlmSession()
session.initInboundSessionFrom(olmAccount, senderKey, body)
olmAccount.removeOneTimeKeys(session)
persistAccount(olmAccount)
olmSessions.getOrPut(senderKey) { mutableListOf() }.add(session)
val olmMsg = OlmMessage().apply { mCipherText = body; mType = type.toLong() }
session.decryptMessage(olmMsg)
} catch (_: Exception) { null }
}
return null
}
private fun handleRoomKey(content: JsonObject) {
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return
val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return
if (sessionId in inboundSessions) return
try {
val session = OlmInboundGroupSession(sessionKey)
inboundSessions[sessionId] = session
persistInboundSession(sessionId, session)
} catch (_: Exception) {}
}
/**
* Handles `m.forwarded_room_key` a key shared by another of our own devices in response
* to an `m.room_key_request`. Uses [OlmInboundGroupSession.importSession] because the
* forwarded key is in exported (message-index-aware) format.
*/
private fun handleForwardedRoomKey(content: JsonObject) {
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return
val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return
if (sessionId in inboundSessions) return
try {
val session = OlmInboundGroupSession.importSession(sessionKey)
inboundSessions[sessionId] = session
persistInboundSession(sessionId, session)
} catch (_: Exception) {}
}
// -------------------------------------------------------------------------
// Room message decryption / encryption
// -------------------------------------------------------------------------
fun decryptRoomEvent(content: JsonObject): String? {
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null
val ciphertext = (content["ciphertext"] as? JsonPrimitive)?.content ?: return null
val session = inboundSessions[sessionId] ?: return null
return try {
val result = session.decryptMessage(ciphertext)
val decrypted = json.parseToJsonElement(result.mDecryptedMessage).jsonObject
(decrypted["content"]?.jsonObject?.get("body") as? JsonPrimitive)?.content
} catch (_: Exception) { null }
}
fun encryptRoomEvent(roomId: String, plaintext: String): JsonObject? {
val session = outboundSessions[roomId] ?: return null
return try {
buildJsonObject {
put("algorithm", "m.megolm.v1.aes-sha2")
put("sender_key", curve25519Key)
put("session_id", session.sessionIdentifier())
put("ciphertext", session.encryptMessage(plaintext))
put("device_id", deviceId)
}
} catch (_: Exception) { null }
}
/**
* Gets or creates the outbound Megolm session for [roomId].
* Returns `(sessionId, sessionKey)` to be shared with room members, or null on failure.
*/
fun initOutboundSession(roomId: String): Pair<String, String>? {
val session = outboundSessions.getOrPut(roomId) {
OlmOutboundGroupSession().also { persistOutboundSession(roomId, it) }
}
return try {
val sessionId = session.sessionIdentifier()
val sessionKey = session.sessionKey()
if (sessionId !in inboundSessions) {
runCatching {
val inbound = OlmInboundGroupSession(sessionKey)
inboundSessions[sessionId] = inbound
persistInboundSession(sessionId, inbound)
}
}
Pair(sessionId, sessionKey)
} catch (_: Exception) { null }
}
/**
* Encrypts [payload] for [theirCurve25519] using a new outbound Olm session seeded
* with [theirOneTimeKey] (from `/keys/claim`).
* Returns the `ciphertext` object to embed in an `m.olm.v1.curve25519-aes-sha2` event.
*/
fun encryptOlmPayload(
payload: String,
theirCurve25519: String,
theirOneTimeKey: String
): JsonObject? {
return try {
val session = OlmSession()
session.initOutboundSession(olmAccount, theirCurve25519, theirOneTimeKey)
persistAccount(olmAccount)
olmSessions.getOrPut(theirCurve25519) { mutableListOf() }.add(session)
val msg = session.encryptMessage(payload)
buildJsonObject {
put(theirCurve25519, buildJsonObject {
put("type", msg.mType)
put("body", msg.mCipherText)
})
}
} catch (_: Exception) { null }
}
// -------------------------------------------------------------------------
// Persistence (pickle/unpickle for Olm objects; export/import for Megolm)
// -------------------------------------------------------------------------
private fun persistAccount(account: OlmAccount) {
val pickled = account.pickle(PICKLE_KEY, StringBuffer())
prefs.edit().putString(KEY_ACCOUNT, Base64.encodeToString(pickled, Base64.NO_WRAP)).apply()
}
private fun restoreAccount(): OlmAccount? {
val stored = prefs.getString(KEY_ACCOUNT, null) ?: return null
return try {
OlmAccount().also { it.unpickle(PICKLE_KEY, Base64.decode(stored, Base64.NO_WRAP)) }
} catch (_: Exception) { null }
}
private fun persistInboundSession(sessionId: String, session: OlmInboundGroupSession) {
try {
val exported = session.export(session.getFirstKnownIndex())
prefs.edit().putString("inbound_$sessionId", exported).apply()
val ids = prefs.getStringSet(KEY_INBOUND_IDS, emptySet())!!.toMutableSet()
ids.add(sessionId)
prefs.edit().putStringSet(KEY_INBOUND_IDS, ids).apply()
} catch (_: Exception) {}
}
private fun restoreInboundSessions() {
val ids = prefs.getStringSet(KEY_INBOUND_IDS, emptySet()) ?: return
for (id in ids) {
val exported = prefs.getString("inbound_$id", null) ?: continue
try { inboundSessions[id] = OlmInboundGroupSession.importSession(exported) } catch (_: Exception) {}
}
}
private fun persistOutboundSession(roomId: String, session: OlmOutboundGroupSession) {
try {
val pickled = session.pickle(PICKLE_KEY, StringBuffer())
prefs.edit().putString("outbound_$roomId", Base64.encodeToString(pickled, Base64.NO_WRAP)).apply()
} catch (_: Exception) {}
}
companion object {
private const val MAX_ONE_TIME_KEYS = 10
private const val KEY_ACCOUNT = "olm_account"
private const val KEY_INBOUND_IDS = "inbound_ids"
private val PICKLE_KEY = "matrix_olm_pickle".toByteArray()
}
}
// ---------------------------------------------------------------------------
// Canonical JSON — required by Matrix spec for computing ed25519 signatures.
// Keys must be sorted lexicographically; no extra whitespace.
// ---------------------------------------------------------------------------
internal fun JsonElement.toCanonicalJson(): String = when (this) {
is JsonObject -> entries
.sortedBy { it.key }
.joinToString(",", "{", "}") { (k, v) ->
"\"${k.escapeJsonString()}\":${v.toCanonicalJson()}"
}
is JsonArray -> joinToString(",", "[", "]") { it.toCanonicalJson() }
is JsonPrimitive -> toString() // already valid JSON literal
}
private fun String.escapeJsonString(): String =
replace("\\", "\\\\").replace("\"", "\\\"")
.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")

View file

@ -0,0 +1,111 @@
package com.ltadeu6.matrix.matrix
import kotlinx.serialization.json.JsonObject
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
interface MatrixApi {
@GET("/_matrix/client/v3/login")
suspend fun getLoginFlows(): LoginFlowsResponse
@POST("/_matrix/client/v3/login")
suspend fun loginWithToken(@Body request: LoginTokenRequest): LoginResponse
@POST("/_matrix/client/v3/login")
suspend fun loginWithPassword(@Body request: LoginPasswordRequest): LoginResponse
@GET("/_matrix/client/v3/sync")
suspend fun sync(
@Header("Authorization") auth: String,
@Query("since") since: String? = null,
@Query("timeout") timeout: Int = 30_000,
@Query("filter") filter: String? = null
): SyncResponse
@GET("/_matrix/client/v3/rooms/{roomId}/state/m.room.name")
suspend fun getRoomName(
@Header("Authorization") auth: String,
@Path("roomId") roomId: String
): RoomNameEvent
@GET("/_matrix/client/v3/rooms/{roomId}/state/m.room.canonical_alias")
suspend fun getRoomAlias(
@Header("Authorization") auth: String,
@Path("roomId") roomId: String
): RoomAliasEvent
@PUT("/_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}")
suspend fun sendMessage(
@Header("Authorization") auth: String,
@Path("roomId") roomId: String,
@Path("txnId") txnId: String,
@Body message: SendMessageRequest
): SendMessageResponse
@PUT("/_matrix/client/v3/rooms/{roomId}/send/{eventType}/{txnId}")
suspend fun sendEvent(
@Header("Authorization") auth: String,
@Path("roomId") roomId: String,
@Path("eventType") eventType: String,
@Path("txnId") txnId: String,
@Body content: JsonObject
): SendMessageResponse
@POST("/_matrix/client/v3/createRoom")
suspend fun createRoom(
@Header("Authorization") auth: String,
@Body request: CreateRoomRequest
): CreateRoomResponse
@POST("/_matrix/client/v3/rooms/{roomId}/leave")
suspend fun leaveRoom(
@Header("Authorization") auth: String,
@Path("roomId") roomId: String,
@Body body: JsonObject = JsonObject(emptyMap())
): JsonObject
@POST("/_matrix/client/v3/rooms/{roomId}/forget")
suspend fun forgetRoom(
@Header("Authorization") auth: String,
@Path("roomId") roomId: String,
@Body body: JsonObject = JsonObject(emptyMap())
): JsonObject
@POST("/_matrix/client/v3/keys/upload")
suspend fun uploadKeys(
@Header("Authorization") auth: String,
@Body request: JsonObject
): JsonObject
@POST("/_matrix/client/v3/keys/query")
suspend fun queryKeys(
@Header("Authorization") auth: String,
@Body request: JsonObject
): JsonObject
@POST("/_matrix/client/v3/keys/claim")
suspend fun claimKeys(
@Header("Authorization") auth: String,
@Body request: JsonObject
): JsonObject
@GET("/_matrix/client/v3/rooms/{roomId}/joined_members")
suspend fun getJoinedMembers(
@Header("Authorization") auth: String,
@Path("roomId") roomId: String
): JoinedMembersResponse
@PUT("/_matrix/client/v3/sendToDevice/{eventType}/{txnId}")
suspend fun sendToDevice(
@Header("Authorization") auth: String,
@Path("eventType") eventType: String,
@Path("txnId") txnId: String,
@Body messages: JsonObject
): JsonObject
}

View file

@ -0,0 +1,173 @@
package com.ltadeu6.matrix.matrix
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
// --- Well-known discovery ---
@Serializable
data class WellKnownClientConfig(
@SerialName("m.homeserver") val homeserver: HomeserverConfig? = null
)
@Serializable
data class HomeserverConfig(
@SerialName("base_url") val baseUrl: String
)
// --- Auth ---
@Serializable
data class LoginTokenRequest(
val type: String = "m.login.token",
val token: String
)
@Serializable
data class LoginPasswordRequest(
val type: String = "m.login.password",
val identifier: UserIdentifier,
val password: String
)
@Serializable
data class UserIdentifier(
val type: String = "m.id.user",
val user: String
)
@Serializable
data class LoginResponse(
@SerialName("access_token") val accessToken: String,
@SerialName("user_id") val userId: String,
@SerialName("device_id") val deviceId: String,
@SerialName("home_server") val homeServer: String? = null
)
@Serializable
data class LoginFlowsResponse(
val flows: List<LoginFlow> = emptyList()
)
@Serializable
data class LoginFlow(
val type: String
)
// --- Sync ---
@Serializable
data class SyncResponse(
@SerialName("next_batch") val nextBatch: String,
val rooms: RoomsSync? = null,
@SerialName("to_device") val toDevice: ToDeviceSync? = null,
@SerialName("device_lists") val deviceLists: DeviceLists? = null,
@SerialName("device_one_time_keys_count") val deviceOneTimeKeysCount: Map<String, Int> = emptyMap(),
@SerialName("device_unused_fallback_key_types") val deviceUnusedFallbackKeyTypes: List<String>? = null
)
@Serializable
data class ToDeviceSync(
val events: List<JsonObject> = emptyList()
)
@Serializable
data class DeviceLists(
val changed: List<String> = emptyList(),
val left: List<String> = emptyList()
)
@Serializable
data class RoomsSync(
val join: Map<String, JoinedRoomSync> = emptyMap()
)
@Serializable
data class JoinedRoomSync(
val timeline: Timeline? = null,
val state: State? = null
)
@Serializable
data class Timeline(
val events: List<RoomEvent> = emptyList(),
@SerialName("limited") val limited: Boolean = false
)
@Serializable
data class State(
val events: List<RoomEvent> = emptyList()
)
@Serializable
data class RoomEvent(
val type: String,
@SerialName("event_id") val eventId: String = "",
val sender: String = "",
@SerialName("origin_server_ts") val timestamp: Long = 0,
val content: JsonObject? = null
)
@Serializable
data class RoomNameEvent(
val name: String? = null
)
@Serializable
data class RoomAliasEvent(
val alias: String? = null
)
// --- Messaging ---
@Serializable
data class SendMessageRequest(
@SerialName("msgtype") val msgType: String = "m.text",
val body: String
)
@Serializable
data class SendMessageResponse(
@SerialName("event_id") val eventId: String
)
@Serializable
data class CreateRoomRequest(
val invite: List<String> = emptyList(),
@SerialName("is_direct") val isDirect: Boolean = false,
val preset: String? = null
)
@Serializable
data class CreateRoomResponse(
@SerialName("room_id") val roomId: String
)
// --- Room members ---
@Serializable
data class JoinedMembersResponse(
val joined: Map<String, MemberInfo> = emptyMap()
)
@Serializable
data class MemberInfo(
@SerialName("display_name") val displayName: String? = null,
@SerialName("avatar_url") val avatarUrl: String? = null
)
// --- Local UI models ---
data class Room(
val id: String,
val name: String
)
data class Message(
val eventId: String,
val sender: String,
val body: String,
val timestamp: Long,
val isOutgoing: Boolean
)

View file

@ -0,0 +1,704 @@
package com.ltadeu6.matrix.matrix
import android.content.Context
import android.content.SharedPreferences
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
sealed class AuthState {
object LoggedOut : AuthState()
object Loading : AuthState()
data class LoggedIn(val userId: String, val homeserver: String) : AuthState()
data class Error(val message: String) : AuthState()
}
data class SyncState(
val rooms: Map<String, Room> = emptyMap(),
val messages: Map<String, List<Message>> = emptyMap()
)
class MatrixSession private constructor(private val appContext: Context) {
private val prefs: SharedPreferences =
appContext.getSharedPreferences("matrix_session", Context.MODE_PRIVATE)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val txnCounter = AtomicInteger(0)
private val _authState = MutableStateFlow<AuthState>(AuthState.LoggedOut)
val authState: StateFlow<AuthState> = _authState.asStateFlow()
private val _syncState = MutableStateFlow(SyncState())
val syncState: StateFlow<SyncState> = _syncState.asStateFlow()
private val _pendingSsoToken = MutableStateFlow<String?>(null)
val pendingSsoToken: StateFlow<String?> = _pendingSsoToken.asStateFlow()
private var accessToken: String? = null
private var currentUserId: String? = null
private var syncJob: Job? = null
private var api: MatrixApi? = null
private var cryptoService: CryptoService? = null
/**
* SAS device verification service. Created once; userId/deviceId/cryptoService
* are populated on login (or session restore).
*/
val verificationService: VerificationService = VerificationService(scope)
/** Room IDs where `m.room.encryption` has been observed in state. */
private val encryptedRooms = mutableSetOf<String>()
/** Rooms for which we have already shared our outbound Megolm session. */
private val sharedSessionRooms = mutableSetOf<String>()
/** Sessions we have already requested from our other devices (roomId+sessionId). */
private val requestedSessions = mutableSetOf<String>()
/** Sessions we failed to decrypt this sync batch and need to request. */
private data class MissingSession(val roomId: String, val senderKey: String, val sessionId: String)
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
encodeDefaults = true
}
// Compact filter: only joined rooms, last 50 timeline events, no presence/typing noise
private val syncFilter = """
{"room":{"timeline":{"limit":50},"state":{"lazy_load_members":true}},
"presence":{"not_types":["*"]},"account_data":{"not_types":["*"]}}
""".trimIndent().replace("\n", "")
init {
verificationService.onSend = { toUser, toDevice, eventType, txnId, content ->
sendVerificationToDevice(toUser, toDevice, eventType, txnId, content)
}
verificationService.onFetchVerificationKey = { userId, keyId, deviceId ->
fetchVerificationKey(userId, keyId, deviceId)
}
verificationService.onVerified = {
requestedSessions.clear()
sharedSessionRooms.clear()
startSync()
}
val savedToken = prefs.getString("access_token", null)
val savedHomeserver = prefs.getString("homeserver_url", null)
val savedUserId = prefs.getString("user_id", null)
val savedDeviceId = prefs.getString("device_id", null)
if (savedToken != null && savedHomeserver != null && savedUserId != null) {
accessToken = savedToken
currentUserId = savedUserId
buildApi(savedHomeserver)
_authState.value = AuthState.LoggedIn(savedUserId, savedHomeserver)
if (savedDeviceId != null) {
val crypto = CryptoService(appContext, savedUserId, savedDeviceId)
cryptoService = crypto
verificationService.userId = savedUserId
verificationService.deviceId = savedDeviceId
verificationService.cryptoService = crypto
scope.launch { uploadKeys() }
}
startSync()
}
}
fun handleSsoCallback(token: String) { _pendingSsoToken.value = token }
fun consumeSsoToken(): String? {
val t = _pendingSsoToken.value
_pendingSsoToken.value = null
return t
}
suspend fun loginWithToken(homeserver: String, token: String): Result<Unit> = runCatching {
val hs = discoverHomeserver(normalizeHomeserver(homeserver))
buildApi(hs)
val response = api!!.loginWithToken(LoginTokenRequest(token = token))
onLoginSuccess(response, hs)
}
suspend fun loginWithPassword(homeserver: String, username: String, password: String): Result<Unit> = runCatching {
val hs = discoverHomeserver(normalizeHomeserver(homeserver))
buildApi(hs)
val response = api!!.loginWithPassword(
LoginPasswordRequest(identifier = UserIdentifier(user = username), password = password)
)
onLoginSuccess(response, hs)
}
suspend fun checkSsoSupported(homeserver: String): String? {
return try {
val hs = discoverHomeserver(normalizeHomeserver(homeserver))
buildApi(hs)
val flows = api!!.getLoginFlows()
val supportsSso = flows.flows.any { it.type == "m.login.sso" || it.type == "m.login.cas" }
if (supportsSso) null else "this homeserver does not support SSO login — use password login below"
} catch (_: Exception) {
"could not reach homeserver — check the address"
}
}
fun logout() {
syncJob?.cancel()
prefs.edit().clear().apply()
// Also wipe crypto state so keys are regenerated fresh on next login
appContext.getSharedPreferences("olm_crypto", Context.MODE_PRIVATE).edit().clear().apply()
accessToken = null
currentUserId = null
api = null
cryptoService = null
encryptedRooms.clear()
sharedSessionRooms.clear()
requestedSessions.clear()
verificationService.dismiss()
verificationService.userId = ""
verificationService.deviceId = ""
verificationService.cryptoService = null
_authState.value = AuthState.LoggedOut
_syncState.value = SyncState()
}
suspend fun sendMessage(roomId: String, body: String): Result<Unit> {
val token = accessToken ?: return Result.failure(Exception("Not logged in"))
val matrixApi = api ?: return Result.failure(Exception("No API client"))
return runCatching {
val txnId = "${System.currentTimeMillis()}_${txnCounter.incrementAndGet()}"
val crypto = cryptoService
if (crypto != null && roomId in encryptedRooms) {
// Share our outbound session with room members if we haven't yet
if (roomId !in sharedSessionRooms) {
shareGroupSession(roomId)
sharedSessionRooms.add(roomId)
}
val eventPayload = buildJsonObject {
put("room_id", roomId)
put("type", "m.room.message")
put("content", buildJsonObject {
put("msgtype", "m.text")
put("body", body)
})
}.toString()
val encryptedContent = crypto.encryptRoomEvent(roomId, eventPayload)
?: throw Exception("Encryption failed — outbound session not established")
matrixApi.sendEvent("Bearer $token", roomId, "m.room.encrypted", txnId, encryptedContent)
} else {
matrixApi.sendMessage("Bearer $token", roomId, txnId, SendMessageRequest(body = body))
}
}
}
suspend fun forgetRoom(roomId: String): Result<Unit> {
val token = accessToken ?: return Result.failure(Exception("Not logged in"))
val matrixApi = api ?: return Result.failure(Exception("No API client"))
return runCatching {
val auth = "Bearer $token"
runCatching { matrixApi.leaveRoom(auth, roomId) }
matrixApi.forgetRoom(auth, roomId)
val current = _syncState.value
_syncState.value = current.copy(
rooms = current.rooms - roomId,
messages = current.messages - roomId
)
encryptedRooms.remove(roomId)
sharedSessionRooms.remove(roomId)
}
}
suspend fun startDirectChat(handle: String): Result<String> {
val token = accessToken ?: return Result.failure(Exception("Not logged in"))
val matrixApi = api ?: return Result.failure(Exception("No API client"))
val normalized = handle.trim().let { raw ->
when {
raw.isBlank() -> ""
raw.startsWith("@") -> raw
else -> "@$raw"
}
}
if (!normalized.contains(":")) {
return Result.failure(Exception("Handle must look like @user:server"))
}
return runCatching {
val response = matrixApi.createRoom(
auth = "Bearer $token",
request = CreateRoomRequest(
invite = listOf(normalized),
isDirect = true,
preset = "trusted_private_chat"
)
)
response.roomId
}
}
// -------------------------------------------------------------------------
// Login flow
// -------------------------------------------------------------------------
private fun onLoginSuccess(response: LoginResponse, homeserver: String) {
accessToken = response.accessToken
currentUserId = response.userId
prefs.edit()
.putString("access_token", response.accessToken)
.putString("homeserver_url", homeserver)
.putString("user_id", response.userId)
.putString("device_id", response.deviceId)
.apply()
_authState.value = AuthState.LoggedIn(response.userId, homeserver)
val crypto = CryptoService(appContext, response.userId, response.deviceId)
cryptoService = crypto
verificationService.userId = response.userId
verificationService.deviceId = response.deviceId
verificationService.cryptoService = crypto
scope.launch { uploadKeys() }
startSync()
}
// -------------------------------------------------------------------------
// Key upload (called once after login)
// -------------------------------------------------------------------------
private suspend fun uploadKeys() {
val crypto = cryptoService ?: return
val token = accessToken ?: return
val matrixApi = api ?: return
runCatching {
val payload = crypto.buildKeysUploadPayload()
matrixApi.uploadKeys("Bearer $token", payload)
crypto.onKeysUploaded()
}
}
/**
* Sends `m.room_key_request` to-device events to all of our own devices (`*`) for every
* session we failed to decrypt. Deduplicates by `roomId+sessionId` so we only ask once.
*/
private suspend fun sendKeyRequests(missing: List<MissingSession>, auth: String, matrixApi: MatrixApi) {
val uid = currentUserId ?: return
val devId = prefs.getString("device_id", "") ?: return
val toRequest = missing.filter { "${it.roomId}:${it.sessionId}" !in requestedSessions }
if (toRequest.isEmpty()) return
// Build one sendToDevice call with all requests
val messages = buildJsonObject {
put(uid, buildJsonObject {
put("*", buildJsonObject {
// Matrix allows only one content object per device; batch as separate calls
})
})
}
for (ms in toRequest) {
val key = "${ms.roomId}:${ms.sessionId}"
if (key in requestedSessions) continue
requestedSessions.add(key)
val requestId = "${System.currentTimeMillis()}_${ms.sessionId.take(8)}"
val body = buildJsonObject {
put("messages", buildJsonObject {
put(uid, buildJsonObject {
put("*", buildJsonObject {
put("action", "request")
put("body", buildJsonObject {
put("algorithm", "m.megolm.v1.aes-sha2")
put("room_id", ms.roomId)
put("sender_key", ms.senderKey)
put("session_id", ms.sessionId)
})
put("request_id", requestId)
put("requesting_device_id", devId)
})
})
})
}
runCatching { matrixApi.sendToDevice(auth, "m.room_key_request", requestId, body) }
}
}
// -------------------------------------------------------------------------
// Outbound Megolm session sharing
// -------------------------------------------------------------------------
/**
* Shares the outbound Megolm session for [roomId] with every joined member device.
* Steps: query joined members query device keys claim OTKs send encrypted m.room_key.
*/
private suspend fun shareGroupSession(roomId: String) {
val crypto = cryptoService ?: return
val token = accessToken ?: return
val matrixApi = api ?: return
val auth = "Bearer $token"
val (sessionId, sessionKey) = crypto.initOutboundSession(roomId) ?: return
// 1. Joined members
val members = runCatching { matrixApi.getJoinedMembers(auth, roomId) }
.getOrNull()?.joined?.keys ?: return
// 2. Device keys for all members
val keysQueryBody = buildJsonObject {
put("device_keys", buildJsonObject {
members.forEach { uid ->
put(uid, kotlinx.serialization.json.JsonArray(emptyList()))
}
})
}
val keysResp = runCatching { matrixApi.queryKeys(auth, keysQueryBody) }
.getOrNull() ?: return
val deviceKeys = keysResp["device_keys"]?.jsonObject ?: return
// Collect devices we need to send to, and build the claim request
data class DevInfo(val userId: String, val devId: String, val curve25519: String, val ed25519: String)
val devices = mutableListOf<DevInfo>()
val claimMap = mutableMapOf<String, MutableMap<String, String>>()
deviceKeys.forEach { (uid, devsJson) ->
devsJson.jsonObject.forEach { (devId, devInfoJson) ->
val keys = devInfoJson.jsonObject["keys"]?.jsonObject ?: return@forEach
val curve = (keys["curve25519:$devId"] as? JsonPrimitive)?.content ?: return@forEach
val ed = (keys["ed25519:$devId"] as? JsonPrimitive)?.content ?: return@forEach
if (curve == crypto.curve25519Key) return@forEach // skip own device
devices.add(DevInfo(uid, devId, curve, ed))
claimMap.getOrPut(uid) { mutableMapOf() }[devId] = "signed_curve25519"
}
}
if (devices.isEmpty()) return
// 3. Claim one-time keys
val claimBody = buildJsonObject {
put("one_time_keys", buildJsonObject {
claimMap.forEach { (uid, devs) ->
put(uid, buildJsonObject { devs.forEach { (d, alg) -> put(d, alg) } })
}
})
}
val claimResp = runCatching { matrixApi.claimKeys(auth, claimBody) }
.getOrNull() ?: return
val otks = claimResp["one_time_keys"]?.jsonObject ?: return
// 4. Build per-device encrypted m.room_key and send via sendToDevice
val toDeviceMessages = mutableMapOf<String, MutableMap<String, JsonObject>>()
for (dev in devices) {
val otkKey = otks[dev.userId]?.jsonObject
?.get(dev.devId)?.jsonObject
?.values?.firstOrNull()?.jsonObject
?.let { (it["key"] as? JsonPrimitive)?.content } ?: continue
val payload = buildJsonObject {
put("type", "m.room_key")
put("content", buildJsonObject {
put("algorithm", "m.megolm.v1.aes-sha2")
put("room_id", roomId)
put("session_id", sessionId)
put("session_key", sessionKey)
})
put("sender", currentUserId ?: "")
put("sender_device", prefs.getString("device_id", "") ?: "")
put("keys", buildJsonObject { put("ed25519", crypto.ed25519Key) })
put("recipient", dev.userId)
put("recipient_keys", buildJsonObject { put("ed25519", dev.ed25519) })
}
val ciphertext = crypto.encryptOlmPayload(payload.toString(), dev.curve25519, otkKey)
?: continue
val encContent = buildJsonObject {
put("algorithm", "m.olm.v1.curve25519-aes-sha2")
put("sender_key", crypto.curve25519Key)
put("ciphertext", ciphertext)
}
toDeviceMessages.getOrPut(dev.userId) { mutableMapOf() }[dev.devId] = encContent
}
if (toDeviceMessages.isEmpty()) return
val toDeviceBody = buildJsonObject {
put("messages", buildJsonObject {
toDeviceMessages.forEach { (uid, devs) ->
put(uid, buildJsonObject { devs.forEach { (d, c) -> put(d, c) } })
}
})
}
val txnId = "${System.currentTimeMillis()}_roomkey"
runCatching { matrixApi.sendToDevice(auth, "m.room.encrypted", txnId, toDeviceBody) }
}
// -------------------------------------------------------------------------
// Sync loop
// -------------------------------------------------------------------------
private fun startSync() {
syncJob?.cancel()
syncJob = scope.launch {
var since: String? = null
var isFirstSync = true
while (isActive) {
val token = accessToken ?: break
val matrixApi = api ?: break
try {
val response = matrixApi.sync(
auth = "Bearer $token",
since = since,
timeout = if (since == null) 0 else 30_000,
filter = syncFilter
)
since = response.nextBatch
// Feed to-device events so incoming room keys are available before decryption
response.toDevice?.events?.let { events ->
cryptoService?.processToDeviceEvents(events)
events.forEach { event ->
val type = (event["type"] as? JsonPrimitive)?.content ?: return@forEach
if (type.startsWith("m.key.verification.")) {
verificationService.processToDeviceEvent(event)
}
}
}
val missing = processSyncResponse(response)
// Request keys from our other devices for any event we couldn't decrypt
if (missing.isNotEmpty()) {
sendKeyRequests(missing, "Bearer $token", matrixApi)
}
if (isFirstSync) {
isFirstSync = false
fetchMissingRoomNames(token, matrixApi)
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
delay(5_000)
}
}
}
}
private suspend fun fetchMissingRoomNames(token: String, matrixApi: MatrixApi) {
val current = _syncState.value
val updates = mutableMapOf<String, Room>()
current.rooms.forEach { (roomId, room) ->
if (room.name == roomId) {
val name = runCatching { matrixApi.getRoomName("Bearer $token", roomId).name }
.getOrNull()?.takeIf { it.isNotBlank() }
?: runCatching { matrixApi.getRoomAlias("Bearer $token", roomId).alias }
.getOrNull()?.takeIf { it.isNotBlank() }
if (name != null) updates[roomId] = room.copy(name = name)
}
}
if (updates.isNotEmpty()) {
_syncState.value = _syncState.value.copy(rooms = _syncState.value.rooms + updates)
}
}
private fun processSyncResponse(response: SyncResponse): List<MissingSession> {
val current = _syncState.value
val rooms = current.rooms.toMutableMap()
val messages = current.messages.toMutableMap()
val missingSessions = mutableListOf<MissingSession>()
response.rooms?.join?.forEach { (roomId, roomData) ->
val allStateEvents = (roomData.state?.events ?: emptyList()) +
(roomData.timeline?.events?.filter {
it.type == "m.room.name" || it.type == "m.room.canonical_alias"
} ?: emptyList())
val nameEvent = allStateEvents.lastOrNull { it.type == "m.room.name" }
val aliasEvent = allStateEvents.lastOrNull { it.type == "m.room.canonical_alias" }
val resolvedName = (nameEvent?.content?.get("name") as? JsonPrimitive)?.content
?: (aliasEvent?.content?.get("alias") as? JsonPrimitive)?.content
?: rooms[roomId]?.name
?: roomId
rooms[roomId] = Room(id = roomId, name = resolvedName)
// Track encryption state
val roomIsEncrypted = (roomData.state?.events ?: emptyList())
.any { it.type == "m.room.encryption" }
if (roomIsEncrypted) encryptedRooms.add(roomId)
val newMsgs = roomData.timeline?.events
?.filter { it.type == "m.room.message" || it.type == "m.room.encrypted" }
?.mapNotNull { event ->
val body = when (event.type) {
"m.room.message" ->
(event.content?.get("body") as? JsonPrimitive)?.content
?: return@mapNotNull null
"m.room.encrypted" -> tryDecrypt(event, roomId, missingSessions)
else -> return@mapNotNull null
}
Message(
eventId = event.eventId,
sender = event.sender,
body = body,
timestamp = event.timestamp,
isOutgoing = event.sender == currentUserId
)
} ?: emptyList()
if (newMsgs.isNotEmpty()) {
val existing = messages[roomId] ?: emptyList()
val existingIds = existing.map { it.eventId }.toSet()
val merged = existing + newMsgs.filter { it.eventId !in existingIds }
messages[roomId] = merged.sortedBy { it.timestamp }
}
}
_syncState.value = SyncState(rooms = rooms, messages = messages)
return missingSessions
}
private fun tryDecrypt(event: RoomEvent, roomId: String, missingSessions: MutableList<MissingSession>): String {
val content = event.content ?: return "[encrypted message]"
val result = cryptoService?.decryptRoomEvent(content)
if (result == null) {
val sessionId = (content["session_id"] as? JsonPrimitive)?.content
val senderKey = (content["sender_key"] as? JsonPrimitive)?.content
if (sessionId != null && senderKey != null) {
missingSessions.add(MissingSession(roomId, senderKey, sessionId))
}
}
return result ?: "[encrypted message]"
}
// -------------------------------------------------------------------------
// Verification to-device send helper
// -------------------------------------------------------------------------
private suspend fun sendVerificationToDevice(
toUser: String,
toDevice: String,
eventType: String,
txnId: String,
content: JsonObject
) {
val token = accessToken ?: return
val matrixApi = api ?: return
val body = buildJsonObject {
put("messages", buildJsonObject {
put(toUser, buildJsonObject { put(toDevice, content) })
})
}
runCatching { matrixApi.sendToDevice("Bearer $token", eventType, txnId, body) }
}
private suspend fun fetchVerificationKey(userId: String, keyId: String, deviceId: String?): String? {
val token = accessToken ?: return null
val matrixApi = api ?: return null
val request = buildJsonObject {
put("device_keys", buildJsonObject {
if (deviceId != null) {
put(userId, JsonArray(listOf(JsonPrimitive(deviceId))))
} else {
put(userId, JsonArray(emptyList()))
}
})
}
val response = runCatching { matrixApi.queryKeys("Bearer $token", request) }.getOrNull() ?: return null
val deviceKey = deviceId?.let { devId ->
response["device_keys"]?.jsonObject
?.get(userId)?.jsonObject
?.get(devId)?.jsonObject
?.get("keys")?.jsonObject
?.get(keyId)
}
if (deviceKey is JsonPrimitive) return deviceKey.content
val masterKey = response["master_keys"]?.jsonObject
?.get(userId)?.jsonObject
?.get("keys")?.jsonObject
?.get(keyId)
if (masterKey is JsonPrimitive) return masterKey.content
val selfSigningKey = response["self_signing_keys"]?.jsonObject
?.get(userId)?.jsonObject
?.get("keys")?.jsonObject
?.get(keyId)
if (selfSigningKey is JsonPrimitive) return selfSigningKey.content
val userSigningKey = response["user_signing_keys"]?.jsonObject
?.get(userId)?.jsonObject
?.get("keys")?.jsonObject
?.get(keyId)
if (userSigningKey is JsonPrimitive) return userSigningKey.content
return null
}
// -------------------------------------------------------------------------
// Homeserver discovery / API setup
// -------------------------------------------------------------------------
private suspend fun discoverHomeserver(baseUrl: String): String {
return try {
val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
val url = "${baseUrl.trimEnd('/')}/.well-known/matrix/client"
val response = client.newCall(Request.Builder().url(url).build()).execute()
if (!response.isSuccessful) return baseUrl
val body = response.body?.string() ?: return baseUrl
val parsed = json.decodeFromString<WellKnownClientConfig>(body)
parsed.homeserver?.baseUrl?.trimEnd('/')?.plus("/") ?: baseUrl
} catch (_: Exception) { baseUrl }
}
private fun buildApi(homeserver: String) {
val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }
val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.addInterceptor(logging)
.build()
api = Retrofit.Builder()
.baseUrl(homeserver)
.client(client)
.addConverterFactory(json.asConverterFactory("application/json; charset=UTF8".toMediaType()))
.build()
.create(MatrixApi::class.java)
}
private fun normalizeHomeserver(url: String): String {
var hs = url.trim()
if (!hs.startsWith("http")) hs = "https://$hs"
if (!hs.endsWith("/")) hs += "/"
return hs
}
companion object {
@Volatile private var instance: MatrixSession? = null
fun getInstance(context: Context): MatrixSession =
instance ?: synchronized(this) {
instance ?: MatrixSession(context.applicationContext).also { instance = it }
}
}
}

View file

@ -0,0 +1,465 @@
package com.ltadeu6.matrix.matrix
import android.util.Base64
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import org.matrix.olm.OlmSAS
import java.security.MessageDigest
data class SasEmoji(val emoji: String, val name: String)
sealed class VerificationState {
object Idle : VerificationState()
data class Requested(val fromUser: String, val fromDevice: String, val txnId: String) : VerificationState()
data class ShowingEmojis(val txnId: String, val emojis: List<SasEmoji>) : VerificationState()
object Done : VerificationState()
data class Cancelled(val reason: String) : VerificationState()
}
/**
* Implements the m.sas.v1 interactive device verification protocol.
*
* Designed for the **accepting** side (the Android device accepts a request initiated by
* another client such as Element Web).
*
* Call [processToDeviceEvent] for every incoming `m.key.verification.*` to-device event.
* Set [onSend] before the first event arrives so responses can be delivered.
*
* [userId] and [deviceId] are set by [MatrixSession] after login; [cryptoService] is set
* similarly so one instance can be created early and configured lazily.
*/
class VerificationService(private val scope: CoroutineScope) {
var userId: String = ""
var deviceId: String = ""
var cryptoService: CryptoService? = null
private val _state = MutableStateFlow<VerificationState>(VerificationState.Idle)
val state: StateFlow<VerificationState> = _state.asStateFlow()
/**
* Callback invoked to send a to-device event.
* (toUser, toDevice, eventType, txnId, content)
*/
var onSend: suspend (String, String, String, String, JsonObject) -> Unit = { _, _, _, _, _ -> }
var onFetchVerificationKey: suspend (String, String, String?) -> String? = { _, _, _ -> null }
var onVerified: () -> Unit = {}
private var olmSas: OlmSAS? = null
private var currentTxnId: String? = null
private var theirUserId: String? = null
private var theirDeviceId: String? = null
private var ourPublicKey: String? = null
private var pendingTheirMac: JsonObject? = null // mac received before we showed emojis
private var selectedMacMethod: String? = null
private var hasConfirmedLocally: Boolean = false
// -------------------------------------------------------------------------
// Incoming event dispatch
// -------------------------------------------------------------------------
fun processToDeviceEvent(event: JsonObject) {
val type = (event["type"] as? JsonPrimitive)?.content ?: return
val content = event["content"]?.jsonObject ?: return
val sender = (event["sender"] as? JsonPrimitive)?.content ?: return
val txnId = (content["transaction_id"] as? JsonPrimitive)?.content ?: return
when (type) {
"m.key.verification.request" -> handleRequest(sender, txnId, content)
"m.key.verification.start" -> handleStart(sender, txnId, content)
"m.key.verification.key" -> handleKey(txnId, content)
"m.key.verification.mac" -> handleMac(txnId, content)
"m.key.verification.done" -> handleDone(txnId)
"m.key.verification.cancel" -> handleCancel(txnId, content)
}
}
// -------------------------------------------------------------------------
// Request — show banner
// -------------------------------------------------------------------------
private fun handleRequest(sender: String, txnId: String, content: JsonObject) {
if (_state.value !is VerificationState.Idle) return
val fromDevice = (content["from_device"] as? JsonPrimitive)?.content ?: return
theirUserId = sender
theirDeviceId = fromDevice
currentTxnId = txnId
_state.value = VerificationState.Requested(sender, fromDevice, txnId)
}
// -------------------------------------------------------------------------
// Accept — user tapped "Accept" on the banner
// -------------------------------------------------------------------------
fun accept(txnId: String) {
if ((_state.value as? VerificationState.Requested)?.txnId != txnId) return
val toUser = theirUserId ?: return
val toDevice = theirDeviceId ?: return
val content = buildJsonObject {
put("from_device", deviceId)
put("transaction_id", txnId)
put("methods", buildJsonArray { add(JsonPrimitive("m.sas.v1")) })
}
scope.launch {
onSend(toUser, toDevice, "m.key.verification.ready", "${txnId}_ready", content)
}
// Stay in Requested state; wait for m.key.verification.start
}
// -------------------------------------------------------------------------
// Start — create OlmSAS, send accept + key
// -------------------------------------------------------------------------
private fun handleStart(sender: String, txnId: String, content: JsonObject) {
if (txnId != currentTxnId) return
val method = (content["method"] as? JsonPrimitive)?.content
if (method != "m.sas.v1") {
sendCancel(txnId, "m.unknown_method", "Unsupported method")
return
}
val macMethods = content["message_authentication_codes"]
?.jsonArray
?.mapNotNull { (it as? JsonPrimitive)?.content }
?: emptyList()
selectedMacMethod = when {
"hkdf-hmac-sha256.v2" in macMethods -> "hkdf-hmac-sha256.v2"
"hkdf-hmac-sha256" in macMethods -> "hkdf-hmac-sha256"
else -> {
sendCancel(txnId, "m.unknown_method", "Unsupported MAC method")
return
}
}
val fromDevice = (content["from_device"] as? JsonPrimitive)?.content ?: return
theirDeviceId = fromDevice
theirUserId = sender
val sas = OlmSAS()
olmSas = sas
ourPublicKey = sas.publicKey
// commitment = unpadded-base64(SHA-256(our_pubkey_base64 + canonical_json(start_content)))
val sha256 = MessageDigest.getInstance("SHA-256")
val commitInput = (ourPublicKey ?: return) + content.toCanonicalJson()
val commitment = Base64.encodeToString(
sha256.digest(commitInput.toByteArray(Charsets.UTF_8)),
Base64.NO_WRAP or Base64.NO_PADDING
)
val acceptContent = buildJsonObject {
put("transaction_id", txnId)
put("method", "m.sas.v1")
put("key_agreement_protocol", "curve25519-hkdf-sha256")
put("hash", "sha256")
put("message_authentication_code", selectedMacMethod ?: "hkdf-hmac-sha256.v2")
put("short_authentication_string", buildJsonArray {
add(JsonPrimitive("decimal"))
add(JsonPrimitive("emoji"))
})
put("commitment", commitment)
}
val keyContent = buildJsonObject {
put("transaction_id", txnId)
put("key", ourPublicKey!!)
}
val toUser = theirUserId ?: return
val toDevice = theirDeviceId ?: return
scope.launch {
onSend(toUser, toDevice, "m.key.verification.accept", "${txnId}_accept", acceptContent)
onSend(toUser, toDevice, "m.key.verification.key", "${txnId}_key", keyContent)
}
}
// -------------------------------------------------------------------------
// Key exchange — compute SAS and show emojis
// -------------------------------------------------------------------------
private fun handleKey(txnId: String, content: JsonObject) {
if (txnId != currentTxnId) return
val sas = olmSas ?: return
val theirKey = (content["key"] as? JsonPrimitive)?.content ?: return
sas.setTheirPublicKey(theirKey)
// Initiator = them (sent the start), Accepting = us
val info = "MATRIX_KEY_VERIFICATION_SAS" +
"|${theirUserId}|${theirDeviceId}|${theirKey}" +
"|${userId}|${deviceId}|${ourPublicKey}" +
"|${txnId}"
val sasBytes = sas.generateShortCode(info, 6)
val emojis = sasBytes.toSasEmojis()
hasConfirmedLocally = false
_state.value = VerificationState.ShowingEmojis(txnId, emojis)
}
// -------------------------------------------------------------------------
// Confirm — user tapped "Confirm emojis match"
// -------------------------------------------------------------------------
fun confirm() {
val state = _state.value as? VerificationState.ShowingEmojis ?: return
val sas = olmSas ?: return
val txnId = state.txnId
val toUser = theirUserId ?: return
val toDevice = theirDeviceId ?: return
val crypto = cryptoService ?: return
val keyId = "ed25519:$deviceId"
hasConfirmedLocally = true
val macBase = "MATRIX_KEY_VERIFICATION_MAC$userId$deviceId$toUser$toDevice$txnId"
val keyMac = sas.calculateMacByMethod(
input = crypto.ed25519Key,
info = "$macBase$keyId",
method = selectedMacMethod
) ?: run {
sendCancel(txnId, "m.key_mismatch", "MAC calculation failed")
return
}
val keyIdsMac = sas.calculateMacByMethod(
input = keyId,
info = "${macBase}KEY_IDS",
method = selectedMacMethod
) ?: run {
sendCancel(txnId, "m.key_mismatch", "MAC calculation failed")
return
}
val macContent = buildJsonObject {
put("transaction_id", txnId)
put("mac", buildJsonObject { put(keyId, keyMac) })
put("keys", keyIdsMac)
}
scope.launch {
onSend(toUser, toDevice, "m.key.verification.mac", "${txnId}_mac", macContent)
}
pendingTheirMac?.let { mac ->
pendingTheirMac = null
verifyTheirMac(txnId, mac)
}
}
// -------------------------------------------------------------------------
// Their MAC — verify and send done
// -------------------------------------------------------------------------
private fun handleMac(txnId: String, content: JsonObject) {
if (txnId != currentTxnId) return
if (_state.value !is VerificationState.ShowingEmojis || !hasConfirmedLocally) {
pendingTheirMac = content
return
}
verifyTheirMac(txnId, content)
}
private fun verifyTheirMac(txnId: String, content: JsonObject) {
scope.launch {
val sas = olmSas ?: return@launch
val toUser = theirUserId ?: return@launch
val toDevice = theirDeviceId ?: return@launch
val mac = content["mac"]?.jsonObject ?: return@launch
val keysMac = (content["keys"] as? JsonPrimitive)?.content ?: return@launch
val macBase = "MATRIX_KEY_VERIFICATION_MAC$toUser$toDevice$userId$deviceId$txnId"
val sortedKeyIds = mac.keys.sorted().joinToString(",")
val expectedKeyIdsMac = sas.calculateMacByMethod(
input = sortedKeyIds,
info = "${macBase}KEY_IDS",
method = selectedMacMethod
) ?: run {
sendCancel(txnId, "m.key_mismatch", "MAC verification failed")
return@launch
}
if (keysMac != expectedKeyIdsMac) {
sendCancel(txnId, "m.key_mismatch", "Key IDs MAC mismatch")
return@launch
}
for ((keyId, keyMac) in mac) {
val expectedKey = onFetchVerificationKey(toUser, keyId, toDevice) ?: run {
sendCancel(txnId, "m.key_mismatch", "Unknown key id: $keyId")
return@launch
}
val expectedMac = sas.calculateMacByMethod(
input = expectedKey,
info = "$macBase$keyId",
method = selectedMacMethod
) ?: run {
sendCancel(txnId, "m.key_mismatch", "MAC verification failed")
return@launch
}
if ((keyMac as? JsonPrimitive)?.content != expectedMac) {
sendCancel(txnId, "m.key_mismatch", "Device key MAC mismatch")
return@launch
}
}
val doneContent = buildJsonObject { put("transaction_id", txnId) }
onSend(toUser, toDevice, "m.key.verification.done", "${txnId}_done", doneContent)
cleanup()
_state.value = VerificationState.Done
onVerified()
}
}
// -------------------------------------------------------------------------
// Done / Cancel
// -------------------------------------------------------------------------
private fun handleDone(txnId: String) {
if (txnId != currentTxnId) return
cleanup()
_state.value = VerificationState.Done
onVerified()
}
private fun handleCancel(txnId: String, content: JsonObject) {
if (txnId != currentTxnId) return
val reason = (content["reason"] as? JsonPrimitive)?.content ?: "Cancelled by other device"
cleanup()
_state.value = VerificationState.Cancelled(reason)
}
fun cancel() {
val txnId = currentTxnId ?: run {
_state.value = VerificationState.Idle
return
}
sendCancel(txnId, "m.user", "Cancelled by user")
}
private fun sendCancel(txnId: String, code: String, reason: String) {
val toUser = theirUserId ?: return
val toDevice = theirDeviceId ?: return
val content = buildJsonObject {
put("transaction_id", txnId)
put("code", code)
put("reason", reason)
}
scope.launch {
onSend(toUser, toDevice, "m.key.verification.cancel", "${txnId}_cancel", content)
}
cleanup()
_state.value = VerificationState.Cancelled(reason)
}
fun dismiss() {
cleanup()
_state.value = VerificationState.Idle
}
private fun cleanup() {
olmSas?.releaseSas()
olmSas = null
currentTxnId = null
theirUserId = null
theirDeviceId = null
ourPublicKey = null
pendingTheirMac = null
selectedMacMethod = null
hasConfirmedLocally = false
}
}
private fun OlmSAS.calculateMacByMethod(input: String, info: String, method: String?): String? =
runCatching {
when (method) {
"hkdf-hmac-sha256.v2" -> calculateMacFixedBase64(input, info)
"hkdf-hmac-sha256" -> calculateMac(input, info)
"hmac-sha256" -> calculateMacLongKdf(input, info)
else -> null
}
}.getOrNull()
// ---------------------------------------------------------------------------
// SAS emoji derivation (Matrix spec: 42 bits = 7 × 6-bit indices)
// ---------------------------------------------------------------------------
private fun ByteArray.toSasEmojis(): List<SasEmoji> {
val b = map { it.toInt() and 0xFF }
val indices = listOf(
b[0] shr 2,
((b[0] and 0x3) shl 4) or (b[1] shr 4),
((b[1] and 0xF) shl 2) or (b[2] shr 6),
b[2] and 0x3F,
b[3] shr 2,
((b[3] and 0x3) shl 4) or (b[4] shr 4),
((b[4] and 0xF) shl 2) or (b[5] shr 6)
)
return indices.map { SAS_EMOJIS[it] }
}
// Matrix SAS emoji list (indices 063), from the Matrix spec.
private val SAS_EMOJIS = listOf(
SasEmoji("\uD83D\uDC36", "Dog"),
SasEmoji("\uD83D\uDC31", "Cat"),
SasEmoji("\uD83E\uDD81", "Lion"),
SasEmoji("\uD83D\uDC0E", "Horse"),
SasEmoji("\uD83E\uDD84", "Unicorn"),
SasEmoji("\uD83D\uDC37", "Pig"),
SasEmoji("\uD83D\uDC18", "Elephant"),
SasEmoji("\uD83D\uDC30", "Rabbit"),
SasEmoji("\uD83D\uDC3C", "Panda"),
SasEmoji("\uD83D\uDC13", "Rooster"),
SasEmoji("\uD83D\uDC27", "Penguin"),
SasEmoji("\uD83D\uDC22", "Turtle"),
SasEmoji("\uD83D\uDC1F", "Fish"),
SasEmoji("\uD83D\uDC19", "Octopus"),
SasEmoji("\uD83E\uDD8B", "Butterfly"),
SasEmoji("\uD83C\uDF38", "Flower"),
SasEmoji("\uD83C\uDF33", "Tree"),
SasEmoji("\uD83C\uDF35", "Cactus"),
SasEmoji("\uD83C\uDF44", "Mushroom"),
SasEmoji("\uD83C\uDF0F", "Globe"),
SasEmoji("\uD83C\uDF19", "Moon"),
SasEmoji("\u2601\uFE0F", "Cloud"),
SasEmoji("\uD83D\uDD25", "Fire"),
SasEmoji("\uD83C\uDF4C", "Banana"),
SasEmoji("\uD83C\uDF4E", "Apple"),
SasEmoji("\uD83C\uDF53", "Strawberry"),
SasEmoji("\uD83C\uDF3D", "Corn"),
SasEmoji("\uD83C\uDF55", "Pizza"),
SasEmoji("\uD83C\uDF82", "Cake"),
SasEmoji("\u2764\uFE0F", "Heart"),
SasEmoji("\uD83D\uDE00", "Smiley"),
SasEmoji("\uD83E\uDD16", "Robot"),
SasEmoji("\uD83C\uDFA9", "Hat"),
SasEmoji("\uD83D\uDC53", "Glasses"),
SasEmoji("\uD83D\uDD27", "Spanner"),
SasEmoji("\uD83C\uDF85", "Santa"),
SasEmoji("\uD83D\uDC4D", "Thumbs Up"),
SasEmoji("\u2602\uFE0F", "Umbrella"),
SasEmoji("\u231B", "Hourglass"),
SasEmoji("\u23F0", "Clock"),
SasEmoji("\uD83C\uDF81", "Gift"),
SasEmoji("\uD83D\uDCA1", "Light Bulb"),
SasEmoji("\uD83D\uDCDA", "Book"),
SasEmoji("\u270F\uFE0F", "Pencil"),
SasEmoji("\uD83D\uDCCE", "Paperclip"),
SasEmoji("\u2702\uFE0F", "Scissors"),
SasEmoji("\uD83D\uDD12", "Lock"),
SasEmoji("\uD83D\uDD11", "Key"),
SasEmoji("\uD83D\uDD28", "Hammer"),
SasEmoji("\u260E\uFE0F", "Telephone"),
SasEmoji("\uD83C\uDFC1", "Flag"),
SasEmoji("\uD83D\uDE82", "Train"),
SasEmoji("\uD83D\uDEB2", "Bicycle"),
SasEmoji("\u2708\uFE0F", "Aeroplane"),
SasEmoji("\uD83D\uDE80", "Rocket"),
SasEmoji("\uD83C\uDFC6", "Trophy"),
SasEmoji("\u26BD", "Ball"),
SasEmoji("\uD83C\uDFB8", "Guitar"),
SasEmoji("\uD83C\uDFBA", "Trumpet"),
SasEmoji("\uD83D\uDD14", "Bell"),
SasEmoji("\u2693", "Anchor"),
SasEmoji("\uD83C\uDFA7", "Headphones"),
SasEmoji("\uD83D\uDCC1", "Folder"),
SasEmoji("\uD83D\uDCCC", "Pin")
)

View file

@ -0,0 +1,88 @@
package com.ltadeu6.matrix.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.ltadeu6.matrix.auth.LoginScreen
import com.ltadeu6.matrix.chat.ChatScreen
import com.ltadeu6.matrix.matrix.AuthState
import com.ltadeu6.matrix.matrix.MatrixSession
import com.ltadeu6.matrix.rooms.RoomListScreen
import com.ltadeu6.matrix.verification.VerificationScreen
import java.net.URLDecoder
import java.net.URLEncoder
private object Route {
const val LOGIN = "login"
const val ROOMS = "rooms"
const val CHAT = "chat/{roomId}"
const val VERIFICATION = "verification"
fun chat(roomId: String) = "chat/${URLEncoder.encode(roomId, "UTF-8")}"
}
@Composable
fun AppNavigation(initialLoginToken: String? = null) {
val context = LocalContext.current
val session = MatrixSession.getInstance(context)
val authState by session.authState.collectAsState()
val startDest = when (authState) {
is AuthState.LoggedIn -> Route.ROOMS
else -> Route.LOGIN
}
val navController = rememberNavController()
NavHost(navController = navController, startDestination = startDest) {
composable(Route.LOGIN) {
LoginScreen(
initialToken = initialLoginToken,
onLoggedIn = {
navController.navigate(Route.ROOMS) {
popUpTo(Route.LOGIN) { inclusive = true }
}
}
)
}
composable(Route.ROOMS) {
RoomListScreen(
onRoomSelected = { roomId ->
navController.navigate(Route.chat(roomId))
},
onLogout = {
session.logout()
navController.navigate(Route.LOGIN) {
popUpTo(0) { inclusive = true }
}
},
onOpenVerification = {
navController.navigate(Route.VERIFICATION)
}
)
}
composable(Route.VERIFICATION) {
VerificationScreen(onDone = { navController.popBackStack() })
}
composable(
route = Route.CHAT,
arguments = listOf(navArgument("roomId") { type = NavType.StringType })
) { backStack ->
val encoded = backStack.arguments?.getString("roomId") ?: return@composable
val roomId = URLDecoder.decode(encoded, "UTF-8")
ChatScreen(
roomId = roomId,
onBack = { navController.popBackStack() }
)
}
}
}

View file

@ -0,0 +1,315 @@
package com.ltadeu6.matrix.rooms
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.ltadeu6.matrix.matrix.AuthState
import com.ltadeu6.matrix.matrix.MatrixSession
import com.ltadeu6.matrix.matrix.Room
import com.ltadeu6.matrix.matrix.VerificationState
import com.ltadeu6.matrix.ui.theme.MatrixBackground
import com.ltadeu6.matrix.ui.theme.MatrixBorder
import com.ltadeu6.matrix.ui.theme.MatrixGreen
import com.ltadeu6.matrix.ui.theme.MatrixGreenDim
import com.ltadeu6.matrix.ui.theme.TerminalFont
import kotlinx.coroutines.launch
@Composable
fun RoomListScreen(
onRoomSelected: (String) -> Unit,
onLogout: () -> Unit,
onOpenVerification: () -> Unit
) {
val context = LocalContext.current
val session = MatrixSession.getInstance(context)
val syncState by session.syncState.collectAsState()
val authState by session.authState.collectAsState()
val verificationState by session.verificationService.state.collectAsState()
val scope = rememberCoroutineScope()
val userId = (authState as? AuthState.LoggedIn)?.userId ?: ""
var roomToForget by remember { mutableStateOf<Room?>(null) }
var showNewChatDialog by remember { mutableStateOf(false) }
var handleInput by remember { mutableStateOf("") }
var actionError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(syncState.rooms.keys) {
roomToForget?.let { room ->
if (room.id !in syncState.rooms) roomToForget = null
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "> ROOMS",
style = TextStyle(fontFamily = TerminalFont, fontSize = 18.sp, color = MatrixGreen)
)
TextButton(onClick = onLogout) {
Text(
text = "[logout]",
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixGreenDim)
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (userId.isNotEmpty()) {
Text(
text = " $userId",
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder)
)
} else {
Spacer(Modifier)
}
TextButton(onClick = { showNewChatDialog = true }) {
Text(
text = "[new chat]",
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixGreenDim)
)
}
}
if (actionError != null) {
Text(
text = " ${actionError}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixGreenDim)
)
}
Spacer(Modifier.height(4.dp))
Text(
text = "".repeat(40),
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixBorder)
)
// Verification banner
when (val vs = verificationState) {
is VerificationState.Requested -> {
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = " [!] verification request",
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixGreen)
)
Text(
text = " from ${vs.fromUser}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 10.sp, color = MatrixGreenDim)
)
}
Button(
onClick = onOpenVerification,
colors = ButtonDefaults.buttonColors(
containerColor = MatrixGreen,
contentColor = MatrixBackground
)
) {
Text("[verify]", fontFamily = TerminalFont, fontSize = 12.sp)
}
}
}
is VerificationState.ShowingEmojis -> {
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenVerification() },
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = " [!] verification in progress — tap to continue",
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixGreen)
)
}
}
else -> {}
}
Spacer(Modifier.height(8.dp))
if (syncState.rooms.isEmpty()) {
Text(
text = " syncing...",
style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreenDim)
)
} else {
LazyColumn {
items(syncState.rooms.values.sortedBy { it.name }) { room ->
RoomRow(
room = room,
onClick = { onRoomSelected(room.id) },
onLongPress = { roomToForget = room }
)
}
}
}
}
roomToForget?.let { room ->
AlertDialog(
containerColor = MatrixBackground,
title = {
Text(
text = "forget room?",
style = TextStyle(fontFamily = TerminalFont, fontSize = 16.sp, color = MatrixGreen)
)
},
text = {
Text(
text = "leave and forget ${room.name}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixGreenDim)
)
},
onDismissRequest = { roomToForget = null },
confirmButton = {
TextButton(onClick = {
scope.launch {
val target = room
roomToForget = null
session.forgetRoom(target.id)
.onFailure { actionError = it.message ?: "failed to forget room" }
}
}) {
Text("forget", fontFamily = TerminalFont, color = MatrixGreen)
}
},
dismissButton = {
TextButton(onClick = { roomToForget = null }) {
Text("cancel", fontFamily = TerminalFont, color = MatrixBorder)
}
}
)
}
if (showNewChatDialog) {
AlertDialog(
containerColor = MatrixBackground,
title = {
Text(
text = "new direct chat",
style = TextStyle(fontFamily = TerminalFont, fontSize = 16.sp, color = MatrixGreen)
)
},
text = {
Column {
OutlinedTextField(
value = handleInput,
onValueChange = { handleInput = it },
singleLine = true,
placeholder = {
Text(
"@user:server",
style = TextStyle(fontFamily = TerminalFont, color = MatrixBorder)
)
},
textStyle = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreen),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MatrixGreen,
unfocusedBorderColor = MatrixBorder,
cursorColor = MatrixGreen,
focusedTextColor = MatrixGreen,
unfocusedTextColor = MatrixGreen,
focusedContainerColor = MatrixBackground,
unfocusedContainerColor = MatrixBackground
)
)
Spacer(Modifier.height(8.dp))
Text(
text = "invite by Matrix handle",
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixGreenDim)
)
}
},
onDismissRequest = { showNewChatDialog = false },
confirmButton = {
TextButton(onClick = {
scope.launch {
session.startDirectChat(handleInput)
.onSuccess { roomId ->
actionError = null
handleInput = ""
showNewChatDialog = false
onRoomSelected(roomId)
}
.onFailure { actionError = it.message ?: "failed to create direct chat" }
}
}) {
Text("open", fontFamily = TerminalFont, color = MatrixGreen)
}
},
dismissButton = {
TextButton(onClick = { showNewChatDialog = false }) {
Text("cancel", fontFamily = TerminalFont, color = MatrixBorder)
}
}
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RoomRow(room: Room, onClick: () -> Unit, onLongPress: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongPress)
.padding(vertical = 10.dp, horizontal = 4.dp)
) {
Text(
text = "> ${room.name}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 15.sp, color = MatrixGreen)
)
Text(
text = " ${room.id}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 10.sp, color = MatrixBorder)
)
}
}

View file

@ -0,0 +1,36 @@
package com.ltadeu6.matrix.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
val MatrixGreen = Color(0xFF00FF41)
val MatrixGreenDim = Color(0xFF00CC33)
val MatrixBackground = Color(0xFF0A0A0A)
val MatrixSurface = Color(0xFF111111)
val MatrixBorder = Color(0xFF1C4A1C)
val TerminalFont: FontFamily = FontFamily.Monospace
private val MatrixColorScheme = darkColorScheme(
primary = MatrixGreen,
onPrimary = MatrixBackground,
background = MatrixBackground,
onBackground = MatrixGreen,
surface = MatrixSurface,
onSurface = MatrixGreen,
secondary = MatrixGreenDim,
onSecondary = MatrixBackground,
error = Color(0xFFFF4444),
outline = MatrixBorder
)
@Composable
fun MatrixTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = MatrixColorScheme,
content = content
)
}

View file

@ -0,0 +1,215 @@
package com.ltadeu6.matrix.verification
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.ltadeu6.matrix.matrix.MatrixSession
import com.ltadeu6.matrix.matrix.SasEmoji
import com.ltadeu6.matrix.matrix.VerificationState
import com.ltadeu6.matrix.ui.theme.MatrixBackground
import com.ltadeu6.matrix.ui.theme.MatrixBorder
import com.ltadeu6.matrix.ui.theme.MatrixGreen
import com.ltadeu6.matrix.ui.theme.MatrixGreenDim
import com.ltadeu6.matrix.ui.theme.TerminalFont
@Composable
fun VerificationScreen(onDone: () -> Unit) {
val context = LocalContext.current
val session = MatrixSession.getInstance(context)
val verState by session.verificationService.state.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp, vertical = 16.dp)
) {
Text(
text = "> DEVICE VERIFICATION",
style = TextStyle(fontFamily = TerminalFont, fontSize = 18.sp, color = MatrixGreen)
)
Spacer(Modifier.height(4.dp))
Text(
text = "".repeat(40),
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixBorder)
)
Spacer(Modifier.height(16.dp))
when (val s = verState) {
is VerificationState.Idle -> {
Text(
text = " no active verification",
style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreenDim)
)
}
is VerificationState.Requested -> {
Text(
text = " verification request from",
style = TextStyle(fontFamily = TerminalFont, fontSize = 13.sp, color = MatrixGreenDim)
)
Spacer(Modifier.height(4.dp))
Text(
text = " ${s.fromUser}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreen)
)
Text(
text = " device: ${s.fromDevice}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder)
)
Spacer(Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = { session.verificationService.accept(s.txnId) },
colors = ButtonDefaults.buttonColors(
containerColor = MatrixGreen,
contentColor = MatrixBackground
)
) {
Text("[accept]", fontFamily = TerminalFont)
}
OutlinedButton(
onClick = { session.verificationService.cancel(); onDone() },
colors = ButtonDefaults.outlinedButtonColors(contentColor = MatrixGreenDim)
) {
Text("[decline]", fontFamily = TerminalFont)
}
}
Spacer(Modifier.height(8.dp))
Text(
text = " waiting for the other device to start...",
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder)
)
}
is VerificationState.ShowingEmojis -> {
Text(
text = " compare these emojis on both devices:",
style = TextStyle(fontFamily = TerminalFont, fontSize = 13.sp, color = MatrixGreenDim)
)
Spacer(Modifier.height(16.dp))
EmojiGrid(emojis = s.emojis)
Spacer(Modifier.height(24.dp))
Text(
text = " do the emojis match on the other device?",
style = TextStyle(fontFamily = TerminalFont, fontSize = 13.sp, color = MatrixGreenDim)
)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = { session.verificationService.confirm() },
colors = ButtonDefaults.buttonColors(
containerColor = MatrixGreen,
contentColor = MatrixBackground
)
) {
Text("[they match]", fontFamily = TerminalFont)
}
OutlinedButton(
onClick = { session.verificationService.cancel(); onDone() },
colors = ButtonDefaults.outlinedButtonColors(contentColor = MatrixGreenDim)
) {
Text("[no match]", fontFamily = TerminalFont)
}
}
}
is VerificationState.Done -> {
Text(
text = " verification complete!",
style = TextStyle(fontFamily = TerminalFont, fontSize = 16.sp, color = MatrixGreen)
)
Spacer(Modifier.height(8.dp))
Text(
text = " device has been verified successfully.",
style = TextStyle(fontFamily = TerminalFont, fontSize = 13.sp, color = MatrixGreenDim)
)
Spacer(Modifier.height(24.dp))
Button(
onClick = { session.verificationService.dismiss(); onDone() },
colors = ButtonDefaults.buttonColors(
containerColor = MatrixGreen,
contentColor = MatrixBackground
)
) {
Text("[done]", fontFamily = TerminalFont)
}
}
is VerificationState.Cancelled -> {
Text(
text = " verification cancelled",
style = TextStyle(fontFamily = TerminalFont, fontSize = 16.sp, color = MatrixGreenDim)
)
Spacer(Modifier.height(8.dp))
Text(
text = " reason: ${s.reason}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixBorder)
)
Spacer(Modifier.height(24.dp))
Button(
onClick = { session.verificationService.dismiss(); onDone() },
colors = ButtonDefaults.buttonColors(
containerColor = MatrixGreen,
contentColor = MatrixBackground
)
) {
Text("[close]", fontFamily = TerminalFont)
}
}
}
}
}
@Composable
private fun EmojiGrid(emojis: List<SasEmoji>) {
LazyVerticalGrid(
columns = GridCells.Fixed(4),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(emojis) { emoji ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(4.dp)
) {
Text(
text = emoji.emoji,
fontSize = 32.sp,
textAlign = TextAlign.Center
)
Text(
text = emoji.name,
style = TextStyle(
fontFamily = TerminalFont,
fontSize = 9.sp,
color = MatrixGreenDim,
textAlign = TextAlign.Center
)
)
}
}
}
}

View file

@ -0,0 +1,14 @@
<?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">
<!-- Terminal ">" prompt in matrix green -->
<path
android:fillColor="#00FF41"
android:pathData="M30,30 L60,54 L30,78 L36,78 L66,54 L36,30 Z" />
<path
android:fillColor="#00FF41"
android:pathData="M68,72 L82,72 L82,78 L68,78 Z" />
</vector>

View file

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

View file

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

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0A0A0A</color>
<color name="matrix_green">#00FF41</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Matrix</string>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base theme — Compose draws everything; this just sets window chrome -->
<style name="Theme.MatrixAndroid" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowBackground">#0A0A0A</item>
<item name="android:statusBarColor">#0A0A0A</item>
<item name="android:navigationBarColor">#0A0A0A</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

6
build.gradle.kts Normal file
View file

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

4
gradle.properties Normal file
View file

@ -0,0 +1,4 @@
android.useAndroidX=true
android.nonTransitiveRClass=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
kotlin.code.style=official

44
gradle/libs.versions.toml Normal file
View file

@ -0,0 +1,44 @@
[versions]
agp = "8.7.3"
kotlin = "2.0.20"
coreKtx = "1.13.1"
lifecycleRuntimeKtx = "2.8.4"
activityCompose = "1.9.1"
composeBom = "2024.09.00"
navigationCompose = "2.8.0"
retrofit = "2.11.0"
okhttp = "4.12.0"
kotlinxSerialization = "1.7.1"
kotlinxCoroutines = "1.8.1"
browser = "1.8.0"
retrofitKotlinxConverter = "1.0.0"
appcompat = "1.7.0"
olmSdk = "3.2.12"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
retrofit-converter-kotlinx-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxConverter" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
olm-sdk = { group = "org.matrix.android", name = "olm-sdk", version.ref = "olmSdk" }
[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" }

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

16
settings.gradle.kts Normal file
View file

@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "matrix-android"
include(":app")

5
settings.json Normal file
View file

@ -0,0 +1,5 @@
{
"permissions": {
"allow": ["Edit", "Write", "Read", "Glob", "Grep", "Bash(*)"]
}
}