feat(android): app nativa Kotlin/Compose (login, rooms, chat)

App Android Material 3, tema oscuro con acento índigo/violeta que replica el
look & feel de la app web (web/src):

- Login: identidad + contraseña, candado de marca, estilo Card.
- Lista de rooms: avatar+handle, buscador rooms/usuarios/mensajes, items con
  candado (E2E) / hash (cleartext), hora, último mensaje y badge de no leídos.
- Chat estilo Element: avatar+nombre+hora+texto, composer redondeado con send.

Arquitectura por capas: UnibusRepository aísla la UI de la fuente de datos.
MockUnibusRepository (en memoria) alimenta la iteración de diseño;
BindingUnibusRepository implementa la misma interfaz sobre el binding gomobile
(unibus.aar) para conectar el bus real sin tocar las pantallas. AppViewModel
orquesta el estado; navegación por estado (login -> rooms -> chat), KISS.

Build: ./gradlew assembleDebug (AGP 8.5.2, Gradle 8.7, Kotlin 1.9.24,
Compose BOM 2024.06.00, compileSdk 34, minSdk 21). El .aar se regenera con
mobile/gen_aar.sh (no se versiona, 38MB).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 18:16:23 +02:00
parent f92973f5fe
commit 5af945778b
30 changed files with 1735 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# Android / Gradle build artifacts
*.iml
.gradle/
/local.properties
/.idea
.DS_Store
/build
/app/build
/captures
.externalNativeBuild
.cxx
# binding gomobile regenerable (38MB): ver mobile/gen_aar.sh
/app/libs/*.aar
/app/libs/*-sources.jar
+75
View File
@@ -0,0 +1,75 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "com.unibus.app"
compileSdk = 34
defaultConfig {
applicationId = "com.unibus.app"
minSdk = 21
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
// The unibus.aar ships native libgojni.so for these ABIs. Limit the APK
// to the desktop/emulator + phone ABIs we actually target.
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
// Compose compiler matching Kotlin 1.9.24.
kotlinCompilerExtensionVersion = "1.5.14"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// gomobile binding over pkg/client (real end-to-end crypto on device).
implementation(files("libs/unibus.aar"))
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.activity:activity-compose:1.9.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.2")
val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}
+12
View File
@@ -0,0 +1,12 @@
# libs/
`unibus.aar` (binding gomobile sobre `pkg/client`, ~38 MB con `libgojni.so` para
4 ABIs) vive aquí pero **no se versiona** — es un artefacto de build reproducible.
Regenéralo con:
```bash
../../mobile/gen_aar.sh
```
(desde la raíz del repo: `./mobile/gen_aar.sh`). Requiere Go + gomobile + Android NDK.
+4
View File
@@ -0,0 +1,4 @@
# gomobile binding: keep the generated Go<->Java bridge classes intact so the
# JNI layer can find them by name at runtime.
-keep class go.** { *; }
-keep class com.unibus.core.mobile.** { *; }
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The bus is reached over the network (NATS data plane + control plane). -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:label="unibus"
android:icon="@mipmap/ic_launcher"
android:theme="@style/Theme.Unibus"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Unibus">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,88 @@
package com.unibus.app
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.unibus.app.data.Message
import com.unibus.app.data.MockUnibusRepository
import com.unibus.app.data.Room
import com.unibus.app.data.UnibusRepository
import com.unibus.app.data.User
import kotlinx.coroutines.launch
/**
* Estado de la app. Orquesta el [UnibusRepository] (mock por defecto) y expone
* estado observable a Compose. Cambiar el repo por [com.unibus.app.data.BindingUnibusRepository]
* conecta la UI al bus real sin tocar las pantallas.
*/
class AppViewModel(
private val repo: UnibusRepository,
) : ViewModel() {
// Constructor no-arg para que androidx `viewModel()` lo instancie por
// reflexión. Por defecto usa el repositorio mock (iteración de diseño).
constructor() : this(MockUnibusRepository())
var user by mutableStateOf<User?>(null)
private set
var rooms by mutableStateOf<List<Room>>(emptyList())
private set
var activeRoomId by mutableStateOf<String?>(null)
private set
var messages by mutableStateOf<List<Message>>(emptyList())
private set
var connecting by mutableStateOf(false)
private set
var error by mutableStateOf<String?>(null)
private set
val activeRoom: Room?
get() = rooms.firstOrNull { it.id == activeRoomId }
fun connect(handle: String, password: String) {
if (connecting) return
connecting = true
error = null
viewModelScope.launch {
repo.connect(handle, password)
.onSuccess {
user = it
rooms = repo.listRooms()
}
.onFailure { error = it.message ?: "No se pudo conectar" }
connecting = false
}
}
fun openRoom(id: String) {
activeRoomId = id
messages = repo.messagesOf(id)
repo.subscribe(id) { incoming ->
if (activeRoomId == id) messages = messages + incoming
}
}
fun closeRoom() {
activeRoomId = null
messages = emptyList()
}
fun send(text: String) {
val rid = activeRoomId ?: return
val body = text.trim()
if (body.isEmpty()) return
viewModelScope.launch {
repo.send(rid, body).onSuccess { messages = messages + it }
}
}
fun logout() {
repo.close()
user = null
rooms = emptyList()
activeRoomId = null
messages = emptyList()
}
}
@@ -0,0 +1,63 @@
package com.unibus.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import com.unibus.app.ui.ChatScreen
import com.unibus.app.ui.LoginScreen
import com.unibus.app.ui.RoomListScreen
import com.unibus.app.ui.theme.LocalUnibusColors
import com.unibus.app.ui.theme.UnibusColors
import com.unibus.app.ui.theme.UnibusTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
UnibusTheme {
CompositionLocalProvider(LocalUnibusColors provides UnibusColors()) {
UnibusApp()
}
}
}
}
}
/**
* Navegación por estado (sin librería de routing — KISS): el usuario fluye
* Login → lista de rooms → chat, igual que la web pero en una sola columna.
*/
@Composable
private fun UnibusApp(vm: AppViewModel = viewModel()) {
val user = vm.user
val activeRoom = vm.activeRoom
when {
user == null -> LoginScreen(
connecting = vm.connecting,
error = vm.error,
onLogin = { handle, password -> vm.connect(handle, password) },
)
activeRoom == null -> RoomListScreen(
user = user,
rooms = vm.rooms,
onSelect = { vm.openRoom(it) },
onLogout = { vm.logout() },
)
else -> {
BackHandler { vm.closeRoom() }
ChatScreen(
room = activeRoom,
messages = vm.messages,
onSend = { vm.send(it) },
onBack = { vm.closeRoom() },
)
}
}
}
@@ -0,0 +1,157 @@
package com.unibus.app.data
import android.content.Context
import android.os.Handler
import android.os.Looper
import com.unibus.core.mobile.FrameListener
import com.unibus.core.mobile.Mobile
import com.unibus.core.mobile.Session
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
/**
* Implementación real sobre el binding gomobile (pkg/client): cifrado de extremo
* a extremo EN el dispositivo, igual que cualquier otro peer del bus. Comparte
* interfaz con [MockUnibusRepository], así que la UI no cambia al enchufarla.
*
* Estado: cableado completo y compilable contra unibus.aar. La iteración 1 de la
* app corre sobre el mock para iterar el diseño; para activar el bus real basta
* con instanciar este repo en [com.unibus.app.MainActivity] pasando las URLs del
* bus y (si el bus exige TLS+auth) el ca.crt en assets.
*
* Contrato de membresía (issue 0006e): tras CreateRoom / Join / Invite hay que
* llamar [refresh] ANTES de subscribe/publish en esa room, o un bus seguro
* deniega el subject. refresh() además tira las suscripciones: re-suscribir luego.
*/
class BindingUnibusRepository(
context: Context,
private val natsURL: String,
private val ctrlURL: String,
) : UnibusRepository {
private val appContext = context.applicationContext
private val mainHandler = Handler(Looper.getMainLooper())
private val json = Json { ignoreUnknownKeys = true }
private var session: Session? = null
private var user: User? = null
@Serializable
private data class RoomDTO(
val room_id: String,
val subject: String,
val epoch: Int = 0,
val encrypted: Boolean = false,
val role: String = "",
)
/** Ruta sandbox de la identidad de larga duración (claves privadas). */
private fun identityPath(): String =
File(appContext.filesDir, "identity.key").absolutePath
/**
* Copia ca.crt de assets a un fichero local y devuelve su ruta; "" si no hay
* (bus de desarrollo en texto plano). El binding pinea TLS a este CA cuando
* la ruta no está vacía.
*/
private fun caPathOrEmpty(): String {
return try {
val out = File(appContext.filesDir, "ca.crt")
appContext.assets.open("ca.crt").use { input ->
out.outputStream().use { input.copyTo(it) }
}
out.absolutePath
} catch (_: Exception) {
""
}
}
override suspend fun connect(handle: String, password: String): Result<User> =
withContext(Dispatchers.IO) {
try {
// La identidad se persiste cifrada en el sandbox; password la
// desbloquea en una iteración futura (hoy LoadOrCreateIdentity la
// crea/lee directamente). handle es la etiqueta visible local.
Mobile.generateIdentity(identityPath())
val s = Mobile.newSession(identityPath(), natsURL, ctrlURL, caPathOrEmpty())
session = s
val u = User(id = s.endpointID(), handle = handle)
user = u
Result.success(u)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun listRooms(): List<Room> = withContext(Dispatchers.IO) {
val s = session ?: return@withContext emptyList()
val raw = runCatching { s.listRoomsJSON() }.getOrDefault("[]")
val dtos = runCatching { json.decodeFromString<List<RoomDTO>>(raw) }.getOrDefault(emptyList())
dtos.map {
Room(
id = it.room_id,
name = it.subject,
encrypted = it.encrypted,
lastMessage = "",
lastTs = System.currentTimeMillis(),
unread = 0,
messages = emptyList(),
)
}
}
override fun messagesOf(roomId: String): List<Message> = emptyList()
override fun subscribe(roomId: String, onMessage: (Message) -> Unit) {
val s = session ?: return
val myId = user?.id
// FrameListener.onFrame llega en una goroutine de NATS: saltamos al hilo
// principal antes de tocar estado de Compose.
val listener = object : FrameListener {
override fun onFrame(rid: String, sender: String, msgID: String, text: String) {
val msg = Message(
id = msgID,
sender = sender,
body = text,
ts = System.currentTimeMillis(),
mine = sender == myId,
)
mainHandler.post { onMessage(msg) }
}
}
runCatching { s.subscribe(roomId, listener) }
}
override suspend fun send(roomId: String, text: String): Result<Message> =
withContext(Dispatchers.IO) {
val s = session ?: return@withContext Result.failure(IllegalStateException("sin sesión"))
try {
s.publish(roomId, text)
Result.success(
Message(
id = "local-${System.currentTimeMillis()}",
sender = user?.id ?: "yo",
body = text,
ts = System.currentTimeMillis(),
mine = true,
),
)
} catch (e: Exception) {
Result.failure(e)
}
}
/** Reaplica permisos tras un cambio de membresía. Re-suscribir después. */
suspend fun refresh(): Result<Unit> = withContext(Dispatchers.IO) {
runCatching { session?.refreshSession(); Unit }
}
override fun close() {
runCatching { session?.close() }
session = null
user = null
}
}
@@ -0,0 +1,59 @@
package com.unibus.app.data
// Datos de muestra para iterar el diseño sin el bus conectado (espejo de mock.ts).
private const val NOW = 1749300000000L
private fun m(n: Int): Long = NOW - n * 60_000L
val MOCK_ROOMS: List<Room> = listOf(
Room(
id = "general",
name = "general",
encrypted = true,
lastMessage = "¿Lo desplegamos hoy?",
lastTs = m(2),
unread = 3,
messages = listOf(
Message("1", "ana", "Buenas, ¿cómo va el cluster?", m(40)),
Message("2", "lucas", "Los 3 nodos en R3, quorum verde", m(38), mine = true),
Message("3", "ana", "Brutal. ¿Y el frontend?", m(30)),
Message("4", "leo", "Primera iteración lista, estilo Element", m(6)),
Message("5", "ana", "¿Lo desplegamos hoy?", m(2)),
),
),
Room(
id = "board",
name = "board · privado",
encrypted = true,
lastMessage = "Os paso el acta cifrada",
lastTs = m(95),
unread = 0,
messages = listOf(
Message("1", "ceo", "Reunión a las 18:00", m(120)),
Message("2", "lucas", "Anotado", m(96), mine = true),
Message("3", "ceo", "Os paso el acta cifrada", m(95)),
),
),
Room(
id = "bots",
name = "bots",
encrypted = false,
lastMessage = "echo: ping",
lastTs = m(210),
unread = 0,
messages = listOf(
Message("1", "lucas", "!ping", m(212), mine = true),
Message("2", "echobot", "echo: ping", m(210)),
),
),
Room(
id = "infra",
name = "infra",
encrypted = true,
lastMessage = "magnus + homer + datardos OK",
lastTs = m(330),
unread = 1,
messages = listOf(
Message("1", "leo", "magnus + homer + datardos OK", m(330)),
),
),
)
@@ -0,0 +1,30 @@
package com.unibus.app.data
/**
* Modelos de dominio de la UI. En la iteración 1 se llenan con datos mock; más
* adelante vendrán del binding gomobile (pkg/client) a través de
* [UnibusRepository]. Reflejan los tipos de la app web (types.ts).
*/
data class User(
val id: String,
val handle: String,
)
data class Message(
val id: String,
val sender: String, // handle
val body: String,
val ts: Long, // epoch ms
val mine: Boolean = false,
)
data class Room(
val id: String,
val name: String,
val encrypted: Boolean,
val lastMessage: String,
val lastTs: Long,
val unread: Int,
val messages: List<Message>,
)
@@ -0,0 +1,74 @@
package com.unibus.app.data
/**
* Capa de repositorio que aísla la UI de la fuente de datos. La iteración 1 usa
* [MockUnibusRepository] (en memoria) para iterar el diseño. Cuando se enchufe
* el bus real, [BindingUnibusRepository] (en BindingRepository.kt) implementa
* esta misma interfaz sobre el binding gomobile (pkg/client), sin tocar la UI.
*/
interface UnibusRepository {
/** Desbloquea/crea la identidad y conecta al bus. Devuelve el usuario logueado. */
suspend fun connect(handle: String, password: String): Result<User>
/** Rooms a las que pertenece el peer. */
suspend fun listRooms(): List<Room>
/** Mensajes históricos conocidos de una room (mock: los del propio Room). */
fun messagesOf(roomId: String): List<Message>
/**
* Suscribe a una room. [onMessage] se invoca por cada mensaje entrante.
* Las implementaciones que vienen del bus DEBEN entregar [onMessage] en el
* hilo principal (el binding lo recibe en una goroutine de NATS).
*/
fun subscribe(roomId: String, onMessage: (Message) -> Unit)
/** Publica texto en la room. */
suspend fun send(roomId: String, text: String): Result<Message>
/** Cierra la sesión. */
fun close()
}
/**
* Implementación en memoria: arranca con [MOCK_ROOMS] y acumula los mensajes que
* el usuario envía. No toca red ni binding — sirve para construir y revisar la UI.
*/
class MockUnibusRepository : UnibusRepository {
private var user: User? = null
private val sent = mutableMapOf<String, MutableList<Message>>()
override suspend fun connect(handle: String, password: String): Result<User> {
val u = User(id = handle, handle = handle)
user = u
return Result.success(u)
}
override suspend fun listRooms(): List<Room> = MOCK_ROOMS
override fun messagesOf(roomId: String): List<Message> {
val base = MOCK_ROOMS.firstOrNull { it.id == roomId }?.messages.orEmpty()
return base + (sent[roomId].orEmpty())
}
override fun subscribe(roomId: String, onMessage: (Message) -> Unit) {
// El mock no recibe tráfico entrante; el eco lo gestiona la UI al enviar.
}
override suspend fun send(roomId: String, text: String): Result<Message> {
val handle = user?.handle ?: "yo"
val msg = Message(
id = "local-${System.currentTimeMillis()}",
sender = handle,
body = text,
ts = System.currentTimeMillis(),
mine = true,
)
sent.getOrPut(roomId) { mutableListOf() }.add(msg)
return Result.success(msg)
}
override fun close() {
user = null
}
}
@@ -0,0 +1,203 @@
package com.unibus.app.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.unibus.app.data.Message
import com.unibus.app.data.Room
import com.unibus.app.ui.theme.Brand3
import com.unibus.app.ui.theme.LocalUnibusColors
@Composable
fun ChatScreen(
room: Room,
messages: List<Message>,
onSend: (String) -> Unit,
onBack: () -> Unit,
) {
val colors = LocalUnibusColors.current
var draft by remember { mutableStateOf("") }
val listState = rememberLazyListState()
LaunchedEffect(messages.size, room.id) {
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(colors.chatBg),
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Atrás", tint = Color.White)
}
InitialsAvatar(room.name, size = 38.dp, rounded = true, accent = true)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
room.name,
fontWeight = FontWeight(650),
fontSize = 16.sp,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
if (room.encrypted) Icons.Filled.Lock else Icons.Filled.Tag,
contentDescription = null,
tint = colors.dimmed,
modifier = Modifier
.padding(start = 6.dp)
.size(14.dp),
)
}
Text(
if (room.encrypted) "cifrada · E2E" else "abierta · cleartext",
color = colors.dimmed,
fontSize = 11.sp,
)
}
IconButton(onClick = { /* opciones de room (futuro) */ }) {
Icon(Icons.Filled.MoreVert, contentDescription = "Opciones", tint = colors.dimmed)
}
}
HorizontalDivider(color = colors.divider)
// Mensajes
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(14.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(messages, key = { it.id }) { msg -> MessageRow(msg) }
}
HorizontalDivider(color = colors.divider)
// Composer
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = { /* adjuntar (futuro) */ }) {
Icon(Icons.Filled.AttachFile, contentDescription = "Adjuntar", tint = colors.dimmed)
}
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
placeholder = { Text("Mensaje a ${room.name}") },
singleLine = true,
shape = CircleShape,
colors = TextFieldDefaults.colors(
focusedContainerColor = colors.field,
unfocusedContainerColor = colors.field,
),
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Send),
keyboardActions = KeyboardActions(onSend = {
if (draft.trim().isNotEmpty()) { onSend(draft); draft = "" }
}),
)
Box(
modifier = Modifier
.padding(start = 6.dp)
.size(46.dp)
.clip(CircleShape)
.background(if (draft.trim().isEmpty()) colors.field else colors.brand),
contentAlignment = Alignment.Center,
) {
IconButton(
onClick = { if (draft.trim().isNotEmpty()) { onSend(draft); draft = "" } },
enabled = draft.trim().isNotEmpty(),
) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Enviar", tint = Color.White)
}
}
}
}
}
@Composable
private fun MessageRow(msg: Message) {
val colors = LocalUnibusColors.current
Row(verticalAlignment = Alignment.Top) {
InitialsAvatar(msg.sender, size = 36.dp, rounded = false, accent = msg.mine)
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.Bottom) {
Text(
msg.sender,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
color = if (msg.mine) Brand3 else Color.White,
)
Text(
timeShort(msg.ts),
color = colors.dimmed,
fontSize = 11.sp,
modifier = Modifier.padding(start = 8.dp),
)
}
Text(
msg.body,
fontSize = 14.sp,
color = com.unibus.app.ui.theme.OnSurface,
modifier = Modifier.padding(top = 1.dp),
)
}
}
}
@@ -0,0 +1,48 @@
package com.unibus.app.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.unibus.app.ui.theme.Brand5
/**
* Avatar con iniciales, equivalente al <Avatar> de la web. [rounded] = esquinas
* (rooms/chat header) vs círculo (usuarios). [accent] colorea el de marca.
*/
@Composable
fun InitialsAvatar(
text: String,
size: Dp = 42.dp,
rounded: Boolean = true,
accent: Boolean = false,
modifier: Modifier = Modifier,
) {
val shape = if (rounded) RoundedCornerShape((size.value * 0.28f).dp) else CircleShape
val bg = if (accent) Brand5 else Color(0xFF3A3D44) // gris neutro tipo Avatar color="gray"
Box(
modifier = modifier
.size(size)
.clip(shape)
.background(bg),
contentAlignment = Alignment.Center,
) {
Text(
text = initials(text),
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = (size.value * 0.36f).sp,
)
}
}
@@ -0,0 +1,154 @@
package com.unibus.app.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.VpnKey
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.unibus.app.ui.theme.Brand4
import com.unibus.app.ui.theme.Dark7
import com.unibus.app.ui.theme.Dark9
import com.unibus.app.ui.theme.LocalUnibusColors
@Composable
fun LoginScreen(
connecting: Boolean,
error: String?,
onLogin: (handle: String, password: String) -> Unit,
) {
val colors = LocalUnibusColors.current
var handle by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val ready = handle.trim().isNotEmpty() && password.isNotEmpty() && !connecting
fun submit() {
if (ready) onLogin(handle.trim(), password)
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Dark9),
contentAlignment = Alignment.Center,
) {
Card(
modifier = Modifier
.padding(24.dp)
.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Dark7),
shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
// ThemeIcon "light brand" — círculo translúcido con candado.
Box(
modifier = Modifier
.size(60.dp)
.clip(CircleShape)
.background(Brand4.copy(alpha = 0.18f)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.Lock,
contentDescription = null,
tint = Brand4,
modifier = Modifier.size(30.dp),
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("unibus", fontSize = 26.sp, color = Brand4)
Text(
"Mensajería cifrada de extremo a extremo",
color = colors.dimmed,
fontSize = 13.sp,
textAlign = TextAlign.Center,
)
}
OutlinedTextField(
value = handle,
onValueChange = { handle = it },
label = { Text("Identidad") },
placeholder = { Text("tu-handle") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Contraseña") },
placeholder = { Text("••••••••") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
leadingIcon = { Icon(Icons.Filled.VpnKey, contentDescription = null) },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { submit() }),
)
Text(
"Desbloquea tu identidad cifrada en este dispositivo",
color = colors.dimmed,
fontSize = 12.sp,
modifier = Modifier.fillMaxWidth(),
)
if (error != null) {
Text(error, color = androidx.compose.ui.graphics.Color(0xFFFF6B6B), fontSize = 13.sp)
}
Button(
onClick = { submit() },
enabled = ready,
modifier = Modifier.fillMaxWidth(),
) {
if (connecting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = androidx.compose.ui.graphics.Color.White,
)
} else {
Text("Conectar")
}
}
}
}
}
}
@@ -0,0 +1,199 @@
package com.unibus.app.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.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.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.Badge
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.unibus.app.data.Room
import com.unibus.app.data.User
import com.unibus.app.ui.theme.LocalUnibusColors
@Composable
fun RoomListScreen(
user: User,
rooms: List<Room>,
onSelect: (String) -> Unit,
onLogout: () -> Unit,
) {
val colors = LocalUnibusColors.current
var query by remember { mutableStateOf("") }
val q = query.trim().lowercase()
val filtered = if (q.isEmpty()) rooms else rooms.filter {
it.name.lowercase().contains(q) || it.messages.any { m -> m.body.lowercase().contains(q) }
}
Column(
modifier = Modifier
.fillMaxSize()
.background(colors.sidebarBg),
) {
// Header: avatar + handle + menú
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
InitialsAvatar(user.handle, size = 36.dp, rounded = false, accent = true)
Text(
user.handle,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(start = 10.dp),
)
var menuOpen by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = "Menú", tint = colors.dimmed)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text("Desconectar") },
onClick = { menuOpen = false; onLogout() },
leadingIcon = {
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null, modifier = Modifier.size(18.dp))
},
)
}
}
}
// Buscador
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Buscar rooms, usuarios, mensajes…") },
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null, modifier = Modifier.size(18.dp)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
)
HorizontalDivider(color = colors.divider)
if (filtered.isEmpty()) {
Text(
"Sin resultados",
color = colors.dimmed,
fontSize = 14.sp,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(6.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
items(filtered, key = { it.id }) { room ->
RoomItem(room = room, onClick = { onSelect(room.id) })
}
}
}
}
}
@Composable
private fun RoomItem(room: Room, onClick: () -> Unit) {
val colors = LocalUnibusColors.current
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.clickable(onClick = onClick)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
InitialsAvatar(room.name, size = 46.dp, rounded = true)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (room.encrypted) Icons.Filled.Lock else Icons.Filled.Tag,
contentDescription = if (room.encrypted) "cifrada" else "abierta",
tint = colors.dimmed,
modifier = Modifier.size(13.dp),
)
Text(
room.name,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(start = 4.dp),
)
Text(timeShort(room.lastTs), color = colors.dimmed, fontSize = 11.sp)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 2.dp),
) {
Text(
room.lastMessage,
color = colors.dimmed,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (room.unread > 0) {
Badge(
containerColor = colors.brand,
contentColor = Color.White,
) { Text(room.unread.toString()) }
}
}
}
}
}
@@ -0,0 +1,17 @@
package com.unibus.app.ui
import java.util.Calendar
/** Iniciales (hasta 2 letras/dígitos) para los avatares, igual que la web. */
fun initials(s: String): String {
val cleaned = s.filter { it.isLetterOrDigit() }
return if (cleaned.isEmpty()) "?" else cleaned.take(2).uppercase()
}
/** Hora corta HH:mm a partir de epoch ms. */
fun timeShort(ts: Long): String {
val c = Calendar.getInstance().apply { timeInMillis = ts }
val h = c.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
val min = c.get(Calendar.MINUTE).toString().padStart(2, '0')
return "$h:$min"
}
@@ -0,0 +1,80 @@
package com.unibus.app.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// --- Brand: índigo/violeta de unibus (mismos tonos que el tema Mantine de la web) ---
val Brand2 = Color(0xFFB5A3F5) // brand.2
val Brand3 = Color(0xFF8D70ED) // brand.3 — legible sobre fondo oscuro
val Brand4 = Color(0xFF6C47E6) // brand.4 — acento principal
val Brand5 = Color(0xFF5A2FE2) // brand.5 — filled
// --- Grises oscuros equivalentes a la escala dark.* de Mantine ---
val Dark9 = Color(0xFF101113) // fondo de la app (login)
val Dark8 = Color(0xFF141517) // sidebar / lista de rooms
val Dark7 = Color(0xFF1A1B1E) // panel de chat / superficie
val Dark6 = Color(0xFF25262B) // item activo / elevado
val Dark5 = Color(0xFF2C2E33) // campos de entrada
val Dark4 = Color(0xFF373A40) // bordes / divisores
val Dimmed = Color(0xFF909296) // texto secundario
val OnSurface = Color(0xFFE3E3E6) // texto principal
/**
* Tokens de color que Material 3 no expresa directamente y que la UI replica de
* la web (matices dark.6/7/8/9, color "dimmed", borde). Se exponen vía un
* CompositionLocal para que cualquier composable los lea sin prop-drilling.
*/
data class UnibusColors(
val appBg: Color = Dark9,
val sidebarBg: Color = Dark8,
val chatBg: Color = Dark7,
val activeItem: Color = Dark6,
val field: Color = Dark5,
val divider: Color = Dark4,
val dimmed: Color = Dimmed,
val brand: Color = Brand4,
)
val LocalUnibusColors = staticCompositionLocalOf { UnibusColors() }
private val UnibusDarkScheme = darkColorScheme(
primary = Brand4,
onPrimary = Color.White,
primaryContainer = Brand5,
onPrimaryContainer = Color.White,
secondary = Brand3,
background = Dark9,
onBackground = OnSurface,
surface = Dark7,
onSurface = OnSurface,
surfaceVariant = Dark6,
onSurfaceVariant = Dimmed,
outline = Dark4,
error = Color(0xFFFF6B6B),
)
private val UnibusTypography = Typography(
titleLarge = Typography().titleLarge.copy(fontWeight = FontWeight(650)),
titleMedium = Typography().titleMedium.copy(fontWeight = FontWeight(650)),
bodyMedium = Typography().bodyMedium.copy(fontSize = 14.sp),
labelLarge = Typography().labelLarge.copy(fontWeight = FontWeight.SemiBold),
)
@Composable
fun UnibusTheme(content: @Composable () -> Unit) {
// unibus es dark-first; ignoramos el modo del sistema a propósito.
@Suppress("UNUSED_EXPRESSION")
isSystemInDarkTheme()
MaterialTheme(
colorScheme = UnibusDarkScheme,
typography = UnibusTypography,
content = content,
)
}
@@ -0,0 +1,18 @@
<?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">
<!-- Material "lock" glyph, white, centered in the adaptive-icon safe zone.
24dp source scaled x3 (=72dp) and translated by 18 to center it. -->
<group
android:scaleX="3"
android:scaleY="3"
android:translateX="18"
android:translateY="18">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,17c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM18,8h-1V6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6v2H6c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V10c0,-1.1 -0.9,-2 -2,-2zM9,6c0,-1.66 1.34,-3 3,-3s3,1.34 3,3v2H9V6z" />
</group>
</vector>
@@ -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/unibus_brand" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -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/unibus_brand" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- dark.9 — app background -->
<color name="unibus_bg">#101113</color>
<!-- brand.5 — índigo/violeta accent, used as launcher icon background -->
<color name="unibus_brand">#5A2FE2</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">unibus</string>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Compose-only host theme: no action bar, dark window background matching
the app's dark.9 surface so there is no white flash before Compose draws. -->
<style name="Theme.Unibus" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/unibus_bg</item>
<item name="android:statusBarColor">@color/unibus_bg</item>
<item name="android:navigationBarColor">@color/unibus_bg</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>
+5
View File
@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false
}
+5
View File
@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
org.gradle.caching=true
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+249
View File
@@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+24
View File
@@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "unibus"
include(":app")