Initial commit
This commit is contained in:
commit
71af5efc4e
33 changed files with 3598 additions and 0 deletions
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
app/release/
|
||||
106
AGENTS.md
Normal file
106
AGENTS.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# AGENTS.md — matrix-android
|
||||
|
||||
Progress log and architecture notes for AI agents working on this project.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
| Component | Status |
|
||||
|---|---|
|
||||
| Build infrastructure (Gradle, version catalog) | done |
|
||||
| Matrix networking (Retrofit API + Session) | done |
|
||||
| SSO / password login flow | done |
|
||||
| Room list screen | done |
|
||||
| Chat screen (send + receive) | done |
|
||||
| Terminal theme (green on black, monospace) | done |
|
||||
| Launcher icon (vector adaptive) | done |
|
||||
| README | done |
|
||||
|
||||
---
|
||||
|
||||
## Design decisions
|
||||
|
||||
### No heavy Matrix SDK
|
||||
Instead of importing the full `matrix-android-sdk2` (used by Element), we call the Matrix REST API directly with Retrofit. This keeps the APK small and the code easy to follow. The trade-off is no E2E encryption support.
|
||||
|
||||
### SSO via Custom Tabs
|
||||
Login opens the homeserver's `/login/sso/redirect` endpoint in a Chrome Custom Tab. After auth the homeserver redirects to `matrixandroid://sso?loginToken=<token>`. The app intercepts this URI via an `<intent-filter>` in `AndroidManifest.xml` and exchanges the token for an access token.
|
||||
|
||||
### Single-activity Compose navigation
|
||||
`MainActivity` → `AppNavigation` hosts all three screens (Login, Rooms, Chat) inside a single `NavHost`. No fragments.
|
||||
|
||||
### MatrixSession singleton
|
||||
`MatrixSession` is a process-scoped singleton (companion object). It holds the Retrofit client, persists the access token in `SharedPreferences`, and runs the `/sync` long-polling loop in a coroutine scope. All screens observe `syncState: StateFlow<SyncState>`.
|
||||
|
||||
### Sync filter
|
||||
Initial and incremental syncs use a compact filter:
|
||||
- Timeline limited to 50 events per room
|
||||
- State events use lazy member loading
|
||||
- Presence and account data stripped
|
||||
|
||||
This prevents the first sync from being huge on accounts with many rooms.
|
||||
|
||||
---
|
||||
|
||||
## Known issues / future work
|
||||
|
||||
- **E2E encryption**: Matrix Olm/Megolm is not implemented. Encrypted rooms will show blank messages.
|
||||
- **Push notifications**: Not implemented. The app only receives messages while open.
|
||||
- **Pagination**: No scroll-back pagination for old messages.
|
||||
- **Room member display names**: Members are shown as their MXID, not display name.
|
||||
- **Image/file messages**: Silently dropped (only `m.text` is rendered).
|
||||
- **Error handling**: Network errors on send are silently swallowed; add a snackbar or retry UI.
|
||||
- **Direct messages**: DMs appear in the room list using the room ID; should resolve the other user's display name.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
```
|
||||
matrix-android/
|
||||
├── settings.gradle.kts
|
||||
├── build.gradle.kts
|
||||
├── gradle/
|
||||
│ ├── libs.versions.toml ← version catalog
|
||||
│ └── wrapper/
|
||||
│ └── gradle-wrapper.properties
|
||||
├── app/
|
||||
│ ├── build.gradle.kts
|
||||
│ ├── proguard-rules.pro
|
||||
│ └── src/main/
|
||||
│ ├── AndroidManifest.xml
|
||||
│ ├── java/com/ltadeu6/matrix/
|
||||
│ │ ├── MainActivity.kt
|
||||
│ │ ├── navigation/AppNavigation.kt
|
||||
│ │ ├── auth/LoginScreen.kt
|
||||
│ │ ├── auth/LoginViewModel.kt
|
||||
│ │ ├── rooms/RoomListScreen.kt
|
||||
│ │ ├── chat/ChatScreen.kt
|
||||
│ │ ├── chat/ChatViewModel.kt
|
||||
│ │ ├── matrix/MatrixApi.kt
|
||||
│ │ ├── matrix/MatrixModels.kt
|
||||
│ │ ├── matrix/MatrixSession.kt
|
||||
│ │ └── ui/theme/Theme.kt
|
||||
│ └── res/
|
||||
│ ├── drawable/ic_launcher_foreground.xml
|
||||
│ ├── mipmap-anydpi-v26/ic_launcher.xml
|
||||
│ ├── mipmap-anydpi-v26/ic_launcher_round.xml
|
||||
│ └── values/{strings,colors,themes}.xml
|
||||
├── README.md
|
||||
└── AGENTS.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session 1 — initial build (2026-05-14)
|
||||
|
||||
- Inspected `matrix-message` terminal app for UI reference:
|
||||
- `app.py`: curses chat with `< ` prefix for incoming, `> ` for outgoing
|
||||
- `font.py`: big pixel ASCII font for incoming banners
|
||||
- Color scheme: `COLOR_GREEN` on black
|
||||
- Created full Android project from scratch (no fork)
|
||||
- Chose Retrofit + kotlinx.serialization over matrix-android-sdk2 for simplicity
|
||||
- SSO flow: Custom Tabs → homeserver → `matrixandroid://sso` deep link → token exchange
|
||||
- Compose UI with monospace font, #00FF41 green, #0A0A0A background
|
||||
- All screens text-only, no images/icons/avatars
|
||||
63
CLAUDE.md
Normal file
63
CLAUDE.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build commands
|
||||
|
||||
```bash
|
||||
# First-time setup: generate the Gradle wrapper (requires Gradle installed)
|
||||
gradle wrapper --gradle-version 8.9
|
||||
|
||||
# Build debug APK
|
||||
./gradlew assembleDebug
|
||||
|
||||
# Build release APK
|
||||
./gradlew assembleRelease
|
||||
|
||||
# Install on connected device/emulator
|
||||
./gradlew installDebug
|
||||
|
||||
# Run lint
|
||||
./gradlew lint
|
||||
|
||||
# Clean
|
||||
./gradlew clean
|
||||
```
|
||||
|
||||
The project has no unit tests yet. Android Studio is the recommended way to open, sync, and run the project.
|
||||
|
||||
## Architecture
|
||||
|
||||
Single-activity Compose app. All screens live inside one `NavHost` in `AppNavigation.kt`. Navigation flow: **Login → Rooms → Chat**.
|
||||
|
||||
### State ownership
|
||||
|
||||
`MatrixSession` (singleton, `matrix/MatrixSession.kt`) is the single source of truth. It:
|
||||
- Persists the access token in `SharedPreferences`
|
||||
- Owns and runs the `/sync` long-polling coroutine loop
|
||||
- Exposes `authState: StateFlow<AuthState>` and `syncState: StateFlow<SyncState>`
|
||||
|
||||
All ViewModels and screens observe these flows directly — there is no local room/message database.
|
||||
|
||||
### Matrix transport
|
||||
|
||||
Direct REST API via Retrofit (`MatrixApi.kt`). No Matrix SDK dependency. Only `m.room.message` text events are rendered; encrypted events are silently dropped.
|
||||
|
||||
### SSO login flow
|
||||
|
||||
1. `LoginViewModel.buildSsoUrl()` constructs `<homeserver>/_matrix/client/v3/login/sso/redirect?redirectUrl=matrixandroid://sso`
|
||||
2. The URL opens in a Chrome Custom Tab
|
||||
3. Homeserver redirects to `matrixandroid://sso?loginToken=<token>`
|
||||
4. `MainActivity.onNewIntent` (hot) or `onCreate` (cold) reads the token and calls `MatrixSession.handleSsoCallback(token)`
|
||||
5. `LoginScreen` observes `session.pendingSsoToken` and calls `vm.loginWithSsoToken(token)` which POSTs to `/_matrix/client/v3/login`
|
||||
|
||||
### Theme
|
||||
|
||||
`ui/theme/Theme.kt` — `MatrixGreen` (`#00FF41`) on `MatrixBackground` (`#0A0A0A`). All text uses `TerminalFont` (`FontFamily.Monospace`). No Material components with default colors — always pass explicit `colors =` overrides to `OutlinedTextField` and `Button`.
|
||||
|
||||
## Key conventions
|
||||
|
||||
- `MatrixSession` is always obtained via `MatrixSession.getInstance(context)` — never constructed directly
|
||||
- Room IDs (`!abc:homeserver`) are URL-encoded when passed as nav arguments (`Route.chat(roomId)`)
|
||||
- `SyncState.messages[roomId]` is append-only and deduped by `eventId`; ordering is by `origin_server_ts`
|
||||
- The sync filter (`MatrixSession.syncFilter`) limits timeline to 50 events and strips presence/account data — change it here if more history is needed
|
||||
125
README.md
Normal file
125
README.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# matrix-android
|
||||
|
||||
A minimal Android Matrix chat client with a terminal aesthetic — dark background, green monospace text, `<` / `>` message prefixes — inspired by the [matrix-message](../matrix-message) living-room terminal app.
|
||||
|
||||
## Features
|
||||
|
||||
- Browser-based SSO login (Custom Tabs — no password stored by the app)
|
||||
- Password login fallback
|
||||
- Room list after login
|
||||
- Text-only chat (no images, no avatars)
|
||||
- Real-time sync via Matrix `/sync` long-polling
|
||||
- Persistent session (survives app restart)
|
||||
|
||||
## Screenshots / UI description
|
||||
|
||||
```
|
||||
> MATRIX LOGIN (login screen)
|
||||
────────────────────────
|
||||
homeserver: matrix.org
|
||||
[ LOGIN WITH BROWSER (SSO) ]
|
||||
──── or password login ────
|
||||
username: @user:matrix.org
|
||||
password: ••••••••
|
||||
[ LOGIN ]
|
||||
|
||||
> ROOMS (room list)
|
||||
────────────────────────────────────────
|
||||
> My Room
|
||||
!abc123:matrix.org
|
||||
> Family chat
|
||||
!xyz456:example.com
|
||||
[logout]
|
||||
|
||||
< back My Room (chat screen)
|
||||
──────────────────────────────────────
|
||||
< alice
|
||||
hello!
|
||||
> hey there >
|
||||
< alice
|
||||
how are you?
|
||||
──────────────────────────────────────
|
||||
> message...
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Android Studio Ladybug (2024.2) or later
|
||||
- Android SDK 35
|
||||
- minSdk 26 (Android 8.0)
|
||||
|
||||
## Build
|
||||
|
||||
1. Clone the repo
|
||||
2. Open the `matrix-android/` folder in Android Studio
|
||||
3. Let Gradle sync (it downloads all dependencies automatically)
|
||||
4. Run on a device or emulator
|
||||
|
||||
If you prefer the command line, first generate the Gradle wrapper:
|
||||
|
||||
```bash
|
||||
cd matrix-android
|
||||
gradle wrapper --gradle-version 8.9
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
The APK is at `app/build/outputs/apk/debug/app-debug.apk`.
|
||||
|
||||
## Login
|
||||
|
||||
### SSO (recommended)
|
||||
|
||||
1. Enter your homeserver (default: `matrix.org`)
|
||||
2. Tap **LOGIN WITH BROWSER (SSO)**
|
||||
3. A browser tab opens to the homeserver's login/SSO page
|
||||
4. After authenticating, you are redirected back to the app automatically
|
||||
|
||||
The app registers the `matrixandroid://sso` URI scheme for this redirect.
|
||||
|
||||
### Password
|
||||
|
||||
Enter your full Matrix username (`@user:homeserver.tld`) and password directly.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
app/src/main/java/com/ltadeu6/matrix/
|
||||
├── MainActivity.kt — single Activity; handles SSO deep link
|
||||
├── navigation/
|
||||
│ └── AppNavigation.kt — Compose NavHost (Login → Rooms → Chat)
|
||||
├── auth/
|
||||
│ ├── LoginScreen.kt — login UI
|
||||
│ └── LoginViewModel.kt — SSO URL builder, login calls
|
||||
├── rooms/
|
||||
│ └── RoomListScreen.kt — joined rooms list
|
||||
├── chat/
|
||||
│ ├── ChatScreen.kt — message list + input bar
|
||||
│ └── ChatViewModel.kt — sends messages, maps sync state
|
||||
├── matrix/
|
||||
│ ├── MatrixApi.kt — Retrofit interface (Matrix REST v3)
|
||||
│ ├── MatrixModels.kt — serialization data classes + local models
|
||||
│ └── MatrixSession.kt — singleton: auth, sync loop, state flows
|
||||
└── ui/theme/
|
||||
└── Theme.kt — terminal color scheme (green on black)
|
||||
```
|
||||
|
||||
**State flow**: `MatrixSession` owns a `syncState: StateFlow<SyncState>` that all screens observe. The background sync loop continuously updates it via `/sync` long-polling.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Library | Purpose |
|
||||
|---|---|
|
||||
| Jetpack Compose + Material3 | UI |
|
||||
| Navigation Compose | Screen routing |
|
||||
| Retrofit 2 + OkHttp | Matrix HTTP API |
|
||||
| kotlinx.serialization | JSON parsing |
|
||||
| retrofit2-kotlinx-serialization-converter | Retrofit ↔ kotlinx bridge |
|
||||
| kotlinx.coroutines | Async / sync loop |
|
||||
| androidx.browser (Custom Tabs) | SSO browser login |
|
||||
|
||||
## Known limitations
|
||||
|
||||
- No E2E encryption (plain-text transport only; suitable for unencrypted rooms)
|
||||
- No push notifications
|
||||
- No media/file messages (text only)
|
||||
- Room list is not paginated (loads whatever `/sync` returns on first sync)
|
||||
69
app/build.gradle.kts
Normal file
69
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.ltadeu6.matrix"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.ltadeu6.matrix"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs.pickFirsts += "**/*.so"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.retrofit.converter.kotlinx.serialization)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.androidx.browser)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.olm.sdk)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.okhttp.logging)
|
||||
}
|
||||
18
app/proguard-rules.pro
vendored
Normal file
18
app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Retrofit
|
||||
-keepattributes Signature
|
||||
-keepattributes *Annotation*
|
||||
-keep class retrofit2.** { *; }
|
||||
-keepclasseswithmembers class * {
|
||||
@retrofit2.http.* <methods>;
|
||||
}
|
||||
|
||||
# kotlinx.serialization
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.AnnotationsKt
|
||||
-keep,includedescriptorclasses class com.ltadeu6.matrix.**$$serializer { *; }
|
||||
-keepclassmembers class com.ltadeu6.matrix.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class com.ltadeu6.matrix.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
39
app/src/main/AndroidManifest.xml
Normal file
39
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="false"
|
||||
android:theme="@style/Theme.MatrixAndroid">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
|
||||
<!-- Launcher -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- SSO callback: matrixandroid://sso?loginToken=... -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="matrixandroid"
|
||||
android:host="sso" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
38
app/src/main/java/com/ltadeu6/matrix/MainActivity.kt
Normal file
38
app/src/main/java/com/ltadeu6/matrix/MainActivity.kt
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package com.ltadeu6.matrix
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import com.ltadeu6.matrix.matrix.MatrixSession
|
||||
import com.ltadeu6.matrix.navigation.AppNavigation
|
||||
import com.ltadeu6.matrix.ui.theme.MatrixTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Token may arrive cold (app launched from SSO redirect)
|
||||
val loginToken = intent.data
|
||||
?.takeIf { it.scheme == "matrixandroid" }
|
||||
?.getQueryParameter("loginToken")
|
||||
|
||||
setContent {
|
||||
MatrixTheme {
|
||||
AppNavigation(initialLoginToken = loginToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// Token may arrive hot (app already running when SSO redirect fires)
|
||||
val token = intent.data
|
||||
?.takeIf { it.scheme == "matrixandroid" }
|
||||
?.getQueryParameter("loginToken") ?: return
|
||||
MatrixSession.getInstance(this).handleSsoCallback(token)
|
||||
}
|
||||
}
|
||||
222
app/src/main/java/com/ltadeu6/matrix/auth/LoginScreen.kt
Normal file
222
app/src/main/java/com/ltadeu6/matrix/auth/LoginScreen.kt
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
package com.ltadeu6.matrix.auth
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.ltadeu6.matrix.matrix.MatrixSession
|
||||
import com.ltadeu6.matrix.ui.theme.MatrixBackground
|
||||
import com.ltadeu6.matrix.ui.theme.MatrixBorder
|
||||
import com.ltadeu6.matrix.ui.theme.MatrixGreen
|
||||
import com.ltadeu6.matrix.ui.theme.MatrixGreenDim
|
||||
import com.ltadeu6.matrix.ui.theme.TerminalFont
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
initialToken: String? = null,
|
||||
onLoggedIn: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val session = MatrixSession.getInstance(context)
|
||||
val vm: LoginViewModel = viewModel(factory = LoginViewModelFactory(session))
|
||||
val state by vm.state.collectAsState()
|
||||
val pendingSsoToken by session.pendingSsoToken.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// SSO token that arrived while app was already open
|
||||
LaunchedEffect(pendingSsoToken) {
|
||||
val token = pendingSsoToken ?: return@LaunchedEffect
|
||||
session.consumeSsoToken()
|
||||
vm.loginWithSsoToken(token)
|
||||
}
|
||||
|
||||
// SSO token that launched the app cold
|
||||
LaunchedEffect(Unit) {
|
||||
if (initialToken != null) vm.loginWithSsoToken(initialToken)
|
||||
}
|
||||
|
||||
LaunchedEffect(state.loggedIn) {
|
||||
if (state.loggedIn) onLoggedIn()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp, vertical = 48.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = "> MATRIX",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 22.sp, color = MatrixGreen)
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "─".repeat(28),
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixBorder)
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
// Homeserver
|
||||
TerminalLabel("homeserver:")
|
||||
TerminalField(
|
||||
value = state.homeserver,
|
||||
onValueChange = vm::setHomeserver,
|
||||
placeholder = "matrix.org",
|
||||
imeAction = ImeAction.Next
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// Browser SSO button
|
||||
TerminalButton(
|
||||
text = if (state.loading) "> CHECKING..." else "> LOGIN WITH BROWSER (SSO)",
|
||||
enabled = !state.loading,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val error = vm.checkSsoSupport()
|
||||
if (error != null) {
|
||||
// Surface the error through the VM state so the existing error text shows it
|
||||
vm.setError(error)
|
||||
} else {
|
||||
CustomTabsIntent.Builder()
|
||||
.build()
|
||||
.launchUrl(context, Uri.parse(vm.buildSsoUrl()))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "──── or password login ────",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Username
|
||||
TerminalLabel("username:")
|
||||
TerminalField(
|
||||
value = state.username,
|
||||
onValueChange = vm::setUsername,
|
||||
placeholder = "@user:matrix.org",
|
||||
imeAction = ImeAction.Next
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// Password
|
||||
TerminalLabel("password:")
|
||||
TerminalField(
|
||||
value = state.password,
|
||||
onValueChange = vm::setPassword,
|
||||
placeholder = "••••••••",
|
||||
imeAction = ImeAction.Done,
|
||||
obscure = true,
|
||||
onDone = vm::loginWithPassword
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
|
||||
TerminalButton(
|
||||
text = if (state.loading) "> CONNECTING..." else "> LOGIN",
|
||||
enabled = !state.loading,
|
||||
onClick = vm::loginWithPassword
|
||||
)
|
||||
|
||||
state.error?.let { err ->
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "! $err",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = androidx.compose.ui.graphics.Color(0xFFFF4444))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TerminalLabel(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 13.sp, color = MatrixGreenDim)
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TerminalField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
imeAction: ImeAction,
|
||||
obscure: Boolean = false,
|
||||
onDone: (() -> Unit)? = null
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(placeholder, style = TextStyle(fontFamily = TerminalFont, color = MatrixBorder))
|
||||
},
|
||||
textStyle = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreen),
|
||||
singleLine = true,
|
||||
visualTransformation = if (obscure) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,
|
||||
keyboardOptions = KeyboardOptions(imeAction = imeAction),
|
||||
keyboardActions = KeyboardActions(onDone = { onDone?.invoke() }),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MatrixGreen,
|
||||
unfocusedBorderColor = MatrixBorder,
|
||||
cursorColor = MatrixGreen,
|
||||
focusedTextColor = MatrixGreen,
|
||||
unfocusedTextColor = MatrixGreen,
|
||||
focusedContainerColor = MatrixBackground,
|
||||
unfocusedContainerColor = MatrixBackground
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TerminalButton(text: String, enabled: Boolean = true, onClick: () -> Unit) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
border = BorderStroke(1.dp, if (enabled) MatrixGreen else MatrixBorder),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MatrixBackground,
|
||||
contentColor = MatrixGreen,
|
||||
disabledContainerColor = MatrixBackground,
|
||||
disabledContentColor = MatrixBorder
|
||||
)
|
||||
) {
|
||||
Text(text, style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp))
|
||||
}
|
||||
}
|
||||
83
app/src/main/java/com/ltadeu6/matrix/auth/LoginViewModel.kt
Normal file
83
app/src/main/java/com/ltadeu6/matrix/auth/LoginViewModel.kt
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package com.ltadeu6.matrix.auth
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.ltadeu6.matrix.matrix.MatrixSession
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class LoginState(
|
||||
val homeserver: String = "matrix.org",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val loggedIn: Boolean = false
|
||||
)
|
||||
|
||||
class LoginViewModel(private val session: MatrixSession) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(LoginState())
|
||||
val state: StateFlow<LoginState> = _state.asStateFlow()
|
||||
|
||||
fun setHomeserver(v: String) { _state.value = _state.value.copy(homeserver = v, error = null) }
|
||||
fun setUsername(v: String) { _state.value = _state.value.copy(username = v, error = null) }
|
||||
fun setPassword(v: String) { _state.value = _state.value.copy(password = v, error = null) }
|
||||
fun setError(msg: String) { _state.value = _state.value.copy(error = msg, loading = false) }
|
||||
|
||||
/** Returns the full SSO redirect URL to open in the browser. */
|
||||
fun buildSsoUrl(): String {
|
||||
val hs = _state.value.homeserver.trim().ifBlank { "matrix.org" }
|
||||
val base = if (hs.startsWith("http")) hs else "https://$hs"
|
||||
val redirect = Uri.encode("matrixandroid://sso")
|
||||
return "$base/_matrix/client/v3/login/sso/redirect?redirectUrl=$redirect"
|
||||
}
|
||||
|
||||
/** Checks if the homeserver supports SSO; returns null if supported, error message if not. */
|
||||
suspend fun checkSsoSupport(): String? {
|
||||
val hs = _state.value.homeserver.trim().ifBlank { "matrix.org" }
|
||||
return session.checkSsoSupported(hs)
|
||||
}
|
||||
|
||||
fun loginWithSsoToken(token: String) {
|
||||
val hs = _state.value.homeserver.trim().ifBlank { "matrix.org" }
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
val result = session.loginWithToken(hs, token)
|
||||
_state.value = if (result.isSuccess) {
|
||||
_state.value.copy(loading = false, loggedIn = true)
|
||||
} else {
|
||||
val msg = result.exceptionOrNull()?.message ?: "SSO login failed"
|
||||
_state.value.copy(loading = false, error = msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loginWithPassword() {
|
||||
val s = _state.value
|
||||
if (s.username.isBlank() || s.password.isBlank()) {
|
||||
_state.value = s.copy(error = "username and password required")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = s.copy(loading = true, error = null)
|
||||
val hs = s.homeserver.trim().ifBlank { "matrix.org" }
|
||||
val result = session.loginWithPassword(hs, s.username, s.password)
|
||||
_state.value = if (result.isSuccess) {
|
||||
_state.value.copy(loading = false, loggedIn = true)
|
||||
} else {
|
||||
_state.value.copy(loading = false, error = result.exceptionOrNull()?.message ?: "Login failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LoginViewModelFactory(private val session: MatrixSession) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
LoginViewModel(session) as T
|
||||
}
|
||||
197
app/src/main/java/com/ltadeu6/matrix/chat/ChatScreen.kt
Normal file
197
app/src/main/java/com/ltadeu6/matrix/chat/ChatScreen.kt
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
package com.ltadeu6.matrix.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.ltadeu6.matrix.matrix.MatrixSession
|
||||
import com.ltadeu6.matrix.matrix.Message
|
||||
import com.ltadeu6.matrix.ui.theme.MatrixBackground
|
||||
import com.ltadeu6.matrix.ui.theme.MatrixBorder
|
||||
import com.ltadeu6.matrix.ui.theme.MatrixGreen
|
||||
import com.ltadeu6.matrix.ui.theme.MatrixGreenDim
|
||||
import com.ltadeu6.matrix.ui.theme.TerminalFont
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
roomId: String,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val session = MatrixSession.getInstance(context)
|
||||
val vm: ChatViewModel = viewModel(
|
||||
key = roomId,
|
||||
factory = ChatViewModelFactory(session, roomId)
|
||||
)
|
||||
|
||||
val messages by vm.messages.collectAsState()
|
||||
val roomName by vm.roomName.collectAsState()
|
||||
val inputText by vm.inputText.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(messages.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = onBack, contentPadding = PaddingValues(0.dp)) {
|
||||
Text(
|
||||
text = "<back",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreenDim)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
text = roomName,
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 15.sp, color = MatrixGreen),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "─".repeat(50),
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder),
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
|
||||
// Message list
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 16.dp),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
items(
|
||||
items = messages,
|
||||
key = { it.eventId.ifEmpty { "${it.sender}:${it.timestamp}" } }
|
||||
) { msg ->
|
||||
MessageRow(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Input separator
|
||||
Text(
|
||||
text = "─".repeat(50),
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder),
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
|
||||
// Input bar
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "> ",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 16.sp, color = MatrixGreen)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = inputText,
|
||||
onValueChange = vm::setInput,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 4.dp),
|
||||
placeholder = {
|
||||
Text(
|
||||
"message...",
|
||||
style = TextStyle(fontFamily = TerminalFont, color = MatrixBorder)
|
||||
)
|
||||
},
|
||||
textStyle = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreen),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = { vm.sendMessage() }),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MatrixGreen,
|
||||
unfocusedBorderColor = MatrixBorder,
|
||||
cursorColor = MatrixGreen,
|
||||
focusedTextColor = MatrixGreen,
|
||||
unfocusedTextColor = MatrixGreen,
|
||||
focusedContainerColor = MatrixBackground,
|
||||
unfocusedContainerColor = MatrixBackground
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageRow(msg: Message) {
|
||||
val shortSender = msg.sender.removePrefix("@").substringBefore(":")
|
||||
|
||||
if (msg.isOutgoing) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Text(
|
||||
text = "> ${msg.body}",
|
||||
style = TextStyle(
|
||||
fontFamily = TerminalFont,
|
||||
fontSize = 14.sp,
|
||||
color = MatrixGreenDim
|
||||
),
|
||||
modifier = Modifier.widthIn(max = 300.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(modifier = Modifier.widthIn(max = 300.dp)) {
|
||||
Text(
|
||||
text = "< $shortSender",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder)
|
||||
)
|
||||
Text(
|
||||
text = " ${msg.body}",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreen)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
53
app/src/main/java/com/ltadeu6/matrix/chat/ChatViewModel.kt
Normal file
53
app/src/main/java/com/ltadeu6/matrix/chat/ChatViewModel.kt
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package com.ltadeu6.matrix.chat
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.ltadeu6.matrix.matrix.MatrixSession
|
||||
import com.ltadeu6.matrix.matrix.Message
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ChatViewModel(
|
||||
private val session: MatrixSession,
|
||||
val roomId: String
|
||||
) : ViewModel() {
|
||||
|
||||
val messages: StateFlow<List<Message>> = session.syncState
|
||||
.map { it.messages[roomId] ?: emptyList() }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val roomName: StateFlow<String> = session.syncState
|
||||
.map { it.rooms[roomId]?.name ?: roomId }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, roomId)
|
||||
|
||||
private val _inputText = MutableStateFlow("")
|
||||
val inputText: StateFlow<String> = _inputText.asStateFlow()
|
||||
|
||||
fun setInput(text: String) {
|
||||
_inputText.value = text
|
||||
}
|
||||
|
||||
fun sendMessage() {
|
||||
val text = _inputText.value.trim()
|
||||
if (text.isBlank()) return
|
||||
_inputText.value = ""
|
||||
viewModelScope.launch {
|
||||
session.sendMessage(roomId, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ChatViewModelFactory(
|
||||
private val session: MatrixSession,
|
||||
private val roomId: String
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
ChatViewModel(session, roomId) as T
|
||||
}
|
||||
342
app/src/main/java/com/ltadeu6/matrix/matrix/CryptoService.kt
Normal file
342
app/src/main/java/com/ltadeu6/matrix/matrix/CryptoService.kt
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
package com.ltadeu6.matrix.matrix
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.matrix.olm.OlmAccount
|
||||
import org.matrix.olm.OlmInboundGroupSession
|
||||
import org.matrix.olm.OlmManager
|
||||
import org.matrix.olm.OlmMessage
|
||||
import org.matrix.olm.OlmOutboundGroupSession
|
||||
import org.matrix.olm.OlmSession
|
||||
|
||||
/**
|
||||
* Encapsulates all Olm/Megolm cryptographic state for the local device.
|
||||
*
|
||||
* - [buildKeysUploadPayload] + [onKeysUploaded] — called once after login to register keys.
|
||||
* - [processToDeviceEvents] — called every sync to receive incoming room keys.
|
||||
* - [decryptRoomEvent] / [encryptRoomEvent] — decrypt/encrypt room traffic.
|
||||
* - [encryptOlmPayload] — encrypt room-key to-device messages for other devices.
|
||||
*/
|
||||
class CryptoService(
|
||||
context: Context,
|
||||
private val userId: String,
|
||||
private val deviceId: String
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences("olm_crypto", Context.MODE_PRIVATE)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val olmAccount: OlmAccount
|
||||
private val inboundSessions = mutableMapOf<String, OlmInboundGroupSession>()
|
||||
private val outboundSessions = mutableMapOf<String, OlmOutboundGroupSession>()
|
||||
private val olmSessions = mutableMapOf<String, MutableList<OlmSession>>()
|
||||
|
||||
init {
|
||||
OlmManager() // loads the native libolm .so
|
||||
olmAccount = restoreAccount() ?: OlmAccount().also { persistAccount(it) }
|
||||
restoreInboundSessions()
|
||||
}
|
||||
|
||||
val curve25519Key: String
|
||||
get() = try { olmAccount.identityKeys()[OlmAccount.JSON_KEY_IDENTITY_KEY] ?: "" } catch (_: Exception) { "" }
|
||||
|
||||
val ed25519Key: String
|
||||
get() = try { olmAccount.identityKeys()[OlmAccount.JSON_KEY_FINGER_PRINT_KEY] ?: "" } catch (_: Exception) { "" }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Key upload
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
fun buildKeysUploadPayload(): JsonObject {
|
||||
olmAccount.generateOneTimeKeys(MAX_ONE_TIME_KEYS)
|
||||
val otks = olmAccount.oneTimeKeys() // Map<String, Map<String, String>>
|
||||
|
||||
val deviceKeysBody = buildJsonObject {
|
||||
put("user_id", userId)
|
||||
put("device_id", deviceId)
|
||||
put("algorithms", buildJsonArray {
|
||||
add(JsonPrimitive("m.olm.v1.curve25519-aes-sha2"))
|
||||
add(JsonPrimitive("m.megolm.v1.aes-sha2"))
|
||||
})
|
||||
put("keys", buildJsonObject {
|
||||
put("curve25519:$deviceId", curve25519Key)
|
||||
put("ed25519:$deviceId", ed25519Key)
|
||||
})
|
||||
}
|
||||
val deviceKeysSig = olmAccount.signMessage(deviceKeysBody.toCanonicalJson())
|
||||
val signedDeviceKeys = buildJsonObject {
|
||||
deviceKeysBody.entries.forEach { (k, v) -> put(k, v) }
|
||||
put("signatures", buildJsonObject {
|
||||
put(userId, buildJsonObject { put("ed25519:$deviceId", deviceKeysSig) })
|
||||
})
|
||||
}
|
||||
|
||||
val curve25519Otks: Map<String, String> = otks["curve25519"] ?: emptyMap()
|
||||
val oneTimeKeys = buildJsonObject {
|
||||
curve25519Otks.forEach { (keyId, keyValue) ->
|
||||
val keyObj = buildJsonObject { put("key", keyValue) }
|
||||
val sig = olmAccount.signMessage(keyObj.toCanonicalJson())
|
||||
put("signed_curve25519:$keyId", buildJsonObject {
|
||||
put("key", keyValue)
|
||||
put("signatures", buildJsonObject {
|
||||
put(userId, buildJsonObject { put("ed25519:$deviceId", sig) })
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return buildJsonObject {
|
||||
put("device_keys", signedDeviceKeys)
|
||||
put("one_time_keys", oneTimeKeys)
|
||||
}
|
||||
}
|
||||
|
||||
/** Call after a successful `/keys/upload` to mark one-time keys as consumed. */
|
||||
fun onKeysUploaded() {
|
||||
olmAccount.markOneTimeKeysAsPublished()
|
||||
persistAccount(olmAccount)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// To-device event processing (receive incoming room keys)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
fun processToDeviceEvents(events: List<JsonObject>) {
|
||||
for (event in events) {
|
||||
try { processToDeviceEvent(event) } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processToDeviceEvent(event: JsonObject) {
|
||||
val type = (event["type"] as? JsonPrimitive)?.content ?: return
|
||||
val content = event["content"]?.jsonObject ?: return
|
||||
|
||||
when (type) {
|
||||
"m.room.encrypted" -> {
|
||||
val plaintext = decryptOlmToDevice(content) ?: return
|
||||
val inner = json.parseToJsonElement(plaintext).jsonObject
|
||||
val innerType = (inner["type"] as? JsonPrimitive)?.content
|
||||
val innerContent = inner["content"]?.jsonObject ?: return
|
||||
when (innerType) {
|
||||
"m.room_key" -> handleRoomKey(innerContent)
|
||||
"m.forwarded_room_key" -> handleForwardedRoomKey(innerContent)
|
||||
}
|
||||
}
|
||||
"m.room_key" -> handleRoomKey(content)
|
||||
"m.forwarded_room_key" -> handleForwardedRoomKey(content)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decryptOlmToDevice(content: JsonObject): String? {
|
||||
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.olm.v1.curve25519-aes-sha2") return null
|
||||
val senderKey = (content["sender_key"] as? JsonPrimitive)?.content ?: return null
|
||||
val ciphertext = content["ciphertext"]?.jsonObject ?: return null
|
||||
val ourMsg = ciphertext[curve25519Key]?.jsonObject ?: return null
|
||||
val type = (ourMsg["type"] as? JsonPrimitive)?.content?.toIntOrNull() ?: return null
|
||||
val body = (ourMsg["body"] as? JsonPrimitive)?.content ?: return null
|
||||
|
||||
// Try existing sessions first (type 1 — regular message)
|
||||
if (type == OlmMessage.MESSAGE_TYPE_MESSAGE) {
|
||||
olmSessions[senderKey]?.forEach { session ->
|
||||
return try {
|
||||
val olmMsg = OlmMessage().apply { mCipherText = body; mType = type.toLong() }
|
||||
session.decryptMessage(olmMsg)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-key message (type 0) — create a new inbound Olm session
|
||||
if (type == OlmMessage.MESSAGE_TYPE_PRE_KEY) {
|
||||
return try {
|
||||
val session = OlmSession()
|
||||
session.initInboundSessionFrom(olmAccount, senderKey, body)
|
||||
olmAccount.removeOneTimeKeys(session)
|
||||
persistAccount(olmAccount)
|
||||
olmSessions.getOrPut(senderKey) { mutableListOf() }.add(session)
|
||||
val olmMsg = OlmMessage().apply { mCipherText = body; mType = type.toLong() }
|
||||
session.decryptMessage(olmMsg)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun handleRoomKey(content: JsonObject) {
|
||||
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return
|
||||
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return
|
||||
val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return
|
||||
if (sessionId in inboundSessions) return
|
||||
try {
|
||||
val session = OlmInboundGroupSession(sessionKey)
|
||||
inboundSessions[sessionId] = session
|
||||
persistInboundSession(sessionId, session)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles `m.forwarded_room_key` — a key shared by another of our own devices in response
|
||||
* to an `m.room_key_request`. Uses [OlmInboundGroupSession.importSession] because the
|
||||
* forwarded key is in exported (message-index-aware) format.
|
||||
*/
|
||||
private fun handleForwardedRoomKey(content: JsonObject) {
|
||||
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return
|
||||
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return
|
||||
val sessionKey = (content["session_key"] as? JsonPrimitive)?.content ?: return
|
||||
if (sessionId in inboundSessions) return
|
||||
try {
|
||||
val session = OlmInboundGroupSession.importSession(sessionKey)
|
||||
inboundSessions[sessionId] = session
|
||||
persistInboundSession(sessionId, session)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Room message decryption / encryption
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
fun decryptRoomEvent(content: JsonObject): String? {
|
||||
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null
|
||||
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null
|
||||
val ciphertext = (content["ciphertext"] as? JsonPrimitive)?.content ?: return null
|
||||
val session = inboundSessions[sessionId] ?: return null
|
||||
return try {
|
||||
val result = session.decryptMessage(ciphertext)
|
||||
val decrypted = json.parseToJsonElement(result.mDecryptedMessage).jsonObject
|
||||
(decrypted["content"]?.jsonObject?.get("body") as? JsonPrimitive)?.content
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun encryptRoomEvent(roomId: String, plaintext: String): JsonObject? {
|
||||
val session = outboundSessions[roomId] ?: return null
|
||||
return try {
|
||||
buildJsonObject {
|
||||
put("algorithm", "m.megolm.v1.aes-sha2")
|
||||
put("sender_key", curve25519Key)
|
||||
put("session_id", session.sessionIdentifier())
|
||||
put("ciphertext", session.encryptMessage(plaintext))
|
||||
put("device_id", deviceId)
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates the outbound Megolm session for [roomId].
|
||||
* Returns `(sessionId, sessionKey)` to be shared with room members, or null on failure.
|
||||
*/
|
||||
fun initOutboundSession(roomId: String): Pair<String, String>? {
|
||||
val session = outboundSessions.getOrPut(roomId) {
|
||||
OlmOutboundGroupSession().also { persistOutboundSession(roomId, it) }
|
||||
}
|
||||
return try {
|
||||
val sessionId = session.sessionIdentifier()
|
||||
val sessionKey = session.sessionKey()
|
||||
if (sessionId !in inboundSessions) {
|
||||
runCatching {
|
||||
val inbound = OlmInboundGroupSession(sessionKey)
|
||||
inboundSessions[sessionId] = inbound
|
||||
persistInboundSession(sessionId, inbound)
|
||||
}
|
||||
}
|
||||
Pair(sessionId, sessionKey)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts [payload] for [theirCurve25519] using a new outbound Olm session seeded
|
||||
* with [theirOneTimeKey] (from `/keys/claim`).
|
||||
* Returns the `ciphertext` object to embed in an `m.olm.v1.curve25519-aes-sha2` event.
|
||||
*/
|
||||
fun encryptOlmPayload(
|
||||
payload: String,
|
||||
theirCurve25519: String,
|
||||
theirOneTimeKey: String
|
||||
): JsonObject? {
|
||||
return try {
|
||||
val session = OlmSession()
|
||||
session.initOutboundSession(olmAccount, theirCurve25519, theirOneTimeKey)
|
||||
persistAccount(olmAccount)
|
||||
olmSessions.getOrPut(theirCurve25519) { mutableListOf() }.add(session)
|
||||
val msg = session.encryptMessage(payload)
|
||||
buildJsonObject {
|
||||
put(theirCurve25519, buildJsonObject {
|
||||
put("type", msg.mType)
|
||||
put("body", msg.mCipherText)
|
||||
})
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Persistence (pickle/unpickle for Olm objects; export/import for Megolm)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun persistAccount(account: OlmAccount) {
|
||||
val pickled = account.pickle(PICKLE_KEY, StringBuffer())
|
||||
prefs.edit().putString(KEY_ACCOUNT, Base64.encodeToString(pickled, Base64.NO_WRAP)).apply()
|
||||
}
|
||||
|
||||
private fun restoreAccount(): OlmAccount? {
|
||||
val stored = prefs.getString(KEY_ACCOUNT, null) ?: return null
|
||||
return try {
|
||||
OlmAccount().also { it.unpickle(PICKLE_KEY, Base64.decode(stored, Base64.NO_WRAP)) }
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
private fun persistInboundSession(sessionId: String, session: OlmInboundGroupSession) {
|
||||
try {
|
||||
val exported = session.export(session.getFirstKnownIndex())
|
||||
prefs.edit().putString("inbound_$sessionId", exported).apply()
|
||||
val ids = prefs.getStringSet(KEY_INBOUND_IDS, emptySet())!!.toMutableSet()
|
||||
ids.add(sessionId)
|
||||
prefs.edit().putStringSet(KEY_INBOUND_IDS, ids).apply()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun restoreInboundSessions() {
|
||||
val ids = prefs.getStringSet(KEY_INBOUND_IDS, emptySet()) ?: return
|
||||
for (id in ids) {
|
||||
val exported = prefs.getString("inbound_$id", null) ?: continue
|
||||
try { inboundSessions[id] = OlmInboundGroupSession.importSession(exported) } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistOutboundSession(roomId: String, session: OlmOutboundGroupSession) {
|
||||
try {
|
||||
val pickled = session.pickle(PICKLE_KEY, StringBuffer())
|
||||
prefs.edit().putString("outbound_$roomId", Base64.encodeToString(pickled, Base64.NO_WRAP)).apply()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_ONE_TIME_KEYS = 10
|
||||
private const val KEY_ACCOUNT = "olm_account"
|
||||
private const val KEY_INBOUND_IDS = "inbound_ids"
|
||||
private val PICKLE_KEY = "matrix_olm_pickle".toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canonical JSON — required by Matrix spec for computing ed25519 signatures.
|
||||
// Keys must be sorted lexicographically; no extra whitespace.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
internal fun JsonElement.toCanonicalJson(): String = when (this) {
|
||||
is JsonObject -> entries
|
||||
.sortedBy { it.key }
|
||||
.joinToString(",", "{", "}") { (k, v) ->
|
||||
"\"${k.escapeJsonString()}\":${v.toCanonicalJson()}"
|
||||
}
|
||||
is JsonArray -> joinToString(",", "[", "]") { it.toCanonicalJson() }
|
||||
is JsonPrimitive -> toString() // already valid JSON literal
|
||||
}
|
||||
|
||||
private fun String.escapeJsonString(): String =
|
||||
replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
||||
111
app/src/main/java/com/ltadeu6/matrix/matrix/MatrixApi.kt
Normal file
111
app/src/main/java/com/ltadeu6/matrix/matrix/MatrixApi.kt
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package com.ltadeu6.matrix.matrix
|
||||
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface MatrixApi {
|
||||
|
||||
@GET("/_matrix/client/v3/login")
|
||||
suspend fun getLoginFlows(): LoginFlowsResponse
|
||||
|
||||
@POST("/_matrix/client/v3/login")
|
||||
suspend fun loginWithToken(@Body request: LoginTokenRequest): LoginResponse
|
||||
|
||||
@POST("/_matrix/client/v3/login")
|
||||
suspend fun loginWithPassword(@Body request: LoginPasswordRequest): LoginResponse
|
||||
|
||||
@GET("/_matrix/client/v3/sync")
|
||||
suspend fun sync(
|
||||
@Header("Authorization") auth: String,
|
||||
@Query("since") since: String? = null,
|
||||
@Query("timeout") timeout: Int = 30_000,
|
||||
@Query("filter") filter: String? = null
|
||||
): SyncResponse
|
||||
|
||||
@GET("/_matrix/client/v3/rooms/{roomId}/state/m.room.name")
|
||||
suspend fun getRoomName(
|
||||
@Header("Authorization") auth: String,
|
||||
@Path("roomId") roomId: String
|
||||
): RoomNameEvent
|
||||
|
||||
@GET("/_matrix/client/v3/rooms/{roomId}/state/m.room.canonical_alias")
|
||||
suspend fun getRoomAlias(
|
||||
@Header("Authorization") auth: String,
|
||||
@Path("roomId") roomId: String
|
||||
): RoomAliasEvent
|
||||
|
||||
@PUT("/_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}")
|
||||
suspend fun sendMessage(
|
||||
@Header("Authorization") auth: String,
|
||||
@Path("roomId") roomId: String,
|
||||
@Path("txnId") txnId: String,
|
||||
@Body message: SendMessageRequest
|
||||
): SendMessageResponse
|
||||
|
||||
@PUT("/_matrix/client/v3/rooms/{roomId}/send/{eventType}/{txnId}")
|
||||
suspend fun sendEvent(
|
||||
@Header("Authorization") auth: String,
|
||||
@Path("roomId") roomId: String,
|
||||
@Path("eventType") eventType: String,
|
||||
@Path("txnId") txnId: String,
|
||||
@Body content: JsonObject
|
||||
): SendMessageResponse
|
||||
|
||||
@POST("/_matrix/client/v3/createRoom")
|
||||
suspend fun createRoom(
|
||||
@Header("Authorization") auth: String,
|
||||
@Body request: CreateRoomRequest
|
||||
): CreateRoomResponse
|
||||
|
||||
@POST("/_matrix/client/v3/rooms/{roomId}/leave")
|
||||
suspend fun leaveRoom(
|
||||
@Header("Authorization") auth: String,
|
||||
@Path("roomId") roomId: String,
|
||||
@Body body: JsonObject = JsonObject(emptyMap())
|
||||
): JsonObject
|
||||
|
||||
@POST("/_matrix/client/v3/rooms/{roomId}/forget")
|
||||
suspend fun forgetRoom(
|
||||
@Header("Authorization") auth: String,
|
||||
@Path("roomId") roomId: String,
|
||||
@Body body: JsonObject = JsonObject(emptyMap())
|
||||
): JsonObject
|
||||
|
||||
@POST("/_matrix/client/v3/keys/upload")
|
||||
suspend fun uploadKeys(
|
||||
@Header("Authorization") auth: String,
|
||||
@Body request: JsonObject
|
||||
): JsonObject
|
||||
|
||||
@POST("/_matrix/client/v3/keys/query")
|
||||
suspend fun queryKeys(
|
||||
@Header("Authorization") auth: String,
|
||||
@Body request: JsonObject
|
||||
): JsonObject
|
||||
|
||||
@POST("/_matrix/client/v3/keys/claim")
|
||||
suspend fun claimKeys(
|
||||
@Header("Authorization") auth: String,
|
||||
@Body request: JsonObject
|
||||
): JsonObject
|
||||
|
||||
@GET("/_matrix/client/v3/rooms/{roomId}/joined_members")
|
||||
suspend fun getJoinedMembers(
|
||||
@Header("Authorization") auth: String,
|
||||
@Path("roomId") roomId: String
|
||||
): JoinedMembersResponse
|
||||
|
||||
@PUT("/_matrix/client/v3/sendToDevice/{eventType}/{txnId}")
|
||||
suspend fun sendToDevice(
|
||||
@Header("Authorization") auth: String,
|
||||
@Path("eventType") eventType: String,
|
||||
@Path("txnId") txnId: String,
|
||||
@Body messages: JsonObject
|
||||
): JsonObject
|
||||
}
|
||||
173
app/src/main/java/com/ltadeu6/matrix/matrix/MatrixModels.kt
Normal file
173
app/src/main/java/com/ltadeu6/matrix/matrix/MatrixModels.kt
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package com.ltadeu6.matrix.matrix
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
// --- Well-known discovery ---
|
||||
|
||||
@Serializable
|
||||
data class WellKnownClientConfig(
|
||||
@SerialName("m.homeserver") val homeserver: HomeserverConfig? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HomeserverConfig(
|
||||
@SerialName("base_url") val baseUrl: String
|
||||
)
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
@Serializable
|
||||
data class LoginTokenRequest(
|
||||
val type: String = "m.login.token",
|
||||
val token: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginPasswordRequest(
|
||||
val type: String = "m.login.password",
|
||||
val identifier: UserIdentifier,
|
||||
val password: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserIdentifier(
|
||||
val type: String = "m.id.user",
|
||||
val user: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginResponse(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
@SerialName("user_id") val userId: String,
|
||||
@SerialName("device_id") val deviceId: String,
|
||||
@SerialName("home_server") val homeServer: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginFlowsResponse(
|
||||
val flows: List<LoginFlow> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginFlow(
|
||||
val type: String
|
||||
)
|
||||
|
||||
// --- Sync ---
|
||||
|
||||
@Serializable
|
||||
data class SyncResponse(
|
||||
@SerialName("next_batch") val nextBatch: String,
|
||||
val rooms: RoomsSync? = null,
|
||||
@SerialName("to_device") val toDevice: ToDeviceSync? = null,
|
||||
@SerialName("device_lists") val deviceLists: DeviceLists? = null,
|
||||
@SerialName("device_one_time_keys_count") val deviceOneTimeKeysCount: Map<String, Int> = emptyMap(),
|
||||
@SerialName("device_unused_fallback_key_types") val deviceUnusedFallbackKeyTypes: List<String>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ToDeviceSync(
|
||||
val events: List<JsonObject> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DeviceLists(
|
||||
val changed: List<String> = emptyList(),
|
||||
val left: List<String> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RoomsSync(
|
||||
val join: Map<String, JoinedRoomSync> = emptyMap()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class JoinedRoomSync(
|
||||
val timeline: Timeline? = null,
|
||||
val state: State? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Timeline(
|
||||
val events: List<RoomEvent> = emptyList(),
|
||||
@SerialName("limited") val limited: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class State(
|
||||
val events: List<RoomEvent> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RoomEvent(
|
||||
val type: String,
|
||||
@SerialName("event_id") val eventId: String = "",
|
||||
val sender: String = "",
|
||||
@SerialName("origin_server_ts") val timestamp: Long = 0,
|
||||
val content: JsonObject? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RoomNameEvent(
|
||||
val name: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RoomAliasEvent(
|
||||
val alias: String? = null
|
||||
)
|
||||
|
||||
// --- Messaging ---
|
||||
|
||||
@Serializable
|
||||
data class SendMessageRequest(
|
||||
@SerialName("msgtype") val msgType: String = "m.text",
|
||||
val body: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SendMessageResponse(
|
||||
@SerialName("event_id") val eventId: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CreateRoomRequest(
|
||||
val invite: List<String> = emptyList(),
|
||||
@SerialName("is_direct") val isDirect: Boolean = false,
|
||||
val preset: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CreateRoomResponse(
|
||||
@SerialName("room_id") val roomId: String
|
||||
)
|
||||
|
||||
// --- Room members ---
|
||||
|
||||
@Serializable
|
||||
data class JoinedMembersResponse(
|
||||
val joined: Map<String, MemberInfo> = emptyMap()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MemberInfo(
|
||||
@SerialName("display_name") val displayName: String? = null,
|
||||
@SerialName("avatar_url") val avatarUrl: String? = null
|
||||
)
|
||||
|
||||
// --- Local UI models ---
|
||||
|
||||
data class Room(
|
||||
val id: String,
|
||||
val name: String
|
||||
)
|
||||
|
||||
data class Message(
|
||||
val eventId: String,
|
||||
val sender: String,
|
||||
val body: String,
|
||||
val timestamp: Long,
|
||||
val isOutgoing: Boolean
|
||||
)
|
||||
704
app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt
Normal file
704
app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt
Normal file
|
|
@ -0,0 +1,704 @@
|
|||
package com.ltadeu6.matrix.matrix
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
sealed class AuthState {
|
||||
object LoggedOut : AuthState()
|
||||
object Loading : AuthState()
|
||||
data class LoggedIn(val userId: String, val homeserver: String) : AuthState()
|
||||
data class Error(val message: String) : AuthState()
|
||||
}
|
||||
|
||||
data class SyncState(
|
||||
val rooms: Map<String, Room> = emptyMap(),
|
||||
val messages: Map<String, List<Message>> = emptyMap()
|
||||
)
|
||||
|
||||
class MatrixSession private constructor(private val appContext: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
appContext.getSharedPreferences("matrix_session", Context.MODE_PRIVATE)
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val txnCounter = AtomicInteger(0)
|
||||
|
||||
private val _authState = MutableStateFlow<AuthState>(AuthState.LoggedOut)
|
||||
val authState: StateFlow<AuthState> = _authState.asStateFlow()
|
||||
|
||||
private val _syncState = MutableStateFlow(SyncState())
|
||||
val syncState: StateFlow<SyncState> = _syncState.asStateFlow()
|
||||
|
||||
private val _pendingSsoToken = MutableStateFlow<String?>(null)
|
||||
val pendingSsoToken: StateFlow<String?> = _pendingSsoToken.asStateFlow()
|
||||
|
||||
private var accessToken: String? = null
|
||||
private var currentUserId: String? = null
|
||||
private var syncJob: Job? = null
|
||||
private var api: MatrixApi? = null
|
||||
private var cryptoService: CryptoService? = null
|
||||
|
||||
/**
|
||||
* SAS device verification service. Created once; userId/deviceId/cryptoService
|
||||
* are populated on login (or session restore).
|
||||
*/
|
||||
val verificationService: VerificationService = VerificationService(scope)
|
||||
|
||||
/** Room IDs where `m.room.encryption` has been observed in state. */
|
||||
private val encryptedRooms = mutableSetOf<String>()
|
||||
|
||||
/** Rooms for which we have already shared our outbound Megolm session. */
|
||||
private val sharedSessionRooms = mutableSetOf<String>()
|
||||
|
||||
/** Sessions we have already requested from our other devices (roomId+sessionId). */
|
||||
private val requestedSessions = mutableSetOf<String>()
|
||||
|
||||
/** Sessions we failed to decrypt this sync batch and need to request. */
|
||||
private data class MissingSession(val roomId: String, val senderKey: String, val sessionId: String)
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
// Compact filter: only joined rooms, last 50 timeline events, no presence/typing noise
|
||||
private val syncFilter = """
|
||||
{"room":{"timeline":{"limit":50},"state":{"lazy_load_members":true}},
|
||||
"presence":{"not_types":["*"]},"account_data":{"not_types":["*"]}}
|
||||
""".trimIndent().replace("\n", "")
|
||||
|
||||
init {
|
||||
verificationService.onSend = { toUser, toDevice, eventType, txnId, content ->
|
||||
sendVerificationToDevice(toUser, toDevice, eventType, txnId, content)
|
||||
}
|
||||
verificationService.onFetchVerificationKey = { userId, keyId, deviceId ->
|
||||
fetchVerificationKey(userId, keyId, deviceId)
|
||||
}
|
||||
verificationService.onVerified = {
|
||||
requestedSessions.clear()
|
||||
sharedSessionRooms.clear()
|
||||
startSync()
|
||||
}
|
||||
|
||||
val savedToken = prefs.getString("access_token", null)
|
||||
val savedHomeserver = prefs.getString("homeserver_url", null)
|
||||
val savedUserId = prefs.getString("user_id", null)
|
||||
val savedDeviceId = prefs.getString("device_id", null)
|
||||
if (savedToken != null && savedHomeserver != null && savedUserId != null) {
|
||||
accessToken = savedToken
|
||||
currentUserId = savedUserId
|
||||
buildApi(savedHomeserver)
|
||||
_authState.value = AuthState.LoggedIn(savedUserId, savedHomeserver)
|
||||
if (savedDeviceId != null) {
|
||||
val crypto = CryptoService(appContext, savedUserId, savedDeviceId)
|
||||
cryptoService = crypto
|
||||
verificationService.userId = savedUserId
|
||||
verificationService.deviceId = savedDeviceId
|
||||
verificationService.cryptoService = crypto
|
||||
scope.launch { uploadKeys() }
|
||||
}
|
||||
startSync()
|
||||
}
|
||||
}
|
||||
|
||||
fun handleSsoCallback(token: String) { _pendingSsoToken.value = token }
|
||||
|
||||
fun consumeSsoToken(): String? {
|
||||
val t = _pendingSsoToken.value
|
||||
_pendingSsoToken.value = null
|
||||
return t
|
||||
}
|
||||
|
||||
suspend fun loginWithToken(homeserver: String, token: String): Result<Unit> = runCatching {
|
||||
val hs = discoverHomeserver(normalizeHomeserver(homeserver))
|
||||
buildApi(hs)
|
||||
val response = api!!.loginWithToken(LoginTokenRequest(token = token))
|
||||
onLoginSuccess(response, hs)
|
||||
}
|
||||
|
||||
suspend fun loginWithPassword(homeserver: String, username: String, password: String): Result<Unit> = runCatching {
|
||||
val hs = discoverHomeserver(normalizeHomeserver(homeserver))
|
||||
buildApi(hs)
|
||||
val response = api!!.loginWithPassword(
|
||||
LoginPasswordRequest(identifier = UserIdentifier(user = username), password = password)
|
||||
)
|
||||
onLoginSuccess(response, hs)
|
||||
}
|
||||
|
||||
suspend fun checkSsoSupported(homeserver: String): String? {
|
||||
return try {
|
||||
val hs = discoverHomeserver(normalizeHomeserver(homeserver))
|
||||
buildApi(hs)
|
||||
val flows = api!!.getLoginFlows()
|
||||
val supportsSso = flows.flows.any { it.type == "m.login.sso" || it.type == "m.login.cas" }
|
||||
if (supportsSso) null else "this homeserver does not support SSO login — use password login below"
|
||||
} catch (_: Exception) {
|
||||
"could not reach homeserver — check the address"
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
syncJob?.cancel()
|
||||
prefs.edit().clear().apply()
|
||||
// Also wipe crypto state so keys are regenerated fresh on next login
|
||||
appContext.getSharedPreferences("olm_crypto", Context.MODE_PRIVATE).edit().clear().apply()
|
||||
accessToken = null
|
||||
currentUserId = null
|
||||
api = null
|
||||
cryptoService = null
|
||||
encryptedRooms.clear()
|
||||
sharedSessionRooms.clear()
|
||||
requestedSessions.clear()
|
||||
verificationService.dismiss()
|
||||
verificationService.userId = ""
|
||||
verificationService.deviceId = ""
|
||||
verificationService.cryptoService = null
|
||||
_authState.value = AuthState.LoggedOut
|
||||
_syncState.value = SyncState()
|
||||
}
|
||||
|
||||
suspend fun sendMessage(roomId: String, body: String): Result<Unit> {
|
||||
val token = accessToken ?: return Result.failure(Exception("Not logged in"))
|
||||
val matrixApi = api ?: return Result.failure(Exception("No API client"))
|
||||
return runCatching {
|
||||
val txnId = "${System.currentTimeMillis()}_${txnCounter.incrementAndGet()}"
|
||||
val crypto = cryptoService
|
||||
if (crypto != null && roomId in encryptedRooms) {
|
||||
// Share our outbound session with room members if we haven't yet
|
||||
if (roomId !in sharedSessionRooms) {
|
||||
shareGroupSession(roomId)
|
||||
sharedSessionRooms.add(roomId)
|
||||
}
|
||||
val eventPayload = buildJsonObject {
|
||||
put("room_id", roomId)
|
||||
put("type", "m.room.message")
|
||||
put("content", buildJsonObject {
|
||||
put("msgtype", "m.text")
|
||||
put("body", body)
|
||||
})
|
||||
}.toString()
|
||||
val encryptedContent = crypto.encryptRoomEvent(roomId, eventPayload)
|
||||
?: throw Exception("Encryption failed — outbound session not established")
|
||||
matrixApi.sendEvent("Bearer $token", roomId, "m.room.encrypted", txnId, encryptedContent)
|
||||
} else {
|
||||
matrixApi.sendMessage("Bearer $token", roomId, txnId, SendMessageRequest(body = body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun forgetRoom(roomId: String): Result<Unit> {
|
||||
val token = accessToken ?: return Result.failure(Exception("Not logged in"))
|
||||
val matrixApi = api ?: return Result.failure(Exception("No API client"))
|
||||
return runCatching {
|
||||
val auth = "Bearer $token"
|
||||
runCatching { matrixApi.leaveRoom(auth, roomId) }
|
||||
matrixApi.forgetRoom(auth, roomId)
|
||||
|
||||
val current = _syncState.value
|
||||
_syncState.value = current.copy(
|
||||
rooms = current.rooms - roomId,
|
||||
messages = current.messages - roomId
|
||||
)
|
||||
encryptedRooms.remove(roomId)
|
||||
sharedSessionRooms.remove(roomId)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun startDirectChat(handle: String): Result<String> {
|
||||
val token = accessToken ?: return Result.failure(Exception("Not logged in"))
|
||||
val matrixApi = api ?: return Result.failure(Exception("No API client"))
|
||||
val normalized = handle.trim().let { raw ->
|
||||
when {
|
||||
raw.isBlank() -> ""
|
||||
raw.startsWith("@") -> raw
|
||||
else -> "@$raw"
|
||||
}
|
||||
}
|
||||
if (!normalized.contains(":")) {
|
||||
return Result.failure(Exception("Handle must look like @user:server"))
|
||||
}
|
||||
return runCatching {
|
||||
val response = matrixApi.createRoom(
|
||||
auth = "Bearer $token",
|
||||
request = CreateRoomRequest(
|
||||
invite = listOf(normalized),
|
||||
isDirect = true,
|
||||
preset = "trusted_private_chat"
|
||||
)
|
||||
)
|
||||
response.roomId
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Login flow
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun onLoginSuccess(response: LoginResponse, homeserver: String) {
|
||||
accessToken = response.accessToken
|
||||
currentUserId = response.userId
|
||||
prefs.edit()
|
||||
.putString("access_token", response.accessToken)
|
||||
.putString("homeserver_url", homeserver)
|
||||
.putString("user_id", response.userId)
|
||||
.putString("device_id", response.deviceId)
|
||||
.apply()
|
||||
_authState.value = AuthState.LoggedIn(response.userId, homeserver)
|
||||
val crypto = CryptoService(appContext, response.userId, response.deviceId)
|
||||
cryptoService = crypto
|
||||
verificationService.userId = response.userId
|
||||
verificationService.deviceId = response.deviceId
|
||||
verificationService.cryptoService = crypto
|
||||
scope.launch { uploadKeys() }
|
||||
startSync()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Key upload (called once after login)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private suspend fun uploadKeys() {
|
||||
val crypto = cryptoService ?: return
|
||||
val token = accessToken ?: return
|
||||
val matrixApi = api ?: return
|
||||
runCatching {
|
||||
val payload = crypto.buildKeysUploadPayload()
|
||||
matrixApi.uploadKeys("Bearer $token", payload)
|
||||
crypto.onKeysUploaded()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends `m.room_key_request` to-device events to all of our own devices (`*`) for every
|
||||
* session we failed to decrypt. Deduplicates by `roomId+sessionId` so we only ask once.
|
||||
*/
|
||||
private suspend fun sendKeyRequests(missing: List<MissingSession>, auth: String, matrixApi: MatrixApi) {
|
||||
val uid = currentUserId ?: return
|
||||
val devId = prefs.getString("device_id", "") ?: return
|
||||
|
||||
val toRequest = missing.filter { "${it.roomId}:${it.sessionId}" !in requestedSessions }
|
||||
if (toRequest.isEmpty()) return
|
||||
|
||||
// Build one sendToDevice call with all requests
|
||||
val messages = buildJsonObject {
|
||||
put(uid, buildJsonObject {
|
||||
put("*", buildJsonObject {
|
||||
// Matrix allows only one content object per device; batch as separate calls
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (ms in toRequest) {
|
||||
val key = "${ms.roomId}:${ms.sessionId}"
|
||||
if (key in requestedSessions) continue
|
||||
requestedSessions.add(key)
|
||||
|
||||
val requestId = "${System.currentTimeMillis()}_${ms.sessionId.take(8)}"
|
||||
val body = buildJsonObject {
|
||||
put("messages", buildJsonObject {
|
||||
put(uid, buildJsonObject {
|
||||
put("*", buildJsonObject {
|
||||
put("action", "request")
|
||||
put("body", buildJsonObject {
|
||||
put("algorithm", "m.megolm.v1.aes-sha2")
|
||||
put("room_id", ms.roomId)
|
||||
put("sender_key", ms.senderKey)
|
||||
put("session_id", ms.sessionId)
|
||||
})
|
||||
put("request_id", requestId)
|
||||
put("requesting_device_id", devId)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
runCatching { matrixApi.sendToDevice(auth, "m.room_key_request", requestId, body) }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Outbound Megolm session sharing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Shares the outbound Megolm session for [roomId] with every joined member device.
|
||||
* Steps: query joined members → query device keys → claim OTKs → send encrypted m.room_key.
|
||||
*/
|
||||
private suspend fun shareGroupSession(roomId: String) {
|
||||
val crypto = cryptoService ?: return
|
||||
val token = accessToken ?: return
|
||||
val matrixApi = api ?: return
|
||||
val auth = "Bearer $token"
|
||||
|
||||
val (sessionId, sessionKey) = crypto.initOutboundSession(roomId) ?: return
|
||||
|
||||
// 1. Joined members
|
||||
val members = runCatching { matrixApi.getJoinedMembers(auth, roomId) }
|
||||
.getOrNull()?.joined?.keys ?: return
|
||||
|
||||
// 2. Device keys for all members
|
||||
val keysQueryBody = buildJsonObject {
|
||||
put("device_keys", buildJsonObject {
|
||||
members.forEach { uid ->
|
||||
put(uid, kotlinx.serialization.json.JsonArray(emptyList()))
|
||||
}
|
||||
})
|
||||
}
|
||||
val keysResp = runCatching { matrixApi.queryKeys(auth, keysQueryBody) }
|
||||
.getOrNull() ?: return
|
||||
val deviceKeys = keysResp["device_keys"]?.jsonObject ?: return
|
||||
|
||||
// Collect devices we need to send to, and build the claim request
|
||||
data class DevInfo(val userId: String, val devId: String, val curve25519: String, val ed25519: String)
|
||||
val devices = mutableListOf<DevInfo>()
|
||||
val claimMap = mutableMapOf<String, MutableMap<String, String>>()
|
||||
|
||||
deviceKeys.forEach { (uid, devsJson) ->
|
||||
devsJson.jsonObject.forEach { (devId, devInfoJson) ->
|
||||
val keys = devInfoJson.jsonObject["keys"]?.jsonObject ?: return@forEach
|
||||
val curve = (keys["curve25519:$devId"] as? JsonPrimitive)?.content ?: return@forEach
|
||||
val ed = (keys["ed25519:$devId"] as? JsonPrimitive)?.content ?: return@forEach
|
||||
if (curve == crypto.curve25519Key) return@forEach // skip own device
|
||||
devices.add(DevInfo(uid, devId, curve, ed))
|
||||
claimMap.getOrPut(uid) { mutableMapOf() }[devId] = "signed_curve25519"
|
||||
}
|
||||
}
|
||||
if (devices.isEmpty()) return
|
||||
|
||||
// 3. Claim one-time keys
|
||||
val claimBody = buildJsonObject {
|
||||
put("one_time_keys", buildJsonObject {
|
||||
claimMap.forEach { (uid, devs) ->
|
||||
put(uid, buildJsonObject { devs.forEach { (d, alg) -> put(d, alg) } })
|
||||
}
|
||||
})
|
||||
}
|
||||
val claimResp = runCatching { matrixApi.claimKeys(auth, claimBody) }
|
||||
.getOrNull() ?: return
|
||||
val otks = claimResp["one_time_keys"]?.jsonObject ?: return
|
||||
|
||||
// 4. Build per-device encrypted m.room_key and send via sendToDevice
|
||||
val toDeviceMessages = mutableMapOf<String, MutableMap<String, JsonObject>>()
|
||||
for (dev in devices) {
|
||||
val otkKey = otks[dev.userId]?.jsonObject
|
||||
?.get(dev.devId)?.jsonObject
|
||||
?.values?.firstOrNull()?.jsonObject
|
||||
?.let { (it["key"] as? JsonPrimitive)?.content } ?: continue
|
||||
|
||||
val payload = buildJsonObject {
|
||||
put("type", "m.room_key")
|
||||
put("content", buildJsonObject {
|
||||
put("algorithm", "m.megolm.v1.aes-sha2")
|
||||
put("room_id", roomId)
|
||||
put("session_id", sessionId)
|
||||
put("session_key", sessionKey)
|
||||
})
|
||||
put("sender", currentUserId ?: "")
|
||||
put("sender_device", prefs.getString("device_id", "") ?: "")
|
||||
put("keys", buildJsonObject { put("ed25519", crypto.ed25519Key) })
|
||||
put("recipient", dev.userId)
|
||||
put("recipient_keys", buildJsonObject { put("ed25519", dev.ed25519) })
|
||||
}
|
||||
|
||||
val ciphertext = crypto.encryptOlmPayload(payload.toString(), dev.curve25519, otkKey)
|
||||
?: continue
|
||||
|
||||
val encContent = buildJsonObject {
|
||||
put("algorithm", "m.olm.v1.curve25519-aes-sha2")
|
||||
put("sender_key", crypto.curve25519Key)
|
||||
put("ciphertext", ciphertext)
|
||||
}
|
||||
toDeviceMessages.getOrPut(dev.userId) { mutableMapOf() }[dev.devId] = encContent
|
||||
}
|
||||
if (toDeviceMessages.isEmpty()) return
|
||||
|
||||
val toDeviceBody = buildJsonObject {
|
||||
put("messages", buildJsonObject {
|
||||
toDeviceMessages.forEach { (uid, devs) ->
|
||||
put(uid, buildJsonObject { devs.forEach { (d, c) -> put(d, c) } })
|
||||
}
|
||||
})
|
||||
}
|
||||
val txnId = "${System.currentTimeMillis()}_roomkey"
|
||||
runCatching { matrixApi.sendToDevice(auth, "m.room.encrypted", txnId, toDeviceBody) }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sync loop
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun startSync() {
|
||||
syncJob?.cancel()
|
||||
syncJob = scope.launch {
|
||||
var since: String? = null
|
||||
var isFirstSync = true
|
||||
while (isActive) {
|
||||
val token = accessToken ?: break
|
||||
val matrixApi = api ?: break
|
||||
try {
|
||||
val response = matrixApi.sync(
|
||||
auth = "Bearer $token",
|
||||
since = since,
|
||||
timeout = if (since == null) 0 else 30_000,
|
||||
filter = syncFilter
|
||||
)
|
||||
since = response.nextBatch
|
||||
|
||||
// Feed to-device events so incoming room keys are available before decryption
|
||||
response.toDevice?.events?.let { events ->
|
||||
cryptoService?.processToDeviceEvents(events)
|
||||
events.forEach { event ->
|
||||
val type = (event["type"] as? JsonPrimitive)?.content ?: return@forEach
|
||||
if (type.startsWith("m.key.verification.")) {
|
||||
verificationService.processToDeviceEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val missing = processSyncResponse(response)
|
||||
|
||||
// Request keys from our other devices for any event we couldn't decrypt
|
||||
if (missing.isNotEmpty()) {
|
||||
sendKeyRequests(missing, "Bearer $token", matrixApi)
|
||||
}
|
||||
|
||||
if (isFirstSync) {
|
||||
isFirstSync = false
|
||||
fetchMissingRoomNames(token, matrixApi)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
delay(5_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchMissingRoomNames(token: String, matrixApi: MatrixApi) {
|
||||
val current = _syncState.value
|
||||
val updates = mutableMapOf<String, Room>()
|
||||
current.rooms.forEach { (roomId, room) ->
|
||||
if (room.name == roomId) {
|
||||
val name = runCatching { matrixApi.getRoomName("Bearer $token", roomId).name }
|
||||
.getOrNull()?.takeIf { it.isNotBlank() }
|
||||
?: runCatching { matrixApi.getRoomAlias("Bearer $token", roomId).alias }
|
||||
.getOrNull()?.takeIf { it.isNotBlank() }
|
||||
if (name != null) updates[roomId] = room.copy(name = name)
|
||||
}
|
||||
}
|
||||
if (updates.isNotEmpty()) {
|
||||
_syncState.value = _syncState.value.copy(rooms = _syncState.value.rooms + updates)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processSyncResponse(response: SyncResponse): List<MissingSession> {
|
||||
val current = _syncState.value
|
||||
val rooms = current.rooms.toMutableMap()
|
||||
val messages = current.messages.toMutableMap()
|
||||
val missingSessions = mutableListOf<MissingSession>()
|
||||
|
||||
response.rooms?.join?.forEach { (roomId, roomData) ->
|
||||
val allStateEvents = (roomData.state?.events ?: emptyList()) +
|
||||
(roomData.timeline?.events?.filter {
|
||||
it.type == "m.room.name" || it.type == "m.room.canonical_alias"
|
||||
} ?: emptyList())
|
||||
|
||||
val nameEvent = allStateEvents.lastOrNull { it.type == "m.room.name" }
|
||||
val aliasEvent = allStateEvents.lastOrNull { it.type == "m.room.canonical_alias" }
|
||||
val resolvedName = (nameEvent?.content?.get("name") as? JsonPrimitive)?.content
|
||||
?: (aliasEvent?.content?.get("alias") as? JsonPrimitive)?.content
|
||||
?: rooms[roomId]?.name
|
||||
?: roomId
|
||||
|
||||
rooms[roomId] = Room(id = roomId, name = resolvedName)
|
||||
|
||||
// Track encryption state
|
||||
val roomIsEncrypted = (roomData.state?.events ?: emptyList())
|
||||
.any { it.type == "m.room.encryption" }
|
||||
if (roomIsEncrypted) encryptedRooms.add(roomId)
|
||||
|
||||
val newMsgs = roomData.timeline?.events
|
||||
?.filter { it.type == "m.room.message" || it.type == "m.room.encrypted" }
|
||||
?.mapNotNull { event ->
|
||||
val body = when (event.type) {
|
||||
"m.room.message" ->
|
||||
(event.content?.get("body") as? JsonPrimitive)?.content
|
||||
?: return@mapNotNull null
|
||||
"m.room.encrypted" -> tryDecrypt(event, roomId, missingSessions)
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
Message(
|
||||
eventId = event.eventId,
|
||||
sender = event.sender,
|
||||
body = body,
|
||||
timestamp = event.timestamp,
|
||||
isOutgoing = event.sender == currentUserId
|
||||
)
|
||||
} ?: emptyList()
|
||||
|
||||
if (newMsgs.isNotEmpty()) {
|
||||
val existing = messages[roomId] ?: emptyList()
|
||||
val existingIds = existing.map { it.eventId }.toSet()
|
||||
val merged = existing + newMsgs.filter { it.eventId !in existingIds }
|
||||
messages[roomId] = merged.sortedBy { it.timestamp }
|
||||
}
|
||||
}
|
||||
|
||||
_syncState.value = SyncState(rooms = rooms, messages = messages)
|
||||
return missingSessions
|
||||
}
|
||||
|
||||
private fun tryDecrypt(event: RoomEvent, roomId: String, missingSessions: MutableList<MissingSession>): String {
|
||||
val content = event.content ?: return "[encrypted message]"
|
||||
val result = cryptoService?.decryptRoomEvent(content)
|
||||
if (result == null) {
|
||||
val sessionId = (content["session_id"] as? JsonPrimitive)?.content
|
||||
val senderKey = (content["sender_key"] as? JsonPrimitive)?.content
|
||||
if (sessionId != null && senderKey != null) {
|
||||
missingSessions.add(MissingSession(roomId, senderKey, sessionId))
|
||||
}
|
||||
}
|
||||
return result ?: "[encrypted message]"
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Verification to-device send helper
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private suspend fun sendVerificationToDevice(
|
||||
toUser: String,
|
||||
toDevice: String,
|
||||
eventType: String,
|
||||
txnId: String,
|
||||
content: JsonObject
|
||||
) {
|
||||
val token = accessToken ?: return
|
||||
val matrixApi = api ?: return
|
||||
val body = buildJsonObject {
|
||||
put("messages", buildJsonObject {
|
||||
put(toUser, buildJsonObject { put(toDevice, content) })
|
||||
})
|
||||
}
|
||||
runCatching { matrixApi.sendToDevice("Bearer $token", eventType, txnId, body) }
|
||||
}
|
||||
|
||||
private suspend fun fetchVerificationKey(userId: String, keyId: String, deviceId: String?): String? {
|
||||
val token = accessToken ?: return null
|
||||
val matrixApi = api ?: return null
|
||||
val request = buildJsonObject {
|
||||
put("device_keys", buildJsonObject {
|
||||
if (deviceId != null) {
|
||||
put(userId, JsonArray(listOf(JsonPrimitive(deviceId))))
|
||||
} else {
|
||||
put(userId, JsonArray(emptyList()))
|
||||
}
|
||||
})
|
||||
}
|
||||
val response = runCatching { matrixApi.queryKeys("Bearer $token", request) }.getOrNull() ?: return null
|
||||
val deviceKey = deviceId?.let { devId ->
|
||||
response["device_keys"]?.jsonObject
|
||||
?.get(userId)?.jsonObject
|
||||
?.get(devId)?.jsonObject
|
||||
?.get("keys")?.jsonObject
|
||||
?.get(keyId)
|
||||
}
|
||||
if (deviceKey is JsonPrimitive) return deviceKey.content
|
||||
|
||||
val masterKey = response["master_keys"]?.jsonObject
|
||||
?.get(userId)?.jsonObject
|
||||
?.get("keys")?.jsonObject
|
||||
?.get(keyId)
|
||||
if (masterKey is JsonPrimitive) return masterKey.content
|
||||
|
||||
val selfSigningKey = response["self_signing_keys"]?.jsonObject
|
||||
?.get(userId)?.jsonObject
|
||||
?.get("keys")?.jsonObject
|
||||
?.get(keyId)
|
||||
if (selfSigningKey is JsonPrimitive) return selfSigningKey.content
|
||||
|
||||
val userSigningKey = response["user_signing_keys"]?.jsonObject
|
||||
?.get(userId)?.jsonObject
|
||||
?.get("keys")?.jsonObject
|
||||
?.get(keyId)
|
||||
if (userSigningKey is JsonPrimitive) return userSigningKey.content
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Homeserver discovery / API setup
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private suspend fun discoverHomeserver(baseUrl: String): String {
|
||||
return try {
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
val url = "${baseUrl.trimEnd('/')}/.well-known/matrix/client"
|
||||
val response = client.newCall(Request.Builder().url(url).build()).execute()
|
||||
if (!response.isSuccessful) return baseUrl
|
||||
val body = response.body?.string() ?: return baseUrl
|
||||
val parsed = json.decodeFromString<WellKnownClientConfig>(body)
|
||||
parsed.homeserver?.baseUrl?.trimEnd('/')?.plus("/") ?: baseUrl
|
||||
} catch (_: Exception) { baseUrl }
|
||||
}
|
||||
|
||||
private fun buildApi(homeserver: String) {
|
||||
val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.addInterceptor(logging)
|
||||
.build()
|
||||
api = Retrofit.Builder()
|
||||
.baseUrl(homeserver)
|
||||
.client(client)
|
||||
.addConverterFactory(json.asConverterFactory("application/json; charset=UTF8".toMediaType()))
|
||||
.build()
|
||||
.create(MatrixApi::class.java)
|
||||
}
|
||||
|
||||
private fun normalizeHomeserver(url: String): String {
|
||||
var hs = url.trim()
|
||||
if (!hs.startsWith("http")) hs = "https://$hs"
|
||||
if (!hs.endsWith("/")) hs += "/"
|
||||
return hs
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile private var instance: MatrixSession? = null
|
||||
|
||||
fun getInstance(context: Context): MatrixSession =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: MatrixSession(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,465 @@
|
|||
package com.ltadeu6.matrix.matrix
|
||||
|
||||
import android.util.Base64
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.matrix.olm.OlmSAS
|
||||
import java.security.MessageDigest
|
||||
|
||||
data class SasEmoji(val emoji: String, val name: String)
|
||||
|
||||
sealed class VerificationState {
|
||||
object Idle : VerificationState()
|
||||
data class Requested(val fromUser: String, val fromDevice: String, val txnId: String) : VerificationState()
|
||||
data class ShowingEmojis(val txnId: String, val emojis: List<SasEmoji>) : VerificationState()
|
||||
object Done : VerificationState()
|
||||
data class Cancelled(val reason: String) : VerificationState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the m.sas.v1 interactive device verification protocol.
|
||||
*
|
||||
* Designed for the **accepting** side (the Android device accepts a request initiated by
|
||||
* another client such as Element Web).
|
||||
*
|
||||
* Call [processToDeviceEvent] for every incoming `m.key.verification.*` to-device event.
|
||||
* Set [onSend] before the first event arrives so responses can be delivered.
|
||||
*
|
||||
* [userId] and [deviceId] are set by [MatrixSession] after login; [cryptoService] is set
|
||||
* similarly so one instance can be created early and configured lazily.
|
||||
*/
|
||||
class VerificationService(private val scope: CoroutineScope) {
|
||||
var userId: String = ""
|
||||
var deviceId: String = ""
|
||||
var cryptoService: CryptoService? = null
|
||||
private val _state = MutableStateFlow<VerificationState>(VerificationState.Idle)
|
||||
val state: StateFlow<VerificationState> = _state.asStateFlow()
|
||||
|
||||
/**
|
||||
* Callback invoked to send a to-device event.
|
||||
* (toUser, toDevice, eventType, txnId, content)
|
||||
*/
|
||||
var onSend: suspend (String, String, String, String, JsonObject) -> Unit = { _, _, _, _, _ -> }
|
||||
var onFetchVerificationKey: suspend (String, String, String?) -> String? = { _, _, _ -> null }
|
||||
var onVerified: () -> Unit = {}
|
||||
|
||||
private var olmSas: OlmSAS? = null
|
||||
private var currentTxnId: String? = null
|
||||
private var theirUserId: String? = null
|
||||
private var theirDeviceId: String? = null
|
||||
private var ourPublicKey: String? = null
|
||||
private var pendingTheirMac: JsonObject? = null // mac received before we showed emojis
|
||||
private var selectedMacMethod: String? = null
|
||||
private var hasConfirmedLocally: Boolean = false
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Incoming event dispatch
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
fun processToDeviceEvent(event: JsonObject) {
|
||||
val type = (event["type"] as? JsonPrimitive)?.content ?: return
|
||||
val content = event["content"]?.jsonObject ?: return
|
||||
val sender = (event["sender"] as? JsonPrimitive)?.content ?: return
|
||||
val txnId = (content["transaction_id"] as? JsonPrimitive)?.content ?: return
|
||||
|
||||
when (type) {
|
||||
"m.key.verification.request" -> handleRequest(sender, txnId, content)
|
||||
"m.key.verification.start" -> handleStart(sender, txnId, content)
|
||||
"m.key.verification.key" -> handleKey(txnId, content)
|
||||
"m.key.verification.mac" -> handleMac(txnId, content)
|
||||
"m.key.verification.done" -> handleDone(txnId)
|
||||
"m.key.verification.cancel" -> handleCancel(txnId, content)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Request — show banner
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun handleRequest(sender: String, txnId: String, content: JsonObject) {
|
||||
if (_state.value !is VerificationState.Idle) return
|
||||
val fromDevice = (content["from_device"] as? JsonPrimitive)?.content ?: return
|
||||
theirUserId = sender
|
||||
theirDeviceId = fromDevice
|
||||
currentTxnId = txnId
|
||||
_state.value = VerificationState.Requested(sender, fromDevice, txnId)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Accept — user tapped "Accept" on the banner
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
fun accept(txnId: String) {
|
||||
if ((_state.value as? VerificationState.Requested)?.txnId != txnId) return
|
||||
val toUser = theirUserId ?: return
|
||||
val toDevice = theirDeviceId ?: return
|
||||
val content = buildJsonObject {
|
||||
put("from_device", deviceId)
|
||||
put("transaction_id", txnId)
|
||||
put("methods", buildJsonArray { add(JsonPrimitive("m.sas.v1")) })
|
||||
}
|
||||
scope.launch {
|
||||
onSend(toUser, toDevice, "m.key.verification.ready", "${txnId}_ready", content)
|
||||
}
|
||||
// Stay in Requested state; wait for m.key.verification.start
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Start — create OlmSAS, send accept + key
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun handleStart(sender: String, txnId: String, content: JsonObject) {
|
||||
if (txnId != currentTxnId) return
|
||||
val method = (content["method"] as? JsonPrimitive)?.content
|
||||
if (method != "m.sas.v1") {
|
||||
sendCancel(txnId, "m.unknown_method", "Unsupported method")
|
||||
return
|
||||
}
|
||||
val macMethods = content["message_authentication_codes"]
|
||||
?.jsonArray
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.content }
|
||||
?: emptyList()
|
||||
selectedMacMethod = when {
|
||||
"hkdf-hmac-sha256.v2" in macMethods -> "hkdf-hmac-sha256.v2"
|
||||
"hkdf-hmac-sha256" in macMethods -> "hkdf-hmac-sha256"
|
||||
else -> {
|
||||
sendCancel(txnId, "m.unknown_method", "Unsupported MAC method")
|
||||
return
|
||||
}
|
||||
}
|
||||
val fromDevice = (content["from_device"] as? JsonPrimitive)?.content ?: return
|
||||
theirDeviceId = fromDevice
|
||||
theirUserId = sender
|
||||
|
||||
val sas = OlmSAS()
|
||||
olmSas = sas
|
||||
ourPublicKey = sas.publicKey
|
||||
|
||||
// commitment = unpadded-base64(SHA-256(our_pubkey_base64 + canonical_json(start_content)))
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
val commitInput = (ourPublicKey ?: return) + content.toCanonicalJson()
|
||||
val commitment = Base64.encodeToString(
|
||||
sha256.digest(commitInput.toByteArray(Charsets.UTF_8)),
|
||||
Base64.NO_WRAP or Base64.NO_PADDING
|
||||
)
|
||||
|
||||
val acceptContent = buildJsonObject {
|
||||
put("transaction_id", txnId)
|
||||
put("method", "m.sas.v1")
|
||||
put("key_agreement_protocol", "curve25519-hkdf-sha256")
|
||||
put("hash", "sha256")
|
||||
put("message_authentication_code", selectedMacMethod ?: "hkdf-hmac-sha256.v2")
|
||||
put("short_authentication_string", buildJsonArray {
|
||||
add(JsonPrimitive("decimal"))
|
||||
add(JsonPrimitive("emoji"))
|
||||
})
|
||||
put("commitment", commitment)
|
||||
}
|
||||
val keyContent = buildJsonObject {
|
||||
put("transaction_id", txnId)
|
||||
put("key", ourPublicKey!!)
|
||||
}
|
||||
val toUser = theirUserId ?: return
|
||||
val toDevice = theirDeviceId ?: return
|
||||
scope.launch {
|
||||
onSend(toUser, toDevice, "m.key.verification.accept", "${txnId}_accept", acceptContent)
|
||||
onSend(toUser, toDevice, "m.key.verification.key", "${txnId}_key", keyContent)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Key exchange — compute SAS and show emojis
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun handleKey(txnId: String, content: JsonObject) {
|
||||
if (txnId != currentTxnId) return
|
||||
val sas = olmSas ?: return
|
||||
val theirKey = (content["key"] as? JsonPrimitive)?.content ?: return
|
||||
|
||||
sas.setTheirPublicKey(theirKey)
|
||||
|
||||
// Initiator = them (sent the start), Accepting = us
|
||||
val info = "MATRIX_KEY_VERIFICATION_SAS" +
|
||||
"|${theirUserId}|${theirDeviceId}|${theirKey}" +
|
||||
"|${userId}|${deviceId}|${ourPublicKey}" +
|
||||
"|${txnId}"
|
||||
|
||||
val sasBytes = sas.generateShortCode(info, 6)
|
||||
val emojis = sasBytes.toSasEmojis()
|
||||
hasConfirmedLocally = false
|
||||
_state.value = VerificationState.ShowingEmojis(txnId, emojis)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Confirm — user tapped "Confirm emojis match"
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
fun confirm() {
|
||||
val state = _state.value as? VerificationState.ShowingEmojis ?: return
|
||||
val sas = olmSas ?: return
|
||||
val txnId = state.txnId
|
||||
val toUser = theirUserId ?: return
|
||||
val toDevice = theirDeviceId ?: return
|
||||
|
||||
val crypto = cryptoService ?: return
|
||||
val keyId = "ed25519:$deviceId"
|
||||
hasConfirmedLocally = true
|
||||
val macBase = "MATRIX_KEY_VERIFICATION_MAC$userId$deviceId$toUser$toDevice$txnId"
|
||||
val keyMac = sas.calculateMacByMethod(
|
||||
input = crypto.ed25519Key,
|
||||
info = "$macBase$keyId",
|
||||
method = selectedMacMethod
|
||||
) ?: run {
|
||||
sendCancel(txnId, "m.key_mismatch", "MAC calculation failed")
|
||||
return
|
||||
}
|
||||
val keyIdsMac = sas.calculateMacByMethod(
|
||||
input = keyId,
|
||||
info = "${macBase}KEY_IDS",
|
||||
method = selectedMacMethod
|
||||
) ?: run {
|
||||
sendCancel(txnId, "m.key_mismatch", "MAC calculation failed")
|
||||
return
|
||||
}
|
||||
|
||||
val macContent = buildJsonObject {
|
||||
put("transaction_id", txnId)
|
||||
put("mac", buildJsonObject { put(keyId, keyMac) })
|
||||
put("keys", keyIdsMac)
|
||||
}
|
||||
scope.launch {
|
||||
onSend(toUser, toDevice, "m.key.verification.mac", "${txnId}_mac", macContent)
|
||||
}
|
||||
pendingTheirMac?.let { mac ->
|
||||
pendingTheirMac = null
|
||||
verifyTheirMac(txnId, mac)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Their MAC — verify and send done
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun handleMac(txnId: String, content: JsonObject) {
|
||||
if (txnId != currentTxnId) return
|
||||
if (_state.value !is VerificationState.ShowingEmojis || !hasConfirmedLocally) {
|
||||
pendingTheirMac = content
|
||||
return
|
||||
}
|
||||
verifyTheirMac(txnId, content)
|
||||
}
|
||||
|
||||
private fun verifyTheirMac(txnId: String, content: JsonObject) {
|
||||
scope.launch {
|
||||
val sas = olmSas ?: return@launch
|
||||
val toUser = theirUserId ?: return@launch
|
||||
val toDevice = theirDeviceId ?: return@launch
|
||||
val mac = content["mac"]?.jsonObject ?: return@launch
|
||||
val keysMac = (content["keys"] as? JsonPrimitive)?.content ?: return@launch
|
||||
val macBase = "MATRIX_KEY_VERIFICATION_MAC$toUser$toDevice$userId$deviceId$txnId"
|
||||
val sortedKeyIds = mac.keys.sorted().joinToString(",")
|
||||
val expectedKeyIdsMac = sas.calculateMacByMethod(
|
||||
input = sortedKeyIds,
|
||||
info = "${macBase}KEY_IDS",
|
||||
method = selectedMacMethod
|
||||
) ?: run {
|
||||
sendCancel(txnId, "m.key_mismatch", "MAC verification failed")
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (keysMac != expectedKeyIdsMac) {
|
||||
sendCancel(txnId, "m.key_mismatch", "Key IDs MAC mismatch")
|
||||
return@launch
|
||||
}
|
||||
|
||||
for ((keyId, keyMac) in mac) {
|
||||
val expectedKey = onFetchVerificationKey(toUser, keyId, toDevice) ?: run {
|
||||
sendCancel(txnId, "m.key_mismatch", "Unknown key id: $keyId")
|
||||
return@launch
|
||||
}
|
||||
val expectedMac = sas.calculateMacByMethod(
|
||||
input = expectedKey,
|
||||
info = "$macBase$keyId",
|
||||
method = selectedMacMethod
|
||||
) ?: run {
|
||||
sendCancel(txnId, "m.key_mismatch", "MAC verification failed")
|
||||
return@launch
|
||||
}
|
||||
if ((keyMac as? JsonPrimitive)?.content != expectedMac) {
|
||||
sendCancel(txnId, "m.key_mismatch", "Device key MAC mismatch")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
val doneContent = buildJsonObject { put("transaction_id", txnId) }
|
||||
onSend(toUser, toDevice, "m.key.verification.done", "${txnId}_done", doneContent)
|
||||
cleanup()
|
||||
_state.value = VerificationState.Done
|
||||
onVerified()
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Done / Cancel
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun handleDone(txnId: String) {
|
||||
if (txnId != currentTxnId) return
|
||||
cleanup()
|
||||
_state.value = VerificationState.Done
|
||||
onVerified()
|
||||
}
|
||||
|
||||
private fun handleCancel(txnId: String, content: JsonObject) {
|
||||
if (txnId != currentTxnId) return
|
||||
val reason = (content["reason"] as? JsonPrimitive)?.content ?: "Cancelled by other device"
|
||||
cleanup()
|
||||
_state.value = VerificationState.Cancelled(reason)
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
val txnId = currentTxnId ?: run {
|
||||
_state.value = VerificationState.Idle
|
||||
return
|
||||
}
|
||||
sendCancel(txnId, "m.user", "Cancelled by user")
|
||||
}
|
||||
|
||||
private fun sendCancel(txnId: String, code: String, reason: String) {
|
||||
val toUser = theirUserId ?: return
|
||||
val toDevice = theirDeviceId ?: return
|
||||
val content = buildJsonObject {
|
||||
put("transaction_id", txnId)
|
||||
put("code", code)
|
||||
put("reason", reason)
|
||||
}
|
||||
scope.launch {
|
||||
onSend(toUser, toDevice, "m.key.verification.cancel", "${txnId}_cancel", content)
|
||||
}
|
||||
cleanup()
|
||||
_state.value = VerificationState.Cancelled(reason)
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
cleanup()
|
||||
_state.value = VerificationState.Idle
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
olmSas?.releaseSas()
|
||||
olmSas = null
|
||||
currentTxnId = null
|
||||
theirUserId = null
|
||||
theirDeviceId = null
|
||||
ourPublicKey = null
|
||||
pendingTheirMac = null
|
||||
selectedMacMethod = null
|
||||
hasConfirmedLocally = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun OlmSAS.calculateMacByMethod(input: String, info: String, method: String?): String? =
|
||||
runCatching {
|
||||
when (method) {
|
||||
"hkdf-hmac-sha256.v2" -> calculateMacFixedBase64(input, info)
|
||||
"hkdf-hmac-sha256" -> calculateMac(input, info)
|
||||
"hmac-sha256" -> calculateMacLongKdf(input, info)
|
||||
else -> null
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SAS emoji derivation (Matrix spec: 42 bits = 7 × 6-bit indices)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private fun ByteArray.toSasEmojis(): List<SasEmoji> {
|
||||
val b = map { it.toInt() and 0xFF }
|
||||
val indices = listOf(
|
||||
b[0] shr 2,
|
||||
((b[0] and 0x3) shl 4) or (b[1] shr 4),
|
||||
((b[1] and 0xF) shl 2) or (b[2] shr 6),
|
||||
b[2] and 0x3F,
|
||||
b[3] shr 2,
|
||||
((b[3] and 0x3) shl 4) or (b[4] shr 4),
|
||||
((b[4] and 0xF) shl 2) or (b[5] shr 6)
|
||||
)
|
||||
return indices.map { SAS_EMOJIS[it] }
|
||||
}
|
||||
|
||||
// Matrix SAS emoji list (indices 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")
|
||||
)
|
||||
|
|
@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
315
app/src/main/java/com/ltadeu6/matrix/rooms/RoomListScreen.kt
Normal file
315
app/src/main/java/com/ltadeu6/matrix/rooms/RoomListScreen.kt
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
package com.ltadeu6.matrix.rooms
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.ltadeu6.matrix.matrix.AuthState
|
||||
import com.ltadeu6.matrix.matrix.MatrixSession
|
||||
import com.ltadeu6.matrix.matrix.Room
|
||||
import com.ltadeu6.matrix.matrix.VerificationState
|
||||
import com.ltadeu6.matrix.ui.theme.MatrixBackground
|
||||
import com.ltadeu6.matrix.ui.theme.MatrixBorder
|
||||
import com.ltadeu6.matrix.ui.theme.MatrixGreen
|
||||
import com.ltadeu6.matrix.ui.theme.MatrixGreenDim
|
||||
import com.ltadeu6.matrix.ui.theme.TerminalFont
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun RoomListScreen(
|
||||
onRoomSelected: (String) -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onOpenVerification: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val session = MatrixSession.getInstance(context)
|
||||
val syncState by session.syncState.collectAsState()
|
||||
val authState by session.authState.collectAsState()
|
||||
val verificationState by session.verificationService.state.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val userId = (authState as? AuthState.LoggedIn)?.userId ?: ""
|
||||
var roomToForget by remember { mutableStateOf<Room?>(null) }
|
||||
var showNewChatDialog by remember { mutableStateOf(false) }
|
||||
var handleInput by remember { mutableStateOf("") }
|
||||
var actionError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(syncState.rooms.keys) {
|
||||
roomToForget?.let { room ->
|
||||
if (room.id !in syncState.rooms) roomToForget = null
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "> ROOMS",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 18.sp, color = MatrixGreen)
|
||||
)
|
||||
TextButton(onClick = onLogout) {
|
||||
Text(
|
||||
text = "[logout]",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixGreenDim)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (userId.isNotEmpty()) {
|
||||
Text(
|
||||
text = " $userId",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder)
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier)
|
||||
}
|
||||
TextButton(onClick = { showNewChatDialog = true }) {
|
||||
Text(
|
||||
text = "[new chat]",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixGreenDim)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (actionError != null) {
|
||||
Text(
|
||||
text = " ${actionError}",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixGreenDim)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "─".repeat(40),
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixBorder)
|
||||
)
|
||||
|
||||
// Verification banner
|
||||
when (val vs = verificationState) {
|
||||
is VerificationState.Requested -> {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = " [!] verification request",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixGreen)
|
||||
)
|
||||
Text(
|
||||
text = " from ${vs.fromUser}",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 10.sp, color = MatrixGreenDim)
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onOpenVerification,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MatrixGreen,
|
||||
contentColor = MatrixBackground
|
||||
)
|
||||
) {
|
||||
Text("[verify]", fontFamily = TerminalFont, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
is VerificationState.ShowingEmojis -> {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpenVerification() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = " [!] verification in progress — tap to continue",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixGreen)
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (syncState.rooms.isEmpty()) {
|
||||
Text(
|
||||
text = " syncing...",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreenDim)
|
||||
)
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(syncState.rooms.values.sortedBy { it.name }) { room ->
|
||||
RoomRow(
|
||||
room = room,
|
||||
onClick = { onRoomSelected(room.id) },
|
||||
onLongPress = { roomToForget = room }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
roomToForget?.let { room ->
|
||||
AlertDialog(
|
||||
containerColor = MatrixBackground,
|
||||
title = {
|
||||
Text(
|
||||
text = "forget room?",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 16.sp, color = MatrixGreen)
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = "leave and forget ${room.name}",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixGreenDim)
|
||||
)
|
||||
},
|
||||
onDismissRequest = { roomToForget = null },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
val target = room
|
||||
roomToForget = null
|
||||
session.forgetRoom(target.id)
|
||||
.onFailure { actionError = it.message ?: "failed to forget room" }
|
||||
}
|
||||
}) {
|
||||
Text("forget", fontFamily = TerminalFont, color = MatrixGreen)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { roomToForget = null }) {
|
||||
Text("cancel", fontFamily = TerminalFont, color = MatrixBorder)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showNewChatDialog) {
|
||||
AlertDialog(
|
||||
containerColor = MatrixBackground,
|
||||
title = {
|
||||
Text(
|
||||
text = "new direct chat",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 16.sp, color = MatrixGreen)
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = handleInput,
|
||||
onValueChange = { handleInput = it },
|
||||
singleLine = true,
|
||||
placeholder = {
|
||||
Text(
|
||||
"@user:server",
|
||||
style = TextStyle(fontFamily = TerminalFont, color = MatrixBorder)
|
||||
)
|
||||
},
|
||||
textStyle = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreen),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MatrixGreen,
|
||||
unfocusedBorderColor = MatrixBorder,
|
||||
cursorColor = MatrixGreen,
|
||||
focusedTextColor = MatrixGreen,
|
||||
unfocusedTextColor = MatrixGreen,
|
||||
focusedContainerColor = MatrixBackground,
|
||||
unfocusedContainerColor = MatrixBackground
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "invite by Matrix handle",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixGreenDim)
|
||||
)
|
||||
}
|
||||
},
|
||||
onDismissRequest = { showNewChatDialog = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
session.startDirectChat(handleInput)
|
||||
.onSuccess { roomId ->
|
||||
actionError = null
|
||||
handleInput = ""
|
||||
showNewChatDialog = false
|
||||
onRoomSelected(roomId)
|
||||
}
|
||||
.onFailure { actionError = it.message ?: "failed to create direct chat" }
|
||||
}
|
||||
}) {
|
||||
Text("open", fontFamily = TerminalFont, color = MatrixGreen)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showNewChatDialog = false }) {
|
||||
Text("cancel", fontFamily = TerminalFont, color = MatrixBorder)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun RoomRow(room: Room, onClick: () -> Unit, onLongPress: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongPress)
|
||||
.padding(vertical = 10.dp, horizontal = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "> ${room.name}",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 15.sp, color = MatrixGreen)
|
||||
)
|
||||
Text(
|
||||
text = " ${room.id}",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 10.sp, color = MatrixBorder)
|
||||
)
|
||||
}
|
||||
}
|
||||
36
app/src/main/java/com/ltadeu6/matrix/ui/theme/Theme.kt
Normal file
36
app/src/main/java/com/ltadeu6/matrix/ui/theme/Theme.kt
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package com.ltadeu6.matrix.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
|
||||
val MatrixGreen = Color(0xFF00FF41)
|
||||
val MatrixGreenDim = Color(0xFF00CC33)
|
||||
val MatrixBackground = Color(0xFF0A0A0A)
|
||||
val MatrixSurface = Color(0xFF111111)
|
||||
val MatrixBorder = Color(0xFF1C4A1C)
|
||||
|
||||
val TerminalFont: FontFamily = FontFamily.Monospace
|
||||
|
||||
private val MatrixColorScheme = darkColorScheme(
|
||||
primary = MatrixGreen,
|
||||
onPrimary = MatrixBackground,
|
||||
background = MatrixBackground,
|
||||
onBackground = MatrixGreen,
|
||||
surface = MatrixSurface,
|
||||
onSurface = MatrixGreen,
|
||||
secondary = MatrixGreenDim,
|
||||
onSecondary = MatrixBackground,
|
||||
error = Color(0xFFFF4444),
|
||||
outline = MatrixBorder
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MatrixTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = MatrixColorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
package com.ltadeu6.matrix.verification
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.ltadeu6.matrix.matrix.MatrixSession
|
||||
import com.ltadeu6.matrix.matrix.SasEmoji
|
||||
import com.ltadeu6.matrix.matrix.VerificationState
|
||||
import com.ltadeu6.matrix.ui.theme.MatrixBackground
|
||||
import com.ltadeu6.matrix.ui.theme.MatrixBorder
|
||||
import com.ltadeu6.matrix.ui.theme.MatrixGreen
|
||||
import com.ltadeu6.matrix.ui.theme.MatrixGreenDim
|
||||
import com.ltadeu6.matrix.ui.theme.TerminalFont
|
||||
|
||||
@Composable
|
||||
fun VerificationScreen(onDone: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val session = MatrixSession.getInstance(context)
|
||||
val verState by session.verificationService.state.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "> DEVICE VERIFICATION",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 18.sp, color = MatrixGreen)
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "─".repeat(40),
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixBorder)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
when (val s = verState) {
|
||||
is VerificationState.Idle -> {
|
||||
Text(
|
||||
text = " no active verification",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreenDim)
|
||||
)
|
||||
}
|
||||
|
||||
is VerificationState.Requested -> {
|
||||
Text(
|
||||
text = " verification request from",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 13.sp, color = MatrixGreenDim)
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = " ${s.fromUser}",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreen)
|
||||
)
|
||||
Text(
|
||||
text = " device: ${s.fromDevice}",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder)
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(
|
||||
onClick = { session.verificationService.accept(s.txnId) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MatrixGreen,
|
||||
contentColor = MatrixBackground
|
||||
)
|
||||
) {
|
||||
Text("[accept]", fontFamily = TerminalFont)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { session.verificationService.cancel(); onDone() },
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MatrixGreenDim)
|
||||
) {
|
||||
Text("[decline]", fontFamily = TerminalFont)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = " waiting for the other device to start...",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder)
|
||||
)
|
||||
}
|
||||
|
||||
is VerificationState.ShowingEmojis -> {
|
||||
Text(
|
||||
text = " compare these emojis on both devices:",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 13.sp, color = MatrixGreenDim)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
EmojiGrid(emojis = s.emojis)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = " do the emojis match on the other device?",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 13.sp, color = MatrixGreenDim)
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(
|
||||
onClick = { session.verificationService.confirm() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MatrixGreen,
|
||||
contentColor = MatrixBackground
|
||||
)
|
||||
) {
|
||||
Text("[they match]", fontFamily = TerminalFont)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { session.verificationService.cancel(); onDone() },
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MatrixGreenDim)
|
||||
) {
|
||||
Text("[no match]", fontFamily = TerminalFont)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is VerificationState.Done -> {
|
||||
Text(
|
||||
text = " verification complete!",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 16.sp, color = MatrixGreen)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = " device has been verified successfully.",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 13.sp, color = MatrixGreenDim)
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = { session.verificationService.dismiss(); onDone() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MatrixGreen,
|
||||
contentColor = MatrixBackground
|
||||
)
|
||||
) {
|
||||
Text("[done]", fontFamily = TerminalFont)
|
||||
}
|
||||
}
|
||||
|
||||
is VerificationState.Cancelled -> {
|
||||
Text(
|
||||
text = " verification cancelled",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 16.sp, color = MatrixGreenDim)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = " reason: ${s.reason}",
|
||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixBorder)
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = { session.verificationService.dismiss(); onDone() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MatrixGreen,
|
||||
contentColor = MatrixBackground
|
||||
)
|
||||
) {
|
||||
Text("[close]", fontFamily = TerminalFont)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiGrid(emojis: List<SasEmoji>) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(4),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(emojis) { emoji ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = emoji.emoji,
|
||||
fontSize = 32.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = emoji.name,
|
||||
style = TextStyle(
|
||||
fontFamily = TerminalFont,
|
||||
fontSize = 9.sp,
|
||||
color = MatrixGreenDim,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
14
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Terminal ">" prompt in matrix green -->
|
||||
<path
|
||||
android:fillColor="#00FF41"
|
||||
android:pathData="M30,30 L60,54 L30,78 L36,78 L66,54 L36,30 Z" />
|
||||
<path
|
||||
android:fillColor="#00FF41"
|
||||
android:pathData="M68,72 L82,72 L82,78 L68,78 Z" />
|
||||
</vector>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/values/colors.xml
Normal file
5
app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0A0A0A</color>
|
||||
<color name="matrix_green">#00FF41</color>
|
||||
</resources>
|
||||
4
app/src/main/res/values/strings.xml
Normal file
4
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Matrix</string>
|
||||
</resources>
|
||||
10
app/src/main/res/values/themes.xml
Normal file
10
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Base theme — Compose draws everything; this just sets window chrome -->
|
||||
<style name="Theme.MatrixAndroid" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">#0A0A0A</item>
|
||||
<item name="android:statusBarColor">#0A0A0A</item>
|
||||
<item name="android:navigationBarColor">#0A0A0A</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
6
build.gradle.kts
Normal file
6
build.gradle.kts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
}
|
||||
4
gradle.properties
Normal file
4
gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
kotlin.code.style=official
|
||||
44
gradle/libs.versions.toml
Normal file
44
gradle/libs.versions.toml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
[versions]
|
||||
agp = "8.7.3"
|
||||
kotlin = "2.0.20"
|
||||
coreKtx = "1.13.1"
|
||||
lifecycleRuntimeKtx = "2.8.4"
|
||||
activityCompose = "1.9.1"
|
||||
composeBom = "2024.09.00"
|
||||
navigationCompose = "2.8.0"
|
||||
retrofit = "2.11.0"
|
||||
okhttp = "4.12.0"
|
||||
kotlinxSerialization = "1.7.1"
|
||||
kotlinxCoroutines = "1.8.1"
|
||||
browser = "1.8.0"
|
||||
retrofitKotlinxConverter = "1.0.0"
|
||||
appcompat = "1.7.0"
|
||||
olmSdk = "3.2.12"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||
retrofit-converter-kotlinx-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxConverter" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
|
||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||
olm-sdk = { group = "org.matrix.android", name = "olm-sdk", version.ref = "olmSdk" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
16
settings.gradle.kts
Normal file
16
settings.gradle.kts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "matrix-android"
|
||||
include(":app")
|
||||
5
settings.json
Normal file
5
settings.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": ["Edit", "Write", "Read", "Glob", "Grep", "Bash(*)"]
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue