@@ -0,0 +1,71 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("com.apollographql.apollo3")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tlm.android"
|
||||
compileSdk = 34
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.tlm.android"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.0.1"
|
||||
val gqlUrl = project.findProperty("TLM_GRAPHQL_URL") as String? ?: "http://10.0.2.2:4000/graphql"
|
||||
buildConfigField("String", "GRAPHQL_URL", "\"$gqlUrl\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apollo {
|
||||
service("tlm") {
|
||||
packageName.set("com.tlm.graphql")
|
||||
schemaFile.set(file("../../../packages/graphql-schema/schema.graphql"))
|
||||
srcDir("src/main/graphql")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.09.03")
|
||||
implementation(composeBom)
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.1")
|
||||
|
||||
implementation("com.apollographql.apollo3:apollo-runtime:3.8.5")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.TLM"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,19 @@
|
||||
query Chats {
|
||||
chats {
|
||||
__typename
|
||||
id
|
||||
... on DirectThread {
|
||||
peer {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
... on Group {
|
||||
title
|
||||
}
|
||||
... on Channel {
|
||||
title
|
||||
}
|
||||
unreadCount
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
mutation Login($email: String!, $password: String!) {
|
||||
login(email: $email, password: $password) {
|
||||
accessToken
|
||||
refreshToken
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation SendMessage($chatId: ID!, $body: String) {
|
||||
sendMessage(chatId: $chatId, body: $body) {
|
||||
id
|
||||
seq
|
||||
body
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.tlm.android
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.apollographql.apollo3.ApolloClient
|
||||
import com.apollographql.apollo3.api.Optional
|
||||
import com.apollographql.apollo3.exception.ApolloException
|
||||
import com.tlm.android.data.OutboxRepository
|
||||
import com.tlm.graphql.ChatsQuery
|
||||
import com.tlm.graphql.LoginMutation
|
||||
import com.tlm.graphql.SendMessageMutation
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val prefs = getSharedPreferences("tlm", Context.MODE_PRIVATE)
|
||||
val ok =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor { chain ->
|
||||
val t = prefs.getString("accessToken", null)
|
||||
val b = chain.request().newBuilder()
|
||||
if (!t.isNullOrBlank()) b.addHeader("Authorization", "Bearer $t")
|
||||
chain.proceed(b.build())
|
||||
}
|
||||
.build()
|
||||
val apollo =
|
||||
ApolloClient.Builder()
|
||||
.serverUrl(BuildConfig.GRAPHQL_URL)
|
||||
.okHttpClient(ok)
|
||||
.build()
|
||||
val outbox = OutboxRepository()
|
||||
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
val scope = rememberCoroutineScope()
|
||||
val email = remember { mutableStateOf("alice@example.com") }
|
||||
val password = remember { mutableStateOf("password123") }
|
||||
val tokenPresent = remember { mutableStateOf(!prefs.getString("accessToken", null).isNullOrBlank()) }
|
||||
val chats = remember { mutableStateOf<List<ChatsQuery.Chat>>(emptyList()) }
|
||||
val activeId = remember { mutableStateOf<String?>(null) }
|
||||
val draft = remember { mutableStateOf("") }
|
||||
val status = remember { mutableStateOf("") }
|
||||
|
||||
fun loadChats() {
|
||||
scope.launch {
|
||||
try {
|
||||
val res = apollo.query(ChatsQuery()).execute()
|
||||
if (res.data != null) {
|
||||
chats.value = res.data!!.chats.filterNotNull()
|
||||
} else {
|
||||
status.value = res.errors?.joinToString { it.message } ?: "chats error"
|
||||
}
|
||||
} catch (e: ApolloException) {
|
||||
status.value = e.message ?: "network"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(tokenPresent.value) {
|
||||
if (tokenPresent.value) loadChats()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(12.dp)) {
|
||||
Text("TLM (dev)", style = MaterialTheme.typography.headlineSmall)
|
||||
if (!tokenPresent.value) {
|
||||
OutlinedTextField(
|
||||
value = email.value,
|
||||
onValueChange = { email.value = it },
|
||||
label = { Text("Email") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = password.value,
|
||||
onValueChange = { password.value = it },
|
||||
label = { Text("Password") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
try {
|
||||
val res =
|
||||
apollo
|
||||
.mutation(
|
||||
LoginMutation(
|
||||
email = email.value,
|
||||
password = password.value,
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
val t = res.data?.login?.accessToken
|
||||
if (!t.isNullOrBlank()) {
|
||||
prefs.edit().putString("accessToken", t).apply()
|
||||
tokenPresent.value = true
|
||||
status.value = "ok"
|
||||
} else {
|
||||
status.value = res.errors?.joinToString { it.message } ?: "login failed"
|
||||
}
|
||||
} catch (e: ApolloException) {
|
||||
status.value = e.message ?: "network"
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("Login")
|
||||
}
|
||||
} else {
|
||||
Row {
|
||||
Button(onClick = { loadChats() }) { Text("Refresh chats") }
|
||||
Button(
|
||||
onClick = {
|
||||
prefs.edit().remove("accessToken").apply()
|
||||
tokenPresent.value = false
|
||||
},
|
||||
) {
|
||||
Text("Logout")
|
||||
}
|
||||
}
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
items(chats.value) { c ->
|
||||
Button(
|
||||
onClick = { activeId.value = c.id },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("${c.__typename} ${c.id} (${c.unreadCount} unread)")
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = draft.value,
|
||||
onValueChange = { draft.value = it },
|
||||
label = { Text("Message") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
val id = activeId.value
|
||||
if (id == null) {
|
||||
status.value = "pick a chat"
|
||||
return@Button
|
||||
}
|
||||
val body = draft.value
|
||||
outbox.enqueue("send:$id:${body.length}")
|
||||
scope.launch {
|
||||
try {
|
||||
apollo
|
||||
.mutation(
|
||||
SendMessageMutation(
|
||||
chatId = id,
|
||||
body =
|
||||
if (body.isBlank()) Optional.absent()
|
||||
else Optional.present(body),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
draft.value = ""
|
||||
loadChats()
|
||||
} catch (e: ApolloException) {
|
||||
status.value = e.message ?: "send failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("Send")
|
||||
}
|
||||
}
|
||||
Text(status.value, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.tlm.android.data
|
||||
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
/**
|
||||
* Minimal outbox for optimistic sends; drain with WorkManager in a real build.
|
||||
*/
|
||||
class OutboxRepository {
|
||||
private val q = ConcurrentLinkedQueue<String>()
|
||||
|
||||
fun enqueue(payload: String) {
|
||||
q.add(payload)
|
||||
}
|
||||
|
||||
fun drain(max: Int = 50): List<String> {
|
||||
val out = ArrayList<String>()
|
||||
repeat(max) {
|
||||
val v = q.poll() ?: return out
|
||||
out.add(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.tlm.android.push
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
|
||||
/**
|
||||
* Placeholder for FCM token registration against your GraphQL `registerDevice` mutation.
|
||||
*/
|
||||
class FcmRegistrationWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
override suspend fun doWork(): Result = Result.success()
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">TLM</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.TLM" parent="Theme.Material3.DayNight.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,6 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.7.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
||||
id("com.apollographql.apollo3") version "3.8.5" apply false
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "tlm-android"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user