From 2f63c7a2f5e3fb05389b76c466ac99d29ece0355 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Costa Date: Wed, 4 Nov 2026 17:55:50 -0300 Subject: [PATCH] feat: cliente Android offline-first (Kotlin + Jetpack Compose) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .gitignore | 11 + README.md | 133 ++++++++ app/build.gradle.kts | 73 ++++ app/proguard-rules.pro | 11 + app/src/main/AndroidManifest.xml | 29 ++ .../java/com/labexposed/notas/MainActivity.kt | 98 ++++++ .../java/com/labexposed/notas/NotasApp.kt | 36 ++ .../labexposed/notas/data/local/Entities.kt | 123 +++++++ .../notas/data/local/NotasDatabase.kt | 32 ++ .../labexposed/notas/data/remote/ApiClient.kt | 89 +++++ .../notas/data/remote/ConnectivityObserver.kt | 36 ++ .../com/labexposed/notas/data/remote/Dtos.kt | 71 ++++ .../com/labexposed/notas/domain/Models.kt | 26 ++ .../notas/domain/SettingsRepository.kt | 49 +++ .../com/labexposed/notas/domain/SyncEngine.kt | 319 ++++++++++++++++++ .../notas/domain/SyncStatusStore.kt | 16 + .../com/labexposed/notas/sync/SyncWorker.kt | 78 +++++ .../com/labexposed/notas/ui/NotasViewModel.kt | 136 ++++++++ .../java/com/labexposed/notas/ui/Screens.kt | 280 +++++++++++++++ .../com/labexposed/notas/ui/theme/Theme.kt | 38 +++ .../res/drawable/ic_launcher_foreground.xml | 10 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/themes.xml | 7 + build.gradle.kts | 8 + gradle.properties | 6 + gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 252 ++++++++++++++ gradlew.bat | 94 ++++++ settings.gradle.kts | 18 + 30 files changed, 2096 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/labexposed/notas/MainActivity.kt create mode 100644 app/src/main/java/com/labexposed/notas/NotasApp.kt create mode 100644 app/src/main/java/com/labexposed/notas/data/local/Entities.kt create mode 100644 app/src/main/java/com/labexposed/notas/data/local/NotasDatabase.kt create mode 100644 app/src/main/java/com/labexposed/notas/data/remote/ApiClient.kt create mode 100644 app/src/main/java/com/labexposed/notas/data/remote/ConnectivityObserver.kt create mode 100644 app/src/main/java/com/labexposed/notas/data/remote/Dtos.kt create mode 100644 app/src/main/java/com/labexposed/notas/domain/Models.kt create mode 100644 app/src/main/java/com/labexposed/notas/domain/SettingsRepository.kt create mode 100644 app/src/main/java/com/labexposed/notas/domain/SyncEngine.kt create mode 100644 app/src/main/java/com/labexposed/notas/domain/SyncStatusStore.kt create mode 100644 app/src/main/java/com/labexposed/notas/sync/SyncWorker.kt create mode 100644 app/src/main/java/com/labexposed/notas/ui/NotasViewModel.kt create mode 100644 app/src/main/java/com/labexposed/notas/ui/Screens.kt create mode 100644 app/src/main/java/com/labexposed/notas/ui/theme/Theme.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83147d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*.iml +.gradle/ +build/ +local.properties +.idea/ +.DS_Store +captures/ +.externalNativeBuild/ +.cxx/ +*.keystore +!debug.keystore diff --git a/README.md b/README.md new file mode 100644 index 0000000..00e78d2 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..dafb6fa --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..e91ae34 --- /dev/null +++ b/app/proguard-rules.pro @@ -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(...); +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9993717 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/labexposed/notas/MainActivity.kt b/app/src/main/java/com/labexposed/notas/MainActivity.kt new file mode 100644 index 0000000..0d320ff --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/MainActivity.kt @@ -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) diff --git a/app/src/main/java/com/labexposed/notas/NotasApp.kt b/app/src/main/java/com/labexposed/notas/NotasApp.kt new file mode 100644 index 0000000..e7ac110 --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/NotasApp.kt @@ -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") + } + } +} diff --git a/app/src/main/java/com/labexposed/notas/data/local/Entities.kt b/app/src/main/java/com/labexposed/notas/data/local/Entities.kt new file mode 100644 index 0000000..b74454f --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/data/local/Entities.kt @@ -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` + * 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> + + @Query("SELECT * FROM notes ORDER BY updatedAt DESC") + suspend fun all(): List + + @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 + + @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 + + @Query("SELECT COUNT(*) FROM outbox") + fun observeCount(): Flow + + @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> + + @Query("DELETE FROM categories") + suspend fun clear() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(items: List) + + @Query("SELECT id FROM categories") + suspend fun ids(): List +} + +@Dao +interface MetaDao { + @Query("SELECT value FROM meta WHERE `key` = :key") + suspend fun get(key: String): String? + + @Upsert + suspend fun set(entry: MetaEntity) +} diff --git a/app/src/main/java/com/labexposed/notas/data/local/NotasDatabase.kt b/app/src/main/java/com/labexposed/notas/data/local/NotasDatabase.kt new file mode 100644 index 0000000..f032eac --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/data/local/NotasDatabase.kt @@ -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 } + } + } +} diff --git a/app/src/main/java/com/labexposed/notas/data/remote/ApiClient.kt b/app/src/main/java/com/labexposed/notas/data/remote/ApiClient.kt new file mode 100644 index 0000000..9d22ce5 --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/data/remote/ApiClient.kt @@ -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 call( + method: String, + path: String, + body: String? = null, + ): ApiResult { + 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(text)) + } + } + resp.code == 401 -> ApiResult.Unauthorized + resp.code == 404 -> ApiResult.NotFound + resp.code == 409 -> { + val c = runCatching { + json.decodeFromString(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> = call("GET", "/api/notes") + + fun listNotes(): ApiResult> = call("GET", "/api/notes") + + fun listCategories(): ApiResult> = call("GET", "/api/categories") + + fun getNote(id: String): ApiResult = call("GET", "/api/notes/$id") + + fun createNote(dto: NoteWriteDto): ApiResult = + call("POST", "/api/notes", json.encodeToString(dto)) + + fun updateNote(id: String, dto: NoteWriteDto): ApiResult = + call("PUT", "/api/notes/$id", json.encodeToString(dto)) + + fun deleteNote(id: String): ApiResult = call("DELETE", "/api/notes/$id") +} diff --git a/app/src/main/java/com/labexposed/notas/data/remote/ConnectivityObserver.kt b/app/src/main/java/com/labexposed/notas/data/remote/ConnectivityObserver.kt new file mode 100644 index 0000000..22b06b7 --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/data/remote/ConnectivityObserver.kt @@ -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 = 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) } + } +} diff --git a/app/src/main/java/com/labexposed/notas/data/remote/Dtos.kt b/app/src/main/java/com/labexposed/notas/data/remote/Dtos.kt new file mode 100644 index 0000000..0d482f4 --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/data/remote/Dtos.kt @@ -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 = emptyList(), + val tags: List = 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 = emptyList(), + val tags: List = 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 = 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 { + data class Ok(val value: T) : ApiResult() + data object Unauthorized : ApiResult() + data object NotFound : ApiResult() + data class Conflict(val serverHash: String) : ApiResult() + data class Network(val message: String) : ApiResult() + data class Server(val code: Int, val message: String) : ApiResult() +} diff --git a/app/src/main/java/com/labexposed/notas/domain/Models.kt b/app/src/main/java/com/labexposed/notas/domain/Models.kt new file mode 100644 index 0000000..35cba72 --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/domain/Models.kt @@ -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() +} diff --git a/app/src/main/java/com/labexposed/notas/domain/SettingsRepository.kt b/app/src/main/java/com/labexposed/notas/domain/SettingsRepository.kt new file mode 100644 index 0000000..a7d8be4 --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/domain/SettingsRepository.kt @@ -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 = 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" } + } + } +} diff --git a/app/src/main/java/com/labexposed/notas/domain/SyncEngine.kt b/app/src/main/java/com/labexposed/notas/domain/SyncEngine.kt new file mode 100644 index 0000000..e4262ff --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/domain/SyncEngine.kt @@ -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() + } +} diff --git a/app/src/main/java/com/labexposed/notas/domain/SyncStatusStore.kt b/app/src/main/java/com/labexposed/notas/domain/SyncStatusStore.kt new file mode 100644 index 0000000..f023e7d --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/domain/SyncStatusStore.kt @@ -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 = _state.asStateFlow() + + fun set(transform: (SyncUiState) -> SyncUiState) { + _state.value = transform(_state.value) + } +} diff --git a/app/src/main/java/com/labexposed/notas/sync/SyncWorker.kt b/app/src/main/java/com/labexposed/notas/sync/SyncWorker.kt new file mode 100644 index 0000000..a7c4158 --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/sync/SyncWorker.kt @@ -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(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() + .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) + } +} diff --git a/app/src/main/java/com/labexposed/notas/ui/NotasViewModel.kt b/app/src/main/java/com/labexposed/notas/ui/NotasViewModel.kt new file mode 100644 index 0000000..6c4faa3 --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/ui/NotasViewModel.kt @@ -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 = settingsRepo.flow + .stateIn(viewModelScope, SharingStarted.Eagerly, Settings()) + + val notes: StateFlow> = db.notes().observeAll() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val categories: StateFlow> = + db.categories().observeAll() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val syncState: StateFlow = 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(null) + val events: StateFlow = _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, + 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) + } +} diff --git a/app/src/main/java/com/labexposed/notas/ui/Screens.kt b/app/src/main/java/com/labexposed/notas/ui/Screens.kt new file mode 100644 index 0000000..687b09f --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/ui/Screens.kt @@ -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 List.ifNotEmpty(): List? = 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>(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") }, + ) + } + } +} diff --git a/app/src/main/java/com/labexposed/notas/ui/theme/Theme.kt b/app/src/main/java/com/labexposed/notas/ui/theme/Theme.kt new file mode 100644 index 0000000..ca093ee --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/ui/theme/Theme.kt @@ -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) +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..5c7a398 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..5c84730 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..1675f8b --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Notas + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..5e1264f --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + #3584E4 + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..107abd0 --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..300b248 --- /dev/null +++ b/gradle.properties @@ -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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..5ff33e4 --- /dev/null +++ b/settings.gradle.kts @@ -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")