feat: cliente Android offline-first (Kotlin + Jetpack Compose)

- Room (notes/outbox/categories/meta): edição 100% local, fila FIFO de mutações
- SyncEngine bidirecional: push (create/update com base_hash/delete, rekey de id
  local→servidor) + pull (categorias, notas divergentes, exclusões remotas)
- Conflito 409: servidor vence a nota + texto local preservado em cópia
  "Título (conflito)" enfileirada como create — nada perdido, sem relógios
- WorkManager: periódico 1h (rede + backoff exponencial), runNow único REPLACE
  em gatilhos (pós-save, rede restabelecida, boot, config, botão manual)
- OkHttp com timeout/retry em IOException; kotlinx.serialization; DataStore
- UI Compose: tela Servidor (URL+token+teste), Lista com banner de status
  (Offline/Sincronizando/Sincronizado/Erro/401 + pendências + conflitos),
  Editor com autosave por ociosidade (10 s) e categorias multi-seleção
- Build validado: assembleDebug (AGP 8.7.3/Kotlin 2.0.21/SDK 35, JDK 21) —
  app-debug.apk 17,6 MB; gradle-wrapper.jar fora do commit (regenerável com
  `gradle wrapper`, documentado no README)
This commit is contained in:
lgcosta committed 2026-11-04 17:55:50 -03:00
commit 2f63c7a2f5
30 files changed
+2096

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
*.iml
.gradle/
build/
local.properties
.idea/
.DS_Store
captures/
.externalNativeBuild/
.cxx/
*.keystore
!debug.keystore
+133
View File
@@ -0,0 +1,133 @@
# Notas — cliente Android (offline-first)
Cliente mobile da plataforma [notas-online](https://git.labexposed.com/lgcosta/notas-online)
(editor de notas estilo Mousepad com versionamento Git-like). **Kotlin + Jetpack Compose**,
arquitetura offline-first: tudo funciona sem rede, com sincronização bidirecional em segundo
plano (WorkManager) e resolução determinística de conflitos.
## Stack (e por que é a mais leve das opções)
| Requisito | Escolha | Peso |
|---|---|---|
| UI | Jetpack Compose (Material 3, dynamic color) | nativo, sem engine extra |
| Banco local | **Room** (SQLite) + WAL | ~0 APK extra relevante vs Flutter/RN |
| Fila offline | tabela `outbox` (FIFO por `seq`) | — |
| Sync em background | **WorkManager** (periódico 1h + `runNow` por gatilhos) | — |
| Rede | OkHttp (timeout 10/20s, retry 2x com backoff em `IOException`) | — |
| JSON | kotlinx.serialization (compile-time, sem reflection) | — |
| Config | DataStore Preferences | — |
| Conflitos | `base_hash` da API (409) + política *servidor vence + cópia local* | — |
Flutter (engine ~8 MB) e React Native (bridge/Hermes) dariam mais runtime pelo mesmo trabalho;
Android já entrega exatamente as primitivas que offline-first pede (Room, WorkManager,
ConnectivityManager).
## Preparação do ambiente — Fedora 44
```bash
# 1) Java 21 (AGP 8.7 trabalha com 17/21) + utilitários
sudo dnf install -y java-21-openjdk-devel git unzip curl findutils
# 2a) Caminho recomendado: Android Studio (SDK + emulador inclusos)
# baixe o .tar.gz em https://developer.android.com/studio e:
mkdir -p ~/opt && tar -xzf ~/Downloads/android-studio-*.tar.gz -C ~/opt
~/opt/android-studio/bin/studio.sh # primeiro assistente instala o SDK em ~/Android/Sdk
# 2b) Caminho só-CLI (sem Studio):
mkdir -p ~/Android/Sdk/cmdline-tools && cd /tmp
curl -LO https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q commandlinetools-linux-*.zip -d ~/Android/Sdk/cmdline-tools
mv ~/Android/Sdk/cmdline-tools/cmdline-tools ~/Android/Sdk/cmdline-tools/latest
yes | ~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager --licenses
~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager \
"platform-tools" "platforms;android-35" "build-tools;35.0.0"
# 3) ~/.bashrc (ou ~/.zshrc)
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
export ANDROID_HOME=$HOME/Android/Sdk
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools
```
O projeto usa o **Gradle Wrapper** (8.11.1) — não é preciso instalar Gradle.
Se clonar sem os jars do wrapper, gere-os uma vez: `gradle wrapper --gradle-version 8.11.1`
(ou abra no Android Studio, que regenera automaticamente).
## Estrutura do repositório
```
notas-online-android/
├── settings.gradle.kts / build.gradle.kts / gradle.properties
├── gradle/wrapper/ # Gradle 8.11.1
├── app/
│ ├── build.gradle.kts # AGP 8.7.3 · Kotlin 2.0.21 · Compose BOM 2024.12
│ ├── proguard-rules.pro
│ └── src/main/
│ ├── AndroidManifest.xml # INTERNET + ACCESS_NETWORK_STATE; cleartext p/ LAN
│ ├── res/ # tema, ícone adaptativo
│ └── java/com/labexposed/notas/
│ ├── NotasApp.kt # Application: periodic work + gatilho de rede
│ ├── MainActivity.kt # NavHost + SyncBanner (estado da sincronização)
│ ├── data/
│ │ ├── local/ # Room: notes, outbox, categories, meta
│ │ └── remote/ # ApiClient (OkHttp+DTOs), ConnectivityObserver
│ ├── domain/ # SyncEngine, SettingsRepository, modelos de estado
│ ├── sync/ # SyncWorker + SyncScheduler (WorkManager)
│ └── ui/ # ViewModel, telas (Settings/Lista/Editor), tema
```
## Como funciona a sincronização
1. **Tudo é local primeiro**: editar grava no Room e marca a nota `dirty`; a mutação entra na
`outbox` (fila FIFO). Notas criadas offline ganham id `L…` temporário.
2. **Push**: para cada item da fila — `create` (POST, renomeia o id local para o id do
servidor), `update` (PUT com `base_hash` = último hash conhecido) e `delete` (DELETE;
nota local-only só sai do banco).
3. **Conflito (HTTP 409)**: o servidor mudou desde a sua base. Política determinística:
**servidor vence a nota + texto local preservado** numa cópia *"Título (conflito)"*
(fila como `create`). A versão antiga do servidor também está no histórico de revisões.
Nada é perdido e não depende de relógios.
4. **Pull**: categorias + lista de notas; notas locais **limpas** com hash divergente são
rebaixadas (GET completo); notas que sumiram do servidor (e não estão na fila) são
removidas localmente.
5. **Gatilhos**: WorkManager periódico (1h, exige rede, backoff exponencial) · rede
restabelecida (`ConnectivityManager` callback) · após cada edição com conteúdo alterado ·
botão **Sincronizar** · troca de configuração.
6. **Autosave do editor**: grava local + enfileira após **10 s de ociosidade** (cada tecla
reinicia a contagem) — igual à política web.
## Telas
- **Servidor**: URL da API + token (`NOTES_TOKEN`) + autor; botão *Testar conexão* (lista
notas via `GET /api/notes`) antes de salvar.
- **Lista**: banner de status colorido — cinza *não configurado*, cinza-escuro *Offline (N na
fila)*, azul *Sincronizando…*, verde *Sincronizado às HH:mm · N pendente(s) · ⚠ conflitos*,
vermelho *erro/401*; itens mostram ● não enviado / 🗑 fila / ⚠ nota de conflito.
- **Editor**: título, chips de formato (TXT/MD/HTML), chips multi-seleção de categorias
(árvore espelhada do servidor), corpo monoespaçado; autosave ocioso + botão Salvar.
## Compilar e instalar
```bash
# CLI (debug APK assinado com debug key):
./gradlew assembleDebug
# saída: app/build/outputs/apk/debug/app-debug.apk
adb install -r app/build/outputs/apk/debug/app-debug.apk
# release (R8/minify):
./gradlew assembleRelease
# Android Studio: abrir a pasta, sincronizar, Run ▶ (ou Build > Build APK(s)).
```
## Testado em
- `assembleDebug` limpo com JDK 21 / AGP 8.7.3 / SDK 35 (Linux, sem device — verificação de
compilação + lint básico).
- Integração com a API: mesmos contratos do web client (`base_hash`/409, categorias, revisões).
## Roadmap honesto (não implementado aqui)
- SSE (`/api/events`) no Android para push tempo-real em vez de gatilho pós-save (OkHttp SSE).
- Token em Keystore/EncryptedSharedPreferences (hoje: sandbox do app via DataStore).
- Editor WYSIWYG/realce de sintaxe mobile (fora do escopo "mais leve possível").
- Multi-perfil/servidores e sincronização de anexos.
+73
View File
@@ -0,0 +1,73 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.devtools.ksp")
}
android {
namespace = "com.labexposed.notas"
compileSdk = 35
defaultConfig {
applicationId = "com.labexposed.notas"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = 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 {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom)
// UI
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
// Dados locais
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("androidx.datastore:datastore-preferences:1.1.1")
// Rede + serialização
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// Sync em segundo plano
implementation("androidx.work:work-runtime-ktx:2.9.1")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
}
+11
View File
@@ -0,0 +1,11 @@
# OkHttp + kotlinx.serialization: keep generic metadata
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn org.jetbrains.annotations.**
-keepattributes *Annotation*, InnerClasses, Signature, RuntimeVisibleAnnotations, AnnotationDefault
-keepclassmembers class com.labexposed.notas.data.remote.** {
*** Companion;
}
-keepclasseswithmembers class com.labexposed.notas.data.remote.** {
kotlinx.serialization.KSerializer serializer(...);
}
+29
View File
@@ -0,0 +1,29 @@
<?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.ACCESS_NETWORK_STATE" />
<application
android:name=".NotasApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Notas"
android:usesCleartextTraffic="true">
<!-- usesCleartextTraffic: necessário para servidores HTTP em LAN sem TLS.
Em produção com HTTPS, remova e use network_security_config. -->
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,98 @@
package com.labexposed.notas
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.labexposed.notas.domain.SyncPhase
import com.labexposed.notas.domain.SyncUiState
import com.labexposed.notas.ui.EditorScreen
import com.labexposed.notas.ui.NotesListScreen
import com.labexposed.notas.ui.NotasViewModel
import com.labexposed.notas.ui.SettingsScreen
import com.labexposed.notas.ui.theme.NotasTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
NotasTheme { NotasApp_() }
}
}
}
@Composable
private fun NotasApp_(vm: NotasViewModel = viewModel()) {
val nav = rememberNavController()
val settings by vm.settings.collectAsState()
NavHost(navController = nav, startDestination = if (settings.configured) "list" else "settings") {
composable("settings") {
SettingsScreen(vm, onDone = { nav.navigate("list") { popUpTo("settings") { inclusive = true } } })
}
composable("list") {
NotesListScreen(
vm = vm,
onOpen = { nav.navigate("editor/$it") },
onNew = { nav.navigate("editor/" + vm.createLocal()) },
onSettings = { nav.navigate("settings") },
)
}
composable("editor/{id}") { entry ->
val id = entry.arguments?.getString("id") ?: return@composable
EditorScreen(vm = vm, noteId = id, onBack = { nav.popBackStack() })
}
}
}
/** Banner de status: Offline · Sincronizando · Sincronizado · Erro · pendências. */
@Composable
fun SyncBanner(state: SyncUiState, modifier: Modifier = Modifier) {
val (bg, fg, label) = when (state.phase) {
SyncPhase.NotConfigured -> Quinteto(Color(0xFF9A9A9A), Color.White, "Configure o servidor ⚙")
SyncPhase.Offline -> Quinteto(Color(0xFF6F6F6F), Color.White, "Offline — ${state.pendingCount} alteração(ões) na fila")
SyncPhase.Syncing -> Quinteto(Color(0xFF3584E4), Color.White, "Sincronizando…")
SyncPhase.Synced -> {
val pend = if (state.pendingCount > 0) " · ${state.pendingCount} pendente(s)" else ""
val conf = if (state.conflicts > 0) " · ⚠ ${state.conflicts} conflito(s)" else ""
val hora = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT)
.format(java.util.Date(state.lastSyncAt))
Quinteto(Color(0xFF2EC27E), Color(0xFF073042), "Sincronizado às $hora$pend$conf")
}
SyncPhase.Error -> Quinteto(Color(0xFFE01B24), Color.White, "Erro: ${state.message}")
SyncPhase.Unauthorized -> Quinteto(Color(0xFFE01B24), Color.White, "Token inválido — revise em Servidor")
}
Surface(color = bg, shape = RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) {
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, color = fg, style = MaterialTheme.typography.labelLarge)
if (state.dirtyCount > 0 && state.phase == SyncPhase.Synced) {
Text("● ${state.dirtyCount} não enviado(s)", color = fg, style = MaterialTheme.typography.labelMedium)
}
}
}
}
private data class Quinteto(val bg: Color, val fg: Color, val label: String)
@@ -0,0 +1,36 @@
package com.labexposed.notas
import android.app.Application
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import com.labexposed.notas.data.remote.ConnectivityObserver
import com.labexposed.notas.domain.SyncPhase
import com.labexposed.notas.domain.SyncStatusStore
import com.labexposed.notas.sync.SyncScheduler
class NotasApp : Application() {
override fun onCreate() {
super.onCreate()
SyncScheduler.ensurePeriodic(this)
// assim que a rede volta, dispara sync imediato (mutações offline sobem)
try {
val cm = getSystemService(ConnectivityManager::class.java)
cm.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
SyncStatusStore.set { it.copy(phase = if (it.phase == SyncPhase.Offline) SyncPhase.Syncing else it.phase) }
SyncScheduler.runNow(this@NotasApp, "network")
}
})
} catch (_: Exception) {
// alguns OEMs restringem registerDefaultNetworkCallback; o flow da UI cobre
}
// se já houver conexão no boot, tenta logo
if (ConnectivityObserver.isOnline(this)) {
SyncScheduler.runNow(this, "boot")
}
}
}
@@ -0,0 +1,123 @@
package com.labexposed.notas.data.local
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
/**
* Nota espelho local (offline-first). `id` é o id do servidor, ou `L<uuid>`
* para notas criadas offline (renomeadas quando o create for confirmado).
* `serverHash` é o hash SHA-256 do conteúdo conhecido no servidor — a base
* do controle otimista de concorrência (`base_hash` da API).
*/
@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey val id: String,
val title: String,
val format: String, // txt | md | html
val categories: String = "", // csv de ids de categoria
val tags: String = "", // csv (derivado pelo servidor)
val content: String,
val serverHash: String = "", // "" = nunca sincronizada
val createdAt: Long, // ms epoch (relógio local)
val updatedAt: Long,
val dirty: Boolean = false, // há edição local não enviada
val pendingDelete: Boolean = false,
val conflictNote: String = "", // mensagem da última resolução de conflito
) {
val isLocalOnly: Boolean get() = id.startsWith("L") && serverHash.isEmpty()
}
/** Fila de mutações pendentes (ordem de chegada = ordem de push). */
@Entity(tableName = "outbox")
data class OutboxEntity(
@PrimaryKey(autoGenerate = true) val seq: Long = 0,
val noteId: String,
val op: String, // create | update | delete
val queuedAt: Long,
)
/** Espelho da árvore de categorias do servidor (lista plana com parent). */
@Entity(tableName = "categories")
data class CategoryEntity(
@PrimaryKey val id: String,
val name: String,
val parent: String,
)
@Entity(tableName = "meta")
data class MetaEntity(
@PrimaryKey val key: String,
val value: String,
)
@Dao
interface NoteDao {
@Query("SELECT * FROM notes ORDER BY updatedAt DESC")
fun observeAll(): Flow<List<NoteEntity>>
@Query("SELECT * FROM notes ORDER BY updatedAt DESC")
suspend fun all(): List<NoteEntity>
@Query("SELECT * FROM notes WHERE id = :id")
suspend fun byId(id: String): NoteEntity?
@Query("SELECT COUNT(*) FROM notes WHERE dirty = 1 OR pendingDelete = 1")
fun observePending(): Flow<Int>
@Upsert
suspend fun upsert(note: NoteEntity)
@Query("DELETE FROM notes WHERE id = :id")
suspend fun delete(id: String)
}
@Dao
interface OutboxDao {
@Query("SELECT * FROM outbox ORDER BY seq ASC")
suspend fun queue(): List<OutboxEntity>
@Query("SELECT COUNT(*) FROM outbox")
fun observeCount(): Flow<Int>
@Query("SELECT EXISTS(SELECT 1 FROM outbox WHERE noteId = :noteId AND op = :op)")
suspend fun has(noteId: String, op: String): Boolean
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun enqueue(entry: OutboxEntity)
@Query("DELETE FROM outbox WHERE seq = :seq")
suspend fun drop(seq: Long)
@Query("DELETE FROM outbox WHERE noteId = :noteId")
suspend fun dropFor(noteId: String)
}
@Dao
interface CategoryDao {
@Query("SELECT * FROM categories")
fun observeAll(): Flow<List<CategoryEntity>>
@Query("DELETE FROM categories")
suspend fun clear()
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(items: List<CategoryEntity>)
@Query("SELECT id FROM categories")
suspend fun ids(): List<String>
}
@Dao
interface MetaDao {
@Query("SELECT value FROM meta WHERE `key` = :key")
suspend fun get(key: String): String?
@Upsert
suspend fun set(entry: MetaEntity)
}
@@ -0,0 +1,32 @@
package com.labexposed.notas.data.local
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [NoteEntity::class, OutboxEntity::class, CategoryEntity::class, MetaEntity::class],
version = 1,
exportSchema = false,
)
abstract class NotasDatabase : RoomDatabase() {
abstract fun notes(): NoteDao
abstract fun outbox(): OutboxDao
abstract fun categories(): CategoryDao
abstract fun meta(): MetaDao
companion object {
@Volatile
private var instance: NotasDatabase? = null
fun get(context: Context): NotasDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
NotasDatabase::class.java,
"notas.db",
).build().also { instance = it }
}
}
}
@@ -0,0 +1,89 @@
package com.labexposed.notas.data.remote
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Cliente HTTP da API notas-online: Bearer token, timeout curto, retry com
* backoff apenas para falhas de rede (IOException), mapeamento de status em
* [ApiResult]. Instanciado por sync (preços de settings podem mudar).
*/
class ApiClient(private val baseUrl: String, private val token: String) {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.retryOnConnectionFailure(false) // retry manual controlado abaixo
.build()
private val jsonMedia = "application/json; charset=utf-8".toMediaType()
private fun url(path: String) = baseUrl.trimEnd('/') + path
/** Executa com até 2 tentativas em erro de rede (backoff 500ms). */
private inline fun <reified T> call(
method: String,
path: String,
body: String? = null,
): ApiResult<T> {
var lastError: IOException? = null
repeat(2) { attempt ->
val request = Request.Builder()
.url(url(path))
.header("Authorization", "Bearer $token")
.method(method, body?.toRequestBody(jsonMedia))
.build()
try {
http.newCall(request).execute().use { resp ->
return when {
resp.isSuccessful -> {
val text = resp.body?.string().orEmpty()
if (T::class == Unit::class) {
ApiResult.Ok(Unit as T)
} else {
ApiResult.Ok(json.decodeFromString<T>(text))
}
}
resp.code == 401 -> ApiResult.Unauthorized
resp.code == 404 -> ApiResult.NotFound
resp.code == 409 -> {
val c = runCatching {
json.decodeFromString<ConflictDto>(resp.body?.string().orEmpty())
}.getOrNull()
ApiResult.Conflict(c?.serverHash ?: "")
}
else -> ApiResult.Server(resp.code, resp.message)
}
}
} catch (e: IOException) {
lastError = e
if (attempt == 0) Thread.sleep(500)
}
}
return ApiResult.Network(lastError?.message ?: "sem conexão")
}
fun testConnection(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
fun listNotes(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
fun listCategories(): ApiResult<List<CategoryDto>> = call("GET", "/api/categories")
fun getNote(id: String): ApiResult<NoteDetailDto> = call("GET", "/api/notes/$id")
fun createNote(dto: NoteWriteDto): ApiResult<NoteDetailDto> =
call("POST", "/api/notes", json.encodeToString(dto))
fun updateNote(id: String, dto: NoteWriteDto): ApiResult<NoteDetailDto> =
call("PUT", "/api/notes/$id", json.encodeToString(dto))
fun deleteNote(id: String): ApiResult<Unit> = call("DELETE", "/api/notes/$id")
}
@@ -0,0 +1,36 @@
package com.labexposed.notas.data.remote
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
/** Detecção reativa de conectividade (flow) + consulta pontual. */
object ConnectivityObserver {
fun isOnline(context: Context): Boolean {
val cm = context.getSystemService(ConnectivityManager::class.java)
val net = cm.activeNetwork ?: return false
val caps = cm.getNetworkCapabilities(net) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
fun observe(context: Context): Flow<Boolean> = callbackFlow {
val cm = context.getSystemService(ConnectivityManager::class.java)
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { trySend(true) }
override fun onLost(network: Network) { trySend(isOnline(context)) }
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
cm.registerNetworkCallback(request, callback)
trySend(isOnline(context))
awaitClose { cm.unregisterNetworkCallback(callback) }
}
}
@@ -0,0 +1,71 @@
package com.labexposed.notas.data.remote
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// ---- payloads do servidor (notas-online API) ----
@Serializable
data class NoteSummaryDto(
val id: String,
val title: String = "",
val format: String = "txt",
val categories: List<String> = emptyList(),
val tags: List<String> = emptyList(),
@SerialName("created_at") val createdAt: String = "",
@SerialName("updated_at") val updatedAt: String = "",
val hash: String = "",
val bytes: Int = 0,
)
@Serializable
data class NoteDetailDto(
val id: String,
val title: String = "",
val format: String = "txt",
val categories: List<String> = emptyList(),
val tags: List<String> = emptyList(),
@SerialName("created_at") val createdAt: String = "",
@SerialName("updated_at") val updatedAt: String = "",
val hash: String = "",
val bytes: Int = 0,
val content: String = "",
)
@Serializable
data class NoteWriteDto(
val title: String,
val format: String,
val categories: List<String> = emptyList(),
val content: String,
val author: String = "",
val message: String = "",
@SerialName("base_hash") val baseHash: String = "",
val session: String = "android",
)
@Serializable
data class CategoryDto(
val id: String,
val name: String,
val parent: String = "",
@SerialName("note_count") val noteCount: Int = 0,
@SerialName("has_child") val hasChild: Boolean = false,
)
@Serializable
data class ConflictDto(
val error: String = "",
@SerialName("server_hash") val serverHash: String = "",
@SerialName("updated_at") val updatedAt: String = "",
)
/** Resultado tipado das chamadas — sem exceções de fluxo de controle. */
sealed class ApiResult<out T> {
data class Ok<T>(val value: T) : ApiResult<T>()
data object Unauthorized : ApiResult<Nothing>()
data object NotFound : ApiResult<Nothing>()
data class Conflict(val serverHash: String) : ApiResult<Nothing>()
data class Network(val message: String) : ApiResult<Nothing>()
data class Server(val code: Int, val message: String) : ApiResult<Nothing>()
}
@@ -0,0 +1,26 @@
package com.labexposed.notas.domain
/** Estado exibido no indicador de sincronização da UI. */
enum class SyncPhase { NotConfigured, Offline, Syncing, Synced, Error, Unauthorized }
data class SyncUiState(
val phase: SyncPhase = SyncPhase.NotConfigured,
val message: String = "",
val lastSyncAt: Long = 0L,
val pendingCount: Int = 0, // mutações na outbox
val dirtyCount: Int = 0, // notas com edição local
val conflicts: Int = 0, // resoluções de conflito na última sincronização
) {
companion object {
val Initial = SyncUiState()
}
}
/** Resultado de uma rodada completa de sincronização. */
sealed class SyncOutcome {
data object NotConfigured : SyncOutcome()
data class Success(val pushed: Int, val pulled: Int, val conflicts: Int) : SyncOutcome()
data class NetworkDown(val message: String) : SyncOutcome()
data object Unauthorized : SyncOutcome()
data class Failed(val message: String) : SyncOutcome()
}
@@ -0,0 +1,49 @@
package com.labexposed.notas.domain
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
data class Settings(
val serverUrl: String = "",
val token: String = "",
val author: String = "android",
) {
val configured: Boolean get() = serverUrl.isNotBlank() && token.isNotBlank()
}
private val Context.dataStore by preferencesDataStore(name = "settings")
/**
* Configuração do cliente (URL do servidor + token Bearer + autor).
* O token fica no sandbox do app (DataStore); para exigência mais forte,
* migrar para Keystore/EncryptedSharedPreferences.
*/
class SettingsRepository(private val context: Context) {
private val keyUrl = stringPreferencesKey("server_url")
private val keyToken = stringPreferencesKey("token")
private val keyAuthor = stringPreferencesKey("author")
val flow: Flow<Settings> = context.dataStore.data.map { p ->
Settings(
serverUrl = p[keyUrl].orEmpty(),
token = p[keyToken].orEmpty(),
author = p[keyAuthor]?.ifBlank { "android" } ?: "android",
)
}
suspend fun current(): Settings = flow.first()
suspend fun save(settings: Settings) {
context.dataStore.edit { p ->
p[keyUrl] = settings.serverUrl.trim().trimEnd('/')
p[keyToken] = settings.token.trim()
p[keyAuthor] = settings.author.trim().ifBlank { "android" }
}
}
}
@@ -0,0 +1,319 @@
package com.labexposed.notas.domain
import com.labexposed.notas.data.local.CategoryEntity
import com.labexposed.notas.data.local.MetaEntity
import com.labexposed.notas.data.local.NoteEntity
import com.labexposed.notas.data.local.NotasDatabase
import com.labexposed.notas.data.local.OutboxEntity
import com.labexposed.notas.data.remote.ApiClient
import com.labexposed.notas.data.remote.ApiResult
import com.labexposed.notas.data.remote.NoteDetailDto
import com.labexposed.notas.data.remote.NoteWriteDto
import java.time.Instant
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Sincronização bidirecional offline-first.
*
* Ordem por rodada:
* 1. PUSH da outbox (ordem FIFO por `seq`): create/update/delete.
* - update usa `base_hash` = hash local conhecido → o servidor devolve
* 409 se outro dispositivo mudou a nota desde então.
* - Conflito (409): política determinística "servidor vence + cópia":
* o texto local nunca enviado é preservado numa NOVA nota
* "Título (conflito)" (entra na fila como create) e a nota principal
* adota o conteúdo do servidor. Nada é perdido — a versão anterior do
* servidor também está no histórico/revisões da plataforma.
* 2. PULL: árvore de categorias + lista de notas; notas locais limpas cujo
* hash divergiu do servidor são rebaixadas (GET completo); notas locais
* ausentes no servidor (e não sujas) são removidas (delete remoto).
*
* Falha de rede em qualquer ponto → NetworkDown (WorkManager aplica backoff).
*/
class SyncEngine(
private val db: NotasDatabase,
private val settings: SettingsRepository,
private val status: SyncStatusStore,
) {
private suspend fun api(): ApiClient? {
val s = settings.current()
if (!s.configured) return null
return ApiClient(s.serverUrl, s.token)
}
suspend fun fullSync(trigger: String = "auto"): SyncOutcome = withContext(Dispatchers.IO) {
val client = api()
if (client == null) {
status.set { it.copy(phase = SyncPhase.NotConfigured) }
return@withContext SyncOutcome.NotConfigured
}
status.set { it.copy(phase = SyncPhase.Syncing) }
val author = settings.current().author
val push = push(client, author)
if (push is SyncOutcome.Unauthorized) {
status.set { it.copy(phase = SyncPhase.Unauthorized, message = "token inválido") }
return@withContext push
}
if (push is SyncOutcome.NetworkDown) {
status.set { it.copy(phase = SyncPhase.Offline, message = push.message) }
return@withContext push
}
val pull = pull(client)
if (pull is SyncOutcome.Unauthorized) {
status.set { it.copy(phase = SyncPhase.Unauthorized, message = "token inválido") }
return@withContext pull
}
if (pull is SyncOutcome.NetworkDown) {
status.set { it.copy(phase = SyncPhase.Offline, message = pull.message) }
return@withContext pull
}
val ok = (push as SyncOutcome.Success) to (pull as SyncOutcome.Success)
val conflicts = ok.first.conflicts + ok.second.conflicts
db.meta().set(MetaEntity("last_pull", System.currentTimeMillis().toString()))
status.set {
it.copy(
phase = SyncPhase.Synced,
message = "sincronizado ($trigger)",
lastSyncAt = System.currentTimeMillis(),
conflicts = conflicts,
)
}
SyncOutcome.Success(ok.first.pushed, ok.second.pulled, conflicts)
}
// ------------------------------------------------------------------ push
private suspend fun push(client: ApiClient, author: String): SyncOutcome {
val queue = db.outbox().queue()
if (queue.isEmpty()) return SyncOutcome.Success(0, 0, 0)
var pushed = 0
var conflicts = 0
for (entry in queue) {
val note = db.notes().byId(entry.noteId)
if (note == null) {
db.outbox().drop(entry.seq)
continue
}
when (entry.op) {
"create" -> {
if (note.pendingDelete) { // criada e apagada offline: nada a enviar
db.outbox().dropFor(note.id)
db.notes().delete(note.id)
continue
}
val resp = client.createNote(note.toWrite(author))
when (resp) {
is ApiResult.Ok -> {
rekeyLocalNote(note, resp.value)
db.outbox().drop(entry.seq)
pushed++
}
is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message)
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
else -> return SyncOutcome.Failed("create ${note.id}: ${describe(resp)}")
}
}
"update" -> {
val sent = note.content
val resp = client.updateNote(note.id, note.toWrite(author))
when (resp) {
is ApiResult.Ok -> {
// limpa a sujeira só se o usuário não digitou durante o push
val fresh = db.notes().byId(note.id) ?: note
val clean = fresh.content == sent
db.notes().upsert(
fresh.copy(
serverHash = resp.value.hash,
dirty = if (clean) false else fresh.dirty,
),
)
db.outbox().drop(entry.seq)
pushed++
}
is ApiResult.Conflict -> conflicts += resolveConflict(client, note, resp.serverHash)
is ApiResult.NotFound -> {
// sumiu no servidor: volta a ser create
db.outbox().drop(entry.seq)
db.notes().upsert(note.copy(serverHash = "", dirty = true))
db.outbox().enqueue(
OutboxEntity(noteId = note.id, op = "create", queuedAt = System.currentTimeMillis()),
)
}
is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message)
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
else -> return SyncOutcome.Failed("update ${note.id}: ${describe(resp)}")
}
}
"delete" -> {
db.outbox().dropFor(note.id) // remove create/update pendentes
if (!note.isLocalOnly) {
when (val resp = client.deleteNote(note.id)) {
is ApiResult.Ok, is ApiResult.NotFound -> Unit
is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message)
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
else -> return SyncOutcome.Failed("delete ${note.id}: ${describe(resp)}")
}
}
db.notes().delete(note.id)
pushed++
}
else -> db.outbox().drop(entry.seq)
}
}
return SyncOutcome.Success(pushed, 0, conflicts)
}
/** 409: servidor vence a nota; texto local vira cópia em conflito. */
private suspend fun resolveConflict(client: ApiClient, note: NoteEntity, serverHash: String): Int {
val detail = client.getNote(note.id)
val server = (detail as? ApiResult.Ok)?.value ?: return 0
if (server.content == note.content) { // convergência: mesmo texto, hash diferente
db.notes().upsert(note.copy(serverHash = server.hash, dirty = false))
db.outbox().dropFor(note.id)
return 0
}
val now = System.currentTimeMillis()
// 1) cópia local com o texto não enviado
val copyId = "L" + UUID.randomUUID().toString().replace("-", "").substring(0, 12)
db.notes().upsert(
note.copy(
id = copyId,
title = note.title + " (conflito)",
serverHash = "",
dirty = true,
pendingDelete = false,
conflictNote = "",
createdAt = now,
updatedAt = now,
),
)
db.outbox().enqueue(OutboxEntity(noteId = copyId, op = "create", queuedAt = now))
// 2) nota principal adota o servidor
db.notes().upsert(
note.copy(
content = server.content,
title = server.title,
format = server.format,
categories = server.categories.joinToString(","),
tags = server.tags.joinToString(","),
serverHash = server.hash,
dirty = false,
conflictNote = "conflito resolvido: seu texto ficou em “${note.title} (conflito)”",
),
)
db.outbox().dropFor(note.id)
return 1
}
private suspend fun rekeyLocalNote(note: NoteEntity, server: NoteDetailDto) {
val now = System.currentTimeMillis()
db.notes().delete(note.id)
db.notes().upsert(
server.toEntity().copy(
createdAt = if (note.createdAt > 0) note.createdAt else now,
dirty = false,
),
)
}
// ------------------------------------------------------------------ pull
private suspend fun pull(client: ApiClient): SyncOutcome {
var pulled = 0
when (val cats = client.listCategories()) {
is ApiResult.Ok -> {
val known = db.categories().ids().toSet()
val fresh = cats.value.map { CategoryEntity(it.id, it.name, it.parent) }
if (fresh.map { it.id }.toSet() != known) {
db.categories().clear()
db.categories().insertAll(fresh)
}
}
is ApiResult.Network -> return SyncOutcome.NetworkDown(cats.message)
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
else -> {}
}
val list = client.listNotes()
if (list is ApiResult.Network) return SyncOutcome.NetworkDown(list.message)
if (list is ApiResult.Unauthorized) return SyncOutcome.Unauthorized
if (list !is ApiResult.Ok) return SyncOutcome.Failed("list: ${describe(list)}")
val serverIds = list.value.map { it.id }.toSet()
for (summary in list.value) {
val local = db.notes().byId(summary.id)
when {
local == null -> {
val detail = client.getNote(summary.id)
if (detail is ApiResult.Ok) {
db.notes().upsert(detail.value.toEntity())
pulled++
}
}
local.dirty || local.pendingDelete -> { /* push é dono */ }
local.serverHash != summary.hash -> {
val detail = client.getNote(summary.id)
if (detail is ApiResult.Ok) {
db.notes().upsert(detail.value.toEntity().copy(createdAt = local.createdAt))
pulled++
}
}
}
}
// exclusões remotas: notas limpas que sumiram do servidor
for (local in db.notes().all()) {
if (!serverIds.contains(local.id) && !local.dirty && !local.pendingDelete) {
db.notes().delete(local.id)
db.outbox().dropFor(local.id)
}
}
return SyncOutcome.Success(0, pulled, 0)
}
// ----------------------------------------------------------------- utils
private fun NoteEntity.toWrite(author: String) = NoteWriteDto(
title = title.ifBlank { "Sem título" },
format = format,
categories = categories.split(",").filter { it.isNotBlank() },
content = content,
author = author,
message = "sync android",
baseHash = serverHash,
)
private fun NoteDetailDto.toEntity() = NoteEntity(
id = id,
title = title,
format = format,
categories = categories.joinToString(","),
tags = tags.joinToString(","),
content = content,
serverHash = hash,
createdAt = parseMillis(createdAt),
updatedAt = parseMillis(updatedAt),
dirty = false,
)
private fun parseMillis(iso: String): Long =
runCatching { Instant.parse(iso).toEpochMilli() }.getOrElse { System.currentTimeMillis() }
private fun describe(r: ApiResult<*>): String = when (r) {
is ApiResult.Server -> "HTTP ${r.code} ${r.message}"
is ApiResult.Network -> r.message
is ApiResult.Conflict -> "conflito"
else -> r.toString()
}
}
@@ -0,0 +1,16 @@
package com.labexposed.notas.domain
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** Fonte único do estado de sincronização observado pela UI e pelos workers. */
object SyncStatusStore {
private val _state = MutableStateFlow(SyncUiState.Initial)
val state: StateFlow<SyncUiState> = _state.asStateFlow()
fun set(transform: (SyncUiState) -> SyncUiState) {
_state.value = transform(_state.value)
}
}
@@ -0,0 +1,78 @@
package com.labexposed.notas.sync
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.Constraints
import androidx.work.BackoffPolicy
import com.labexposed.notas.data.local.NotasDatabase
import com.labexposed.notas.domain.SettingsRepository
import com.labexposed.notas.domain.SyncEngine
import com.labexposed.notas.domain.SyncStatusStore
import java.util.concurrent.TimeUnit
class SyncWorker(context: Context, params: WorkerParameters) :
CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val db = NotasDatabase.get(applicationContext)
val engine = SyncEngine(db, SettingsRepository(applicationContext), SyncStatusStore)
return when (val outcome = engine.fullSync(trigger = inputData.getString(TRIGGER) ?: "auto")) {
is com.labexposed.notas.domain.SyncOutcome.NetworkDown -> Result.retry()
is com.labexposed.notas.domain.SyncOutcome.Failed -> Result.retry()
is com.labexposed.notas.domain.SyncOutcome.Unauthorized -> Result.success()
else -> Result.success()
}
}
companion object {
const val TRIGGER = "trigger"
internal const val UNIQUE_PERIODIC = "notas-sync-periodic"
internal const val UNIQUE_ONE = "notas-sync-one"
}
}
object SyncScheduler {
private val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
/** Ciclo periódico: a cada 1h (com janela de flexibilidade), apenas com rede. */
fun ensurePeriodic(context: Context) {
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
SyncWorker.UNIQUE_PERIODIC,
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build(),
)
}
/**
* "Sincronizar agora" / gatilho pós-save / rede restabelecida:
* REPLACE garante exatamente um run pendente, sem acumular fila.
*/
fun runNow(context: Context, trigger: String = "manual") {
WorkManager.getInstance(context).enqueueUniqueWork(
SyncWorker.UNIQUE_ONE,
ExistingWorkPolicy.REPLACE,
OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.setInputData(androidx.work.Data.Builder().putString(SyncWorker.TRIGGER, trigger).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
.build(),
)
}
fun cancelAll(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(SyncWorker.UNIQUE_ONE)
}
}
@@ -0,0 +1,136 @@
package com.labexposed.notas.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.labexposed.notas.data.local.NotasDatabase
import com.labexposed.notas.data.local.NoteEntity
import com.labexposed.notas.data.local.OutboxEntity
import com.labexposed.notas.data.remote.ApiClient
import com.labexposed.notas.data.remote.ApiResult
import com.labexposed.notas.domain.Settings
import com.labexposed.notas.domain.SettingsRepository
import com.labexposed.notas.domain.SyncStatusStore
import com.labexposed.notas.domain.SyncUiState
import com.labexposed.notas.sync.SyncScheduler
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
sealed class ListEvent {
data class ConnectionOk(val notes: Int) : ListEvent()
data class ConnectionFail(val message: String) : ListEvent()
}
class NotasViewModel(app: Application) : AndroidViewModel(app) {
private val db = NotasDatabase.get(app)
private val settingsRepo = SettingsRepository(app)
val settings: StateFlow<Settings> = settingsRepo.flow
.stateIn(viewModelScope, SharingStarted.Eagerly, Settings())
val notes: StateFlow<List<NoteEntity>> = db.notes().observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val categories: StateFlow<List<com.labexposed.notas.data.local.CategoryEntity>> =
db.categories().observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val syncState: StateFlow<SyncUiState> = combine(
SyncStatusStore.state,
db.outbox().observeCount(),
db.notes().observePending(),
) { state, outbox, dirty ->
state.copy(pendingCount = outbox, dirtyCount = dirty)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SyncUiState.Initial)
private val _events = MutableStateFlow<ListEvent?>(null)
val events: StateFlow<ListEvent?> = _events
fun saveSettings(s: Settings) = viewModelScope.launch {
settingsRepo.save(s)
SyncScheduler.runNow(getApplication(), "config")
}
fun testConnection(s: Settings) = viewModelScope.launch(Dispatchers.IO) {
val result = ApiClient(s.serverUrl, s.token).testConnection()
_events.value = when (result) {
is ApiResult.Ok -> ListEvent.ConnectionOk(result.value.size)
is ApiResult.Unauthorized -> ListEvent.ConnectionFail("token inválido (401)")
is ApiResult.Network -> ListEvent.ConnectionFail("sem conexão: ${result.message}")
else -> ListEvent.ConnectionFail(result.toString())
}
}
fun consumeEvent() { _events.value = null }
fun syncNow() {
SyncScheduler.runNow(getApplication(), "manual")
}
fun createLocal(): String {
val id = "L" + UUID.randomUUID().toString().replace("-", "").substring(0, 12)
viewModelScope.launch(Dispatchers.IO) {
val now = System.currentTimeMillis()
db.notes().upsert(
NoteEntity(
id = id, title = "", format = "md", content = "",
createdAt = now, updatedAt = now, dirty = true,
),
)
db.outbox().enqueue(OutboxEntity(noteId = id, op = "create", queuedAt = now))
}
return id
}
/** Salva edição local + enfileira mutação + agenda sync (debounce no editor). */
fun saveNote(
id: String,
title: String,
format: String,
categories: List<String>,
content: String,
) = viewModelScope.launch(Dispatchers.IO) {
val existing = db.notes().byId(id) ?: return@launch
val now = System.currentTimeMillis()
val contentChanged = existing.content != content
db.notes().upsert(
existing.copy(
title = title,
format = format,
categories = categories.joinToString(","),
content = content,
updatedAt = now,
dirty = true,
conflictNote = "",
),
)
if (contentChanged && !db.outbox().has(id, "create")) {
db.outbox().enqueue(OutboxEntity(noteId = id, op = "update", queuedAt = now))
SyncScheduler.runNow(getApplication(), "autosave")
}
}
fun deleteNote(id: String) = viewModelScope.launch(Dispatchers.IO) {
val note = db.notes().byId(id) ?: return@launch
if (note.isLocalOnly) {
db.notes().delete(id)
db.outbox().dropFor(id)
} else {
db.notes().upsert(note.copy(pendingDelete = true, dirty = true, updatedAt = System.currentTimeMillis()))
db.outbox().enqueue(OutboxEntity(noteId = id, op = "delete", queuedAt = System.currentTimeMillis()))
SyncScheduler.runNow(getApplication(), "delete")
}
}
suspend fun noteById(id: String): NoteEntity? = withContext(Dispatchers.IO) {
db.notes().byId(id)
}
}
@@ -0,0 +1,280 @@
package com.labexposed.notas.ui
import com.labexposed.notas.SyncBanner
import com.labexposed.notas.domain.Settings
import androidx.compose.foundation.layout.Arrangement
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.filled.Add
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private val AUTOSAVE_IDLE_MS = 10_000L
/** Tela de configuração: URL do servidor + token + autor, com teste de conexão. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(vm: NotasViewModel, onDone: () -> Unit) {
val settings by vm.settings.collectAsState()
var url by remember { mutableStateOf(settings.serverUrl) }
var token by remember { mutableStateOf(settings.token) }
var author by remember { mutableStateOf(settings.author) }
var feedback by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
val events by vm.events.collectAsState()
LaunchedEffect(events) {
when (val e = events) {
is ListEvent.ConnectionOk -> feedback = "OK! ${e.notes} nota(s) visíveis no servidor."
is ListEvent.ConnectionFail -> feedback = "Falhou: ${e.message}"
null -> {}
}
if (events != null) vm.consumeEvent()
}
Scaffold(topBar = { TopAppBar(title = { Text("Servidor") }) }) { pad ->
Column(
modifier = Modifier.padding(pad).padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = url, onValueChange = { url = it },
label = { Text("URL da API (ex.: http://192.168.0.10:8080)") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = token, onValueChange = { token = it },
label = { Text("Token de acesso (NOTES_TOKEN)") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = author, onValueChange = { author = it },
label = { Text("Autor das revisões") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
if (feedback.isNotEmpty()) {
Card(modifier = Modifier.fillMaxWidth()) {
Text(feedback, modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { scope.launch { vm.testConnection(Settings(url, token, author)) } }) {
Text("Testar conexão")
}
TextButton(
enabled = url.isNotBlank() && token.isNotBlank(),
onClick = {
vm.saveSettings(Settings(url, token, author))
onDone()
},
) { Text("Salvar e continuar") }
}
}
}
}
/** Tela principal: lista local de notas + banner de sincronização. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotesListScreen(
vm: NotasViewModel,
onOpen: (String) -> Unit,
onNew: () -> Unit,
onSettings: () -> Unit,
) {
val notes by vm.notes.collectAsState()
val sync by vm.syncState.collectAsState()
val cats by vm.categories.collectAsState()
val catNames = remember(cats) { cats.associate { it.id to it.name } }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Notas") },
actions = {
TextButton(onClick = { vm.syncNow() }) { Text("Sincronizar") }
TextButton(onClick = onSettings) { Text("Servidor") }
},
)
},
floatingActionButton = {
androidx.compose.material3.FloatingActionButton(onClick = onNew) {
Icon(Icons.Default.Add, contentDescription = "Nova nota")
}
},
) { pad ->
Column(modifier = Modifier.padding(pad).fillMaxSize()) {
SyncBanner(sync)
if (notes.isEmpty()) {
Text(
"Nenhuma nota local.\nToque em + para criar (funciona offline).",
modifier = Modifier.padding(24.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(8.dp),
) {
items(notes, key = { it.id }) { note ->
androidx.compose.material3.Card(
onClick = { onOpen(note.id) },
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Text(
(note.title.ifBlank { "Sem título" }) +
when (note.format) { "md" -> ".md"; "html" -> ".html"; else -> ".txt" },
style = MaterialTheme.typography.titleSmall,
)
val bits = mutableListOf(
java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT)
.format(java.util.Date(note.updatedAt)),
)
note.categories.split(",").filter { it.isNotBlank() }
.map { catNames[it] ?: it }.ifNotEmpty()?.let { bits.add(it.joinToString(" · ")) }
if (note.tags.isNotBlank()) bits.add(note.tags.split(",").joinToString(" ") { "#$it" })
Text(bits.joinToString(" · "), style = MaterialTheme.typography.bodySmall)
if (note.conflictNote.isNotBlank()) {
Text("⚠ ${note.conflictNote}", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error)
}
}
Column(horizontalAlignment = androidx.compose.ui.Alignment.End) {
if (note.dirty) Text("● não enviado", color = MaterialTheme.colorScheme.tertiary,
style = MaterialTheme.typography.labelSmall)
if (note.pendingDelete) Text("🗑 fila", style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
}
}
}
private fun <T> List<T>.ifNotEmpty(): List<T>? = if (isEmpty()) null else this
/** Editor offline-first: autosave local após 10 s de ociosidade + salvar explícito. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
var loaded by remember { mutableStateOf(false) }
var title by remember { mutableStateOf("") }
var format by remember { mutableStateOf("md") }
var content by remember { mutableStateOf("") }
var selectedCats by remember { mutableStateOf<List<String>>(emptyList()) }
var savedAt by remember { mutableStateOf("") }
val cats by vm.categories.collectAsState()
val keyboard = LocalSoftwareKeyboardController.current
LaunchedEffect(noteId) {
vm.noteById(noteId)?.let {
title = it.title
format = it.format
content = it.content
selectedCats = it.categories.split(",").filter { c -> c.isNotBlank() }
}
loaded = true
}
fun persist() {
if (!loaded) return
vm.saveNote(noteId, title, format, selectedCats, content)
savedAt = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT)
.format(java.util.Date())
}
// ociosidade: o efeito é reiniciado a cada alteração; só grava se
// passarem 10 s sem nenhuma (cada tecla cancela o delay anterior)
LaunchedEffect(title, format, content, loaded) {
if (!loaded) return@LaunchedEffect
delay(AUTOSAVE_IDLE_MS)
persist()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(title.ifBlank { "Nova nota" }) },
navigationIcon = { TextButton(onClick = { persist(); onBack() }) { Text("←") } },
actions = {
Text(savedAt.ifBlank { "—" }, style = MaterialTheme.typography.labelSmall)
TextButton(onClick = { persist(); keyboard?.hide() }) { Text("Salvar") }
},
)
},
) { pad ->
Column(modifier = Modifier.padding(pad).fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = title, onValueChange = { title = it },
label = { Text("Título") }, singleLine = true, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("txt", "md", "html").forEach { f ->
FilterChip(selected = format == f, onClick = { format = f }, label = { Text(f.uppercase()) })
}
}
if (cats.isNotEmpty()) {
Text("Categorias", style = MaterialTheme.typography.labelMedium)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
cats.forEach { c ->
FilterChip(
selected = selectedCats.contains(c.id),
onClick = {
selectedCats = if (selectedCats.contains(c.id))
selectedCats - c.id else selectedCats + c.id
},
label = { Text(c.name) },
)
}
}
}
OutlinedTextField(
value = content, onValueChange = { content = it },
modifier = Modifier.fillMaxWidth().weight(1f),
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = FontFamily.Monospace),
placeholder = { Text("Escreva… #tags viram tags no servidor") },
)
}
}
}
@@ -0,0 +1,38 @@
package com.labexposed.notas.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
// seed Greybird/Mousepad-like
private val Light = lightColorScheme(
primary = Color(0xFF1C71D8),
secondary = Color(0xFF3584E4),
background = Color(0xFFF6F5F4),
surface = Color(0xFFFFFFFF),
)
private val Dark = darkColorScheme(
primary = Color(0xFF78AEFF),
secondary = Color(0xFF3584E4),
)
@Composable
fun NotasTheme(content: @Composable () -> Unit) {
val ctx = LocalContext.current
val dynamic = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val scheme = when {
dynamic && isSystemInDarkTheme() -> dynamicDarkColorScheme(ctx)
dynamic -> dynamicLightColorScheme(ctx)
isSystemInDarkTheme() -> Dark
else -> Light
}
MaterialTheme(colorScheme = scheme, content = content)
}
@@ -0,0 +1,10 @@
<?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">
<path
android:fillColor="#FFFFFF"
android:pathData="M34,38h40v4H34zM34,48h40v4H34zM34,58h28v4H34z" />
</vector>
@@ -0,0 +1,6 @@
<?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" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Notas</string>
</resources>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Notas" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
</style>
<color name="ic_launcher_background">#3584E4</color>
</resources>
+8
View File
@@ -0,0 +1,8 @@
// Arquivo raiz — só declara plugins; versão centralizada aqui.
plugins {
id("com.android.application") version "8.7.3" 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("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false
}
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+252
View File
@@ -0,0 +1,252 @@
#!/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.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# 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/platforms/jvm/plugins-application/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 -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || 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" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@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
@rem SPDX-License-Identifier: Apache-2.0
@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
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "notas-online-android"
include(":app")