From 71af5efc4e85442d654c9ae8a326d8f635c0581e Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Thu, 14 May 2026 23:22:06 -0300 Subject: [PATCH] Initial commit --- .gitignore | 11 + AGENTS.md | 106 +++ CLAUDE.md | 63 ++ README.md | 125 ++++ app/build.gradle.kts | 69 ++ app/proguard-rules.pro | 18 + app/src/main/AndroidManifest.xml | 39 + .../java/com/ltadeu6/matrix/MainActivity.kt | 38 + .../com/ltadeu6/matrix/auth/LoginScreen.kt | 222 ++++++ .../com/ltadeu6/matrix/auth/LoginViewModel.kt | 83 +++ .../com/ltadeu6/matrix/chat/ChatScreen.kt | 197 +++++ .../com/ltadeu6/matrix/chat/ChatViewModel.kt | 53 ++ .../ltadeu6/matrix/matrix/CryptoService.kt | 342 +++++++++ .../com/ltadeu6/matrix/matrix/MatrixApi.kt | 111 +++ .../com/ltadeu6/matrix/matrix/MatrixModels.kt | 173 +++++ .../ltadeu6/matrix/matrix/MatrixSession.kt | 704 ++++++++++++++++++ .../matrix/matrix/VerificationService.kt | 465 ++++++++++++ .../matrix/navigation/AppNavigation.kt | 88 +++ .../ltadeu6/matrix/rooms/RoomListScreen.kt | 315 ++++++++ .../java/com/ltadeu6/matrix/ui/theme/Theme.kt | 36 + .../matrix/verification/VerificationScreen.kt | 215 ++++++ .../res/drawable/ic_launcher_foreground.xml | 14 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/colors.xml | 5 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/themes.xml | 10 + build.gradle.kts | 6 + gradle.properties | 4 + gradle/libs.versions.toml | 44 ++ gradle/wrapper/gradle-wrapper.properties | 7 + settings.gradle.kts | 16 + settings.json | 5 + 33 files changed, 3598 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/ltadeu6/matrix/MainActivity.kt create mode 100644 app/src/main/java/com/ltadeu6/matrix/auth/LoginScreen.kt create mode 100644 app/src/main/java/com/ltadeu6/matrix/auth/LoginViewModel.kt create mode 100644 app/src/main/java/com/ltadeu6/matrix/chat/ChatScreen.kt create mode 100644 app/src/main/java/com/ltadeu6/matrix/chat/ChatViewModel.kt create mode 100644 app/src/main/java/com/ltadeu6/matrix/matrix/CryptoService.kt create mode 100644 app/src/main/java/com/ltadeu6/matrix/matrix/MatrixApi.kt create mode 100644 app/src/main/java/com/ltadeu6/matrix/matrix/MatrixModels.kt create mode 100644 app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt create mode 100644 app/src/main/java/com/ltadeu6/matrix/matrix/VerificationService.kt create mode 100644 app/src/main/java/com/ltadeu6/matrix/navigation/AppNavigation.kt create mode 100644 app/src/main/java/com/ltadeu6/matrix/rooms/RoomListScreen.kt create mode 100644 app/src/main/java/com/ltadeu6/matrix/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/ltadeu6/matrix/verification/VerificationScreen.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 settings.gradle.kts create mode 100644 settings.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ff53f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +app/release/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b38f016 --- /dev/null +++ b/AGENTS.md @@ -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=`. The app intercepts this URI via an `` 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`. + +### 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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..79f5603 --- /dev/null +++ b/CLAUDE.md @@ -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` and `syncState: StateFlow` + +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 `/_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=` +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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e5e205 --- /dev/null +++ b/README.md @@ -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` 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) diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..5c72ada --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..a1ff435 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,18 @@ +# Retrofit +-keepattributes Signature +-keepattributes *Annotation* +-keep class retrofit2.** { *; } +-keepclasseswithmembers class * { + @retrofit2.http.* ; +} + +# 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(...); +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0deaa1e --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/ltadeu6/matrix/MainActivity.kt b/app/src/main/java/com/ltadeu6/matrix/MainActivity.kt new file mode 100644 index 0000000..02ae1bf --- /dev/null +++ b/app/src/main/java/com/ltadeu6/matrix/MainActivity.kt @@ -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) + } +} diff --git a/app/src/main/java/com/ltadeu6/matrix/auth/LoginScreen.kt b/app/src/main/java/com/ltadeu6/matrix/auth/LoginScreen.kt new file mode 100644 index 0000000..9728ffe --- /dev/null +++ b/app/src/main/java/com/ltadeu6/matrix/auth/LoginScreen.kt @@ -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)) + } +} diff --git a/app/src/main/java/com/ltadeu6/matrix/auth/LoginViewModel.kt b/app/src/main/java/com/ltadeu6/matrix/auth/LoginViewModel.kt new file mode 100644 index 0000000..f9c0d5f --- /dev/null +++ b/app/src/main/java/com/ltadeu6/matrix/auth/LoginViewModel.kt @@ -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 = _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 create(modelClass: Class): T = + LoginViewModel(session) as T +} diff --git a/app/src/main/java/com/ltadeu6/matrix/chat/ChatScreen.kt b/app/src/main/java/com/ltadeu6/matrix/chat/ChatScreen.kt new file mode 100644 index 0000000..373b3f9 --- /dev/null +++ b/app/src/main/java/com/ltadeu6/matrix/chat/ChatScreen.kt @@ -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 = " + 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) + ) + } + } +} diff --git a/app/src/main/java/com/ltadeu6/matrix/chat/ChatViewModel.kt b/app/src/main/java/com/ltadeu6/matrix/chat/ChatViewModel.kt new file mode 100644 index 0000000..14156f1 --- /dev/null +++ b/app/src/main/java/com/ltadeu6/matrix/chat/ChatViewModel.kt @@ -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> = session.syncState + .map { it.messages[roomId] ?: emptyList() } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + val roomName: StateFlow = session.syncState + .map { it.rooms[roomId]?.name ?: roomId } + .stateIn(viewModelScope, SharingStarted.Eagerly, roomId) + + private val _inputText = MutableStateFlow("") + val inputText: StateFlow = _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 create(modelClass: Class): T = + ChatViewModel(session, roomId) as T +} diff --git a/app/src/main/java/com/ltadeu6/matrix/matrix/CryptoService.kt b/app/src/main/java/com/ltadeu6/matrix/matrix/CryptoService.kt new file mode 100644 index 0000000..ba5b5dc --- /dev/null +++ b/app/src/main/java/com/ltadeu6/matrix/matrix/CryptoService.kt @@ -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() + private val outboundSessions = mutableMapOf() + private val olmSessions = mutableMapOf>() + + 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> + + 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 = 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) { + 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? { + 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") diff --git a/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixApi.kt b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixApi.kt new file mode 100644 index 0000000..0188629 --- /dev/null +++ b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixApi.kt @@ -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 +} diff --git a/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixModels.kt b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixModels.kt new file mode 100644 index 0000000..d4183a1 --- /dev/null +++ b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixModels.kt @@ -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 = 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 = emptyMap(), + @SerialName("device_unused_fallback_key_types") val deviceUnusedFallbackKeyTypes: List? = null +) + +@Serializable +data class ToDeviceSync( + val events: List = emptyList() +) + +@Serializable +data class DeviceLists( + val changed: List = emptyList(), + val left: List = emptyList() +) + +@Serializable +data class RoomsSync( + val join: Map = emptyMap() +) + +@Serializable +data class JoinedRoomSync( + val timeline: Timeline? = null, + val state: State? = null +) + +@Serializable +data class Timeline( + val events: List = emptyList(), + @SerialName("limited") val limited: Boolean = false +) + +@Serializable +data class State( + val events: List = 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 = 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 = 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 +) diff --git a/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt new file mode 100644 index 0000000..0650294 --- /dev/null +++ b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt @@ -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 = emptyMap(), + val messages: Map> = 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.LoggedOut) + val authState: StateFlow = _authState.asStateFlow() + + private val _syncState = MutableStateFlow(SyncState()) + val syncState: StateFlow = _syncState.asStateFlow() + + private val _pendingSsoToken = MutableStateFlow(null) + val pendingSsoToken: StateFlow = _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() + + /** Rooms for which we have already shared our outbound Megolm session. */ + private val sharedSessionRooms = mutableSetOf() + + /** Sessions we have already requested from our other devices (roomId+sessionId). */ + private val requestedSessions = mutableSetOf() + + /** 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 = 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 = 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 { + 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 { + 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 { + 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, 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() + val claimMap = mutableMapOf>() + + 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>() + 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() + 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 { + val current = _syncState.value + val rooms = current.rooms.toMutableMap() + val messages = current.messages.toMutableMap() + val missingSessions = mutableListOf() + + 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): 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(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 } + } + } +} diff --git a/app/src/main/java/com/ltadeu6/matrix/matrix/VerificationService.kt b/app/src/main/java/com/ltadeu6/matrix/matrix/VerificationService.kt new file mode 100644 index 0000000..3970931 --- /dev/null +++ b/app/src/main/java/com/ltadeu6/matrix/matrix/VerificationService.kt @@ -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) : 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.Idle) + val state: StateFlow = _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 { + 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 0–63), 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") +) diff --git a/app/src/main/java/com/ltadeu6/matrix/navigation/AppNavigation.kt b/app/src/main/java/com/ltadeu6/matrix/navigation/AppNavigation.kt new file mode 100644 index 0000000..31b3d7e --- /dev/null +++ b/app/src/main/java/com/ltadeu6/matrix/navigation/AppNavigation.kt @@ -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() } + ) + } + } +} diff --git a/app/src/main/java/com/ltadeu6/matrix/rooms/RoomListScreen.kt b/app/src/main/java/com/ltadeu6/matrix/rooms/RoomListScreen.kt new file mode 100644 index 0000000..fde50eb --- /dev/null +++ b/app/src/main/java/com/ltadeu6/matrix/rooms/RoomListScreen.kt @@ -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(null) } + var showNewChatDialog by remember { mutableStateOf(false) } + var handleInput by remember { mutableStateOf("") } + var actionError by remember { mutableStateOf(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) + ) + } +} diff --git a/app/src/main/java/com/ltadeu6/matrix/ui/theme/Theme.kt b/app/src/main/java/com/ltadeu6/matrix/ui/theme/Theme.kt new file mode 100644 index 0000000..b581198 --- /dev/null +++ b/app/src/main/java/com/ltadeu6/matrix/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/app/src/main/java/com/ltadeu6/matrix/verification/VerificationScreen.kt b/app/src/main/java/com/ltadeu6/matrix/verification/VerificationScreen.kt new file mode 100644 index 0000000..79215cd --- /dev/null +++ b/app/src/main/java/com/ltadeu6/matrix/verification/VerificationScreen.kt @@ -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) { + 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 + ) + ) + } + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..9fe2e17 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..5e8a5d9 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #0A0A0A + #00FF41 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d12c69c --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Matrix + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..ff2a933 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..132ad8d --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..73e2c0c --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +android.useAndroidX=true +android.nonTransitiveRClass=true +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..592029f --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..09523c0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..b0f00ef --- /dev/null +++ b/settings.gradle.kts @@ -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") diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..5c1a26e --- /dev/null +++ b/settings.json @@ -0,0 +1,5 @@ +{ + "permissions": { + "allow": ["Edit", "Write", "Read", "Glob", "Grep", "Bash(*)"] + } +} \ No newline at end of file