9 Commits
Author SHA1 Message Date
lgcosta 81e945b490 build: toolchain AGP 8.13.2 + Kotlin 2.2.20 + SDK 36 (v0.4.0)
- Gradle wrapper 8.11.1 -> 8.13 (exigido pelo AGP 8.13)
- AGP 8.7.3 -> 8.13.2; Kotlin 2.0.21 -> 2.2.20; KSP -> 2.2.20-2.0.4
- compileSdk/targetSdk 35 -> 36 (Android 16 como alvo real)
- Compose BOM 2025.06 -> 2026.06 (material3 1.4.0 estável; Expressive
  completo segue beta em 1.5.x e fica de fora a pedido: só estável)
- activity-compose 1.10.1, navigation-compose 2.9.0, lifecycle 2.9.0
- Room 2.6.1 -> 2.8.3; WorkManager 2.9.1 -> 2.10.1; DataStore 1.1.7
- kotlinx-serialization 1.8.1; coroutines 1.10.2
- versionCode 7 / versionName 0.4.0

Verificado: testes unitários verdes; APKs debug+release OK; em Android 16
(API 36, Pixel 6) upgrade in-place, criar nota -> autosave -> Room -> lista,
editor e chips de modo/formato sem crashes. README atualizado.
2026-11-06 08:48:16 -03:00
lgcosta 641c374a03 fix(build): restaurar targetSdk=35 perdido no bump da v0.2.2
Sem targetSdkVersion no manifest, o AGP usou minSdk (26) como alvo:
o instalador do Android 16 (One UI 8) bloqueia o APK como 'versão
antiga do Android'. Restore targetSdk=35 + versionCode 6 / 0.3.1.

Verificado em emulador Android 16 (API 36, Pixel 6): install sem
bloqueio, boot limpo, fluxo config -> lista -> status/busca/empty state.
2026-11-05 22:09:50 -03:00
lgcosta a8c8a5a998 feat(ui): modernização visual Material 3 (v0.3.0)
- Edge-to-edge (enableEdgeToEdge) com Scaffold/insets em todas as telas
- Compose BOM 2024.12 -> 2025.06; tipografia e tema com dynamic color
- SyncBanner colorido removido -> SyncStatusRow compacto com chips M3
  acionáveis (sync agora / abrir config / erro em errorContainer)
- Lista: busca por título/conteúdo/tag (filterNotes testado),
  pull-to-refresh, animateItem, contentType, estado vazio com CTA
- Ícones no lugar de emoji (pasta, presença, exclusão, aviso)
- Editor: chip de presença acessível; imports limpos
- Teste unitário de busca (NoteSearchTest)
- versionCode 5 / versionName 0.3.0
2026-11-05 21:48:16 -03:00
codeall 8561ae2f8b fix: robustez de sync e cancelamento (v0.2.2)
- SyncEngine: CancellationException volta a propagar (não vira erro falso)
- SyncWorker: Failed (4xx/5xx) agora é failure, só NetworkDown retenta
- ApiClient: métodos suspend com delay() em vez de Thread.sleep
- SseClient: cancelamento fecha o socket; parse tolera 'data:' sem espaço
- Room v3: migração real 2->3 (índice único outbox) sem apagar a fila
- versionCode 4 / versionName 0.2.2
2026-11-05 21:02:10 -03:00
lgcosta 34a6c38540 chore(wrapper): Added wrapper jar and enabled gradlew execution
- Added gradle-wrapper.jar to enable version-pinned Gradle bootstrapping.
- Set executable permission on gradlew for direct wrapper invocation.
2026-11-05 20:48:17 -03:00
lgcosta 57edfbe4df feat: exclusão de notas na UI (lista e editor) com confirmação e sync
- Botão 🗑 no card da lista e na barra do editor; AlertDialog de confirmação
  explicando o efeito (remoto sobe na sync; local-only some direto)
- Exclusões já eram enfileiradas (pendingDelete + op delete) e o engine já
  tratava 404; propagação via SSE 'deleted' para outras sessões
- versionCode 3 / versionName 0.2.1; README atualizado
- Testado no emulador: delete pela lista → servidor 0 notas; delete pelo
  editor → idem, com volta à lista; pull de nota da Web e exclusão seguinte ok
2026-11-05 07:48:57 -03:00
lgcosta 9508dbf24a chore: ignorar .kotlin/ (cache do plugin Kotlin) 2026-11-04 20:57:56 -03:00
lgcosta 400ab066b2 chore: versionCode 2 / versionName 0.2.0 (release v0.2.0) 2026-11-04 20:56:08 -03:00
lgcosta 61d4abebbd feat: v0.2.0 — sincronização do botão garantida, SSE em tempo real, WYSIWYG/Preview e lista rica
Sync (bug do celular real):
- mudanças só de metadados (título/formato/categorias) entram na fila
- auto-reconciliação: toda rodada enfileira notas sujas sem entrada na fila
- re-enfileira update se o usuário digitou durante o push
- sync no ON_RESUME; botão Sincronizar com spinner no lugar do ícone

Tempo real (SSE):
- SseClient (OkHttp, Dispatchers.IO, backoff) consome /api/events com Bearer
- saved: nota limpa recarrega sozinha (editor aberto incluído, com rekey por meta),
  nota suja mostra banner Recarregar/Manter; snackbar na lista
- presence: 👥N na lista/editor quando outra sessão (web ou app) abre a mesma nota
- SessionId único por instalação, enviado em presence e ecoado nos saves

Editor moderno (WebView nativo, sem deps novas):
- modos Editar / WYSIWYG / Preview; WYSIWYG = contenteditable com serializador
  html→md em JS na página-asset + ponte @JavascriptInterface (tudo na main thread)
- toolbar Markdown no modo texto (wrap/prefix seleção) e execCommand no WYSIWYG
- Preview renderiza md via /api/render; links não navegam fora do app
- lista: MediumTopAppBar, chips de filtro (categoria com caminho da árvore e tags),
  cards com status ☁/●/🗑/ e 👥, FAB estendido; telas em Material 3 dynamic color

Testado no emulador Android 15: título-only sync ✅, SSE ao vivo ✅, WYSIWYG→servidor ✅,
👥 presença ✅, conflito 409 com cópia única ✅
2026-11-04 20:52:59 -03:00
22 changed files with 1451 additions and 248 deletions

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
*.iml *.iml
.gradle/ .gradle/
.kotlin/
build/ build/
local.properties local.properties
.idea/ .idea/
+47 -24
View File
@@ -40,7 +40,7 @@ unzip -q commandlinetools-linux-*.zip -d ~/Android/Sdk/cmdline-tools
mv ~/Android/Sdk/cmdline-tools/cmdline-tools ~/Android/Sdk/cmdline-tools/latest mv ~/Android/Sdk/cmdline-tools/cmdline-tools ~/Android/Sdk/cmdline-tools/latest
yes | ~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager --licenses yes | ~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager --licenses
~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager \ ~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager \
"platform-tools" "platforms;android-35" "build-tools;35.0.0" "platform-tools" "platforms;android-36" "build-tools;36.0.0"
# 3) ~/.bashrc (ou ~/.zshrc) # 3) ~/.bashrc (ou ~/.zshrc)
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
@@ -48,8 +48,8 @@ export ANDROID_HOME=$HOME/Android/Sdk
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools 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. O projeto usa o **Gradle Wrapper** (8.13) — não é preciso instalar Gradle.
Se clonar sem os jars do wrapper, gere-os uma vez: `gradle wrapper --gradle-version 8.11.1` Se clonar sem os jars do wrapper, gere-os uma vez: `gradle wrapper --gradle-version 8.13`
(ou abra no Android Studio, que regenera automaticamente). (ou abra no Android Studio, que regenera automaticamente).
## Estrutura do repositório ## Estrutura do repositório
@@ -57,9 +57,9 @@ Se clonar sem os jars do wrapper, gere-os uma vez: `gradle wrapper --gradle-vers
``` ```
notas-online-android/ notas-online-android/
├── settings.gradle.kts / build.gradle.kts / gradle.properties ├── settings.gradle.kts / build.gradle.kts / gradle.properties
├── gradle/wrapper/ # Gradle 8.11.1 ├── gradle/wrapper/ # Gradle 8.13
├── app/ ├── app/
│ ├── build.gradle.kts # AGP 8.7.3 · Kotlin 2.0.21 · Compose BOM 2024.12 │ ├── build.gradle.kts # AGP 8.13.2 · Kotlin 2.2.20 · Compose BOM 2026.06
│ ├── proguard-rules.pro │ ├── proguard-rules.pro
│ └── src/main/ │ └── src/main/
│ ├── AndroidManifest.xml # INTERNET + ACCESS_NETWORK_STATE; cleartext p/ LAN │ ├── AndroidManifest.xml # INTERNET + ACCESS_NETWORK_STATE; cleartext p/ LAN
@@ -90,20 +90,29 @@ notas-online-android/
rebaixadas (GET completo); notas que sumiram do servidor (e não estão na fila) são rebaixadas (GET completo); notas que sumiram do servidor (e não estão na fila) são
removidas localmente. removidas localmente.
5. **Gatilhos**: WorkManager periódico (1h, exige rede, backoff exponencial) · rede 5. **Gatilhos**: WorkManager periódico (1h, exige rede, backoff exponencial) · rede
restabelecida (`ConnectivityManager` callback) · após cada edição com conteúdo alterado · restabelecida (`ConnectivityManager` callback) · **a cada edição com autosave (10 s de
botão **Sincronizar** · troca de configuração. ociosidade), incluindo mudanças só de título/formato/categorias** · **ao voltar para o app**
6. **Autosave do editor**: grava local + enfileira após **10 s de ociosidade** (cada tecla (`ON_RESUME`) · botão **Sincronizar** · troca de configuração. Uma auto-reconciliação a cada
reinicia a contagem) — igual à política web. rodada enfileira qualquer nota suja sem entrada na fila (recupera estados presos).
6. **Tempo real via SSE** (`GET /api/events`, OkHttp, reconexão com backoff, só em primeiro
plano): eventos `saved` atualizam a UI na hora — nota limpa aberta no editor é recarregada
ao vivo; nota suja mostra banner "Mudou no servidor — Recarregar/Manter"; snackbar na lista.
Eventos `presence` desenham **👥N** na nota quando outra sessão (app ou navegador web) está
com ela aberta.
7. **WYSIWYG e Preview** (WebView nativo, sem dependência extra): modo WYSIWYG renderiza o
Markdown (via `/api/render` do servidor) num `contenteditable`; cada edição é convertida de
volta para Markdown por um serializador JS embutido (títulos, negrito/itálico/tachado, listas,
citação, código, links, tabelas) e sincronizada — notas `.html` editam o HTML preservando
tags/atributos (scripts/`on*` sanitizados). Preview é o mesmo WebView não-editável. Barra de
ferramentas aplica `execCommand` (B I S H1 H2 listas ❝ 🔗 código).
## Telas ## UI (Material 3, dynamic color, edge-to-edge) — v0.3.0
- **Servidor**: URL da API + token (`NOTES_TOKEN`) + autor; botão *Testar conexão* (lista - **Lista**: `MediumTopAppBar`, estado de sync compacto com cores M3 e chips acionáveis, busca por título/conteúdo/tags, atualização por gesto, filtros por categoria/tag, cards animados com status e presença acessível, estado vazio com CTA e FAB "Nova".
notas via `GET /api/notes`) antes de salvar. - **Editor**: campo de título na top bar, seletor de modo (Editar / WYSIWYG / Preview), chips de formato e categorias, corpo monoespaçado com toolbar Markdown, presença com ícone acessível, autosave e exclusão confirmada.
- **Lista**: banner de status colorido — cinza *não configurado*, cinza-escuro *Offline (N na - **Visual**: cores dinâmicas quando disponíveis, tipografia ajustada e edge-to-edge para integrar conteúdo às barras do sistema.
fila)*, azul *Sincronizando…*, verde *Sincronizado às HH:mm · N pendente(s) · ⚠ conflitos*, - **Exclusões sincronizam**: apagar marca `pendingDelete`, entra `delete` na fila e sobe na próxima sync; nota local-only some direto. Eventos SSE removem notas limpas das outras sessões.
vermelho *erro/401*; itens mostram ● não enviado / 🗑 fila / ⚠ nota de conflito. - **Servidor**: URL + token + autor com *Testar conexão* e validação de esquema http(s).
- **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 ## Compilar e instalar
@@ -121,23 +130,37 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
## Testado em ## Testado em
- **Build**: `assembleRelease` limpo (JDK 21 / AGP 8.7.3 / Kotlin 2.0.21 / SDK 35), APK assinado - **Build**: `testDebugUnitTest` + `assembleDebug` + `assembleRelease` limpos (JDK 21 / AGP 8.13.2 /
com R8 ≈ **1,6 MB** (debug ≈ 17 MB). Kotlin 2.2.20 / SDK 36 / Compose BOM 2026.06); release R8 ≈ **1,9 MB**, debug ≈ 20 MB.
- **Emulador Android 16 (API 36, x86_64, Pixel 6)** — v0.4.0: upgrade in-place sem bloqueio,
criar nota → autosave → persiste no Room → lista atualiza; editor WYSIWYG/Preview ok;
sem crashes (logcat AndroidRuntime limpo).
- **Emulador Android 16 (API 36, x86_64, Pixel 6)** — v0.3.1: instalação sem bloqueio de
"versão antiga", boot limpo e fluxo config → lista (status, busca, empty state) verificados.
- **Emulador Android 15 (x86_64, Pixel 6)** — v0.3.0: edge-to-edge, status de sync compacto,
campo de busca, estado vazio com CTA e editor com chips de modo/formato verificados por
screenshot + uiautomator (config → lista → criar nota → editor).
- **Emulador Android 15 (x86_64, Pixel 6)** contra o servidor notas-online real, cenários: - **Emulador Android 15 (x86_64, Pixel 6)** contra o servidor notas-online real, cenários:
- configuração de servidor com *Testar conexão* → OK; - configuração de servidor com *Testar conexão* → OK;
- criar nota offline → autosave por ociosidade (10 s) → `POST` único (sem duplicatas); - criar nota offline → autosave por ociosidade (10 s) → `POST` único (sem duplicatas);
- editar offline → fila resiste à queda de rede → push automático ao restabelecer (`ConnectivityManager`); - editar offline → fila resiste à queda de rede → push automático ao restabelecer (`ConnectivityManager`);
- nota criada na Web aparece no device via pull; - nota criada na Web aparece no device via pull;
- **mudança só de título sincroniza** (bug reportado em celular real, corrigido: metadados
entram na fila + auto-reconciliação + sync no `ON_RESUME`);
- **SSE ao vivo**: PUT vindo da Web atualiza o editor aberto em ~2 s, sem toque; snackbar na
lista; **👥 presença** aparece quando outra sessão abre a mesma nota;
- **WYSIWYG**: digitar no `contenteditable` → serialização html→md na ponte JS → autosave →
servidor recebe o texto; Preview renderiza o Markdown via `/api/render`;
- **conflito real**: device e Web editam a mesma nota → HTTP 409 (`base_hash`) → nota principal - **conflito real**: device e Web editam a mesma nota → HTTP 409 (`base_hash`) → nota principal
adota o servidor e o texto do Android vira exatamente **uma** cópia "… (conflito)" adota o servidor e o texto do Android vira exatamente **uma** cópia "… (conflito)"
(idempotente; sincronizações serializadas por mutex + índices únicos na outbox). (idempotente; sincronizações serializadas por mutex + índices únicos na outbox).
- Integração com a API: mesmos contratos do web client (`base_hash`/409, categorias, revisões). - Integração com a API: mesmos contratos do web client (`base_hash`/409, categorias, revisões,
O servidor normaliza `categories`/`tags` vazios como `[]` (null quebrava clientes com `/api/events`, `/api/presence`). O servidor normaliza `categories`/`tags` vazios como `[]`
tipos não-nuláveis, ex. kotlinx.serialization). (null quebrava clientes com tipos não-nuláveis, ex. kotlinx.serialization).
## Roadmap honesto (não implementado aqui) ## 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). - Token em Keystore/EncryptedSharedPreferences (hoje: sandbox do app via DataStore).
- Editor WYSIWYG/realce de sintaxe mobile (fora do escopo "mais leve possível"). - Sync reativo incremental (o pull compara hashes por nota; milhares de notas pediriam um
endpoint de delta no servidor).
- Multi-perfil/servidores e sincronização de anexos. - Multi-perfil/servidores e sincronização de anexos.
+17 -18
View File
@@ -8,14 +8,14 @@ plugins {
android { android {
namespace = "com.labexposed.notas" namespace = "com.labexposed.notas"
compileSdk = 35 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "com.labexposed.notas" applicationId = "com.labexposed.notas"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 36
versionCode = 1 versionCode = 7
versionName = "1.0.0" versionName = "0.4.0"
} }
buildTypes { buildTypes {
@@ -42,32 +42,31 @@ android {
} }
dependencies { dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01") val composeBom = platform("androidx.compose:compose-bom:2026.06.00")
implementation(composeBom) implementation(composeBom)
// UI // UI
implementation("androidx.activity:activity-compose:1.9.3") implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3") implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended") implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.navigation:navigation-compose:2.8.5") implementation("androidx.navigation:navigation-compose:2.9.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.0")
// Dados locais // Dados locais
implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-runtime:2.8.3")
implementation("androidx.room:room-ktx:2.6.1") implementation("androidx.room:room-ktx:2.8.3")
ksp("androidx.room:room-compiler:2.6.1") ksp("androidx.room:room-compiler:2.8.3")
implementation("androidx.datastore:datastore-preferences:1.1.1") implementation("androidx.datastore:datastore-preferences:1.1.7")
// Rede + serialização
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
// Sync em segundo plano // Sync em segundo plano
implementation("androidx.work:work-runtime-ktx:2.9.1") implementation("androidx.work:work-runtime-ktx:2.10.1")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
} }
@@ -3,28 +3,16 @@ package com.labexposed.notas
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement import androidx.activity.enableEdgeToEdge
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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.labexposed.notas.domain.SyncPhase import androidx.lifecycle.Lifecycle
import com.labexposed.notas.domain.SyncUiState
import com.labexposed.notas.ui.EditorScreen import com.labexposed.notas.ui.EditorScreen
import com.labexposed.notas.ui.NotesListScreen import com.labexposed.notas.ui.NotesListScreen
import com.labexposed.notas.ui.NotasViewModel import com.labexposed.notas.ui.NotasViewModel
@@ -34,6 +22,7 @@ import com.labexposed.notas.ui.theme.NotasTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { setContent {
NotasTheme { NotasApp_() } NotasTheme { NotasApp_() }
} }
@@ -45,6 +34,9 @@ private fun NotasApp_(vm: NotasViewModel = viewModel()) {
val nav = rememberNavController() val nav = rememberNavController()
val settings by vm.settings.collectAsState() val settings by vm.settings.collectAsState()
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { vm.onResumed() }
LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) { vm.onPaused() }
NavHost(navController = nav, startDestination = if (settings.configured) "list" else "settings") { NavHost(navController = nav, startDestination = if (settings.configured) "list" else "settings") {
composable("settings") { composable("settings") {
SettingsScreen(vm, onDone = { nav.navigate("list") { popUpTo("settings") { inclusive = true } } }) SettingsScreen(vm, onDone = { nav.navigate("list") { popUpTo("settings") { inclusive = true } } })
@@ -63,36 +55,3 @@ private fun NotasApp_(vm: NotasViewModel = viewModel()) {
} }
} }
} }
/** 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)
@@ -4,10 +4,12 @@ import android.content.Context
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database( @Database(
entities = [NoteEntity::class, OutboxEntity::class, CategoryEntity::class, MetaEntity::class], entities = [NoteEntity::class, OutboxEntity::class, CategoryEntity::class, MetaEntity::class],
version = 2, version = 3,
exportSchema = false, exportSchema = false,
) )
abstract class NotasDatabase : RoomDatabase() { abstract class NotasDatabase : RoomDatabase() {
@@ -20,6 +22,17 @@ abstract class NotasDatabase : RoomDatabase() {
@Volatile @Volatile
private var instance: NotasDatabase? = null private var instance: NotasDatabase? = null
/**
* v1→v2→v3: a mudança real foi o índice único (noteId, op) na outbox
* (v2). Migração recria o índice sem tocar nos dados — a fila não
* enviada sobrevive ao upgrade, ao contrário do destructive fallback.
*/
val MIGRATION_2_3: Migration = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_outbox_noteId_op` ON `outbox` (`noteId`, `op`)")
}
}
fun get(context: Context): NotasDatabase = fun get(context: Context): NotasDatabase =
instance ?: synchronized(this) { instance ?: synchronized(this) {
instance ?: Room.databaseBuilder( instance ?: Room.databaseBuilder(
@@ -27,7 +40,7 @@ abstract class NotasDatabase : RoomDatabase() {
NotasDatabase::class.java, NotasDatabase::class.java,
"notas.db", "notas.db",
) )
.fallbackToDestructiveMigration() // v1→v2: dados de teste; em produção usar migração real .addMigrations(MIGRATION_2_3)
.build().also { instance = it } .build().also { instance = it }
} }
} }
@@ -1,5 +1,6 @@
package com.labexposed.notas.data.remote package com.labexposed.notas.data.remote
import kotlinx.coroutines.delay
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@@ -8,7 +9,6 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
* Cliente HTTP da API notas-online: Bearer token, timeout curto, retry com * Cliente HTTP da API notas-online: Bearer token, timeout curto, retry com
* backoff apenas para falhas de rede (IOException), mapeamento de status em * backoff apenas para falhas de rede (IOException), mapeamento de status em
@@ -29,7 +29,7 @@ class ApiClient(private val baseUrl: String, private val token: String) {
private fun url(path: String) = baseUrl.trimEnd('/') + path private fun url(path: String) = baseUrl.trimEnd('/') + path
/** Executa com até 2 tentativas; erros de rede/URL inválida viram Network. */ /** Executa com até 2 tentativas; erros de rede/URL inválida viram Network. */
private inline fun <reified T> call( private suspend inline fun <reified T> call(
method: String, method: String,
path: String, path: String,
body: String? = null, body: String? = null,
@@ -65,7 +65,7 @@ class ApiClient(private val baseUrl: String, private val token: String) {
} }
} catch (e: IOException) { } catch (e: IOException) {
lastError = e lastError = e
if (attempt == 0) Thread.sleep(500) if (attempt == 0) delay(500)
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
// URL malformada (configuração errada): não adianta repetir // URL malformada (configuração errada): não adianta repetir
return ApiResult.Network("URL inválida: ${e.message}") return ApiResult.Network("URL inválida: ${e.message}")
@@ -74,19 +74,25 @@ class ApiClient(private val baseUrl: String, private val token: String) {
return ApiResult.Network(lastError?.message ?: "sem conexão") return ApiResult.Network(lastError?.message ?: "sem conexão")
} }
fun testConnection(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes") suspend fun testConnection(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
fun listNotes(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes") suspend fun listNotes(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
fun listCategories(): ApiResult<List<CategoryDto>> = call("GET", "/api/categories") suspend fun listCategories(): ApiResult<List<CategoryDto>> = call("GET", "/api/categories")
fun getNote(id: String): ApiResult<NoteDetailDto> = call("GET", "/api/notes/$id") suspend fun getNote(id: String): ApiResult<NoteDetailDto> = call("GET", "/api/notes/$id")
fun createNote(dto: NoteWriteDto): ApiResult<NoteDetailDto> = suspend fun createNote(dto: NoteWriteDto): ApiResult<NoteDetailDto> =
call("POST", "/api/notes", json.encodeToString(dto)) call("POST", "/api/notes", json.encodeToString(dto))
fun updateNote(id: String, dto: NoteWriteDto): ApiResult<NoteDetailDto> = suspend fun updateNote(id: String, dto: NoteWriteDto): ApiResult<NoteDetailDto> =
call("PUT", "/api/notes/$id", json.encodeToString(dto)) call("PUT", "/api/notes/$id", json.encodeToString(dto))
fun deleteNote(id: String): ApiResult<Unit> = call("DELETE", "/api/notes/$id") suspend fun deleteNote(id: String): ApiResult<Unit> = call("DELETE", "/api/notes/$id")
suspend fun renderMd(content: String): ApiResult<String> {
val body = """{"content":${json.encodeToString(content)},"format":"md"}"""
val r = call<RenderDto>("POST", "/api/render", body)
return if (r is ApiResult.Ok) ApiResult.Ok((r.value as RenderDto).html) else r as ApiResult<Nothing>
}
} }
@@ -1,5 +1,7 @@
package com.labexposed.notas.data.remote package com.labexposed.notas.data.remote
import com.labexposed.notas.data.local.NoteEntity
import java.time.Instant
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -60,6 +62,9 @@ data class ConflictDto(
@SerialName("updated_at") val updatedAt: String = "", @SerialName("updated_at") val updatedAt: String = "",
) )
@Serializable
data class RenderDto(val html: String = "")
/** Resultado tipado das chamadas — sem exceções de fluxo de controle. */ /** Resultado tipado das chamadas — sem exceções de fluxo de controle. */
sealed class ApiResult<out T> { sealed class ApiResult<out T> {
data class Ok<T>(val value: T) : ApiResult<T>() data class Ok<T>(val value: T) : ApiResult<T>()
@@ -69,3 +74,18 @@ sealed class ApiResult<out T> {
data class Network(val message: String) : ApiResult<Nothing>() data class Network(val message: String) : ApiResult<Nothing>()
data class Server(val code: Int, val message: String) : ApiResult<Nothing>() data class Server(val code: Int, val message: String) : ApiResult<Nothing>()
} }
// ---- mapeamento para a entidade local ----
fun NoteDetailDto.toNoteEntity(): NoteEntity = NoteEntity(
id = id,
title = title,
format = format,
categories = (categories ?: emptyList()).joinToString(","),
tags = (tags ?: emptyList()).joinToString(","),
content = content,
serverHash = hash,
createdAt = runCatching { Instant.parse(createdAt).toEpochMilli() }.getOrElse { System.currentTimeMillis() },
updatedAt = runCatching { Instant.parse(updatedAt).toEpochMilli() }.getOrElse { System.currentTimeMillis() },
dirty = false,
)
@@ -0,0 +1,75 @@
package com.labexposed.notas.data.remote
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
/**
* Cliente SSE (Server-Sent Events) para eventos em tempo real do servidor:
* `saved` (nota mudou em outra sessão) e `presence` (quem está com o quê aberto).
* Reconexão com backoff; roda apenas com o app em primeiro plano — em
* background o WorkManager assume.
*/
class SseClient(private val baseUrl: String, private val token: String, private val sessionId: String) {
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // stream longo
.build()
fun connect(scope: CoroutineScope, onEvent: (SseEvent) -> Unit): Job = scope.launch(Dispatchers.IO) {
var backoff = 1_000L
while (isActive) {
try {
streamOnce(onEvent)
backoff = 1_000L // stream terminou limpo (scope cancelado não chega aqui)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// rede caiu / servidor fora: espera e tenta de novo
}
delay(backoff)
backoff = (backoff * 2).coerceAtMost(30_000L)
}
}
private suspend fun streamOnce(onEvent: (SseEvent) -> Unit) {
val request = Request.Builder()
.url(baseUrl.trimEnd('/') + "/api/events?session=" + sessionId)
.header("Authorization", "Bearer $token")
.header("Accept", "text/event-stream")
.build()
val call = http.newCall(request)
currentCoroutineContext().job.invokeOnCompletion { runCatching { call.cancel() } }
call.execute().use { resp ->
if (!resp.isSuccessful) throw java.io.IOException("SSE HTTP ${resp.code}")
val reader = resp.body?.source()?.inputStream()?.bufferedReader() ?: throw java.io.IOException("sem corpo")
var data = StringBuilder()
while (true) {
currentCoroutineContext().ensureActive()
val line = reader.readLine() ?: break
when {
line.startsWith("data: ") -> data.append(line.removePrefix("data: "))
line.startsWith("data:") -> data.append(line.removePrefix("data:").trimStart())
line.isEmpty() && data.isNotEmpty() -> {
val raw = data.toString()
data = StringBuilder()
runCatching { SseEvent.parse(raw) }.getOrNull()?.let(onEvent)
}
else -> { /* comentários ": ping" e campos outros */ }
}
}
}
throw java.io.IOException("SSE encerrado")
}
}
@@ -0,0 +1,38 @@
package com.labexposed.notas.data.remote
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class SsePresenceSession(val session: String, val notes: List<String>? = null)
@Serializable
data class SseEventDto(
val type: String = "",
@SerialName("note_id") val noteId: String = "",
val hash: String = "",
val title: String = "",
val author: String = "",
val session: String = "",
val deleted: Boolean = false,
val sessions: List<SsePresenceSession>? = null,
)
private val json = Json { ignoreUnknownKeys = true }
sealed class SseEvent {
data class Saved(val noteId: String, val hash: String, val title: String, val session: String, val deleted: Boolean) : SseEvent()
data class Presence(val sessions: List<SsePresenceSession>) : SseEvent()
companion object {
fun parse(raw: String): SseEvent? {
val dto = json.decodeFromString<SseEventDto>(raw)
return when (dto.type) {
"saved" -> Saved(dto.noteId, dto.hash, dto.title, dto.session, dto.deleted)
"presence", "hello" -> Presence(dto.sessions ?: emptyList())
else -> null
}
}
}
}
@@ -0,0 +1,16 @@
package com.labexposed.notas.domain
import android.content.Context
/** Id de sessão estável por instalação — usado em presence/SSE e no eco de `session`. */
object SessionId {
fun get(context: Context): String {
val p = context.getSharedPreferences("notas", Context.MODE_PRIVATE)
var s = p.getString("sid", null)
if (s.isNullOrEmpty()) {
s = java.util.UUID.randomUUID().toString()
p.edit().putString("sid", s).apply()
}
return s
}
}
@@ -9,7 +9,7 @@ import com.labexposed.notas.data.remote.ApiClient
import com.labexposed.notas.data.remote.ApiResult import com.labexposed.notas.data.remote.ApiResult
import com.labexposed.notas.data.remote.NoteDetailDto import com.labexposed.notas.data.remote.NoteDetailDto
import com.labexposed.notas.data.remote.NoteWriteDto import com.labexposed.notas.data.remote.NoteWriteDto
import java.time.Instant import com.labexposed.notas.data.remote.toNoteEntity
import java.util.UUID import java.util.UUID
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -37,6 +37,7 @@ class SyncEngine(
private val db: NotasDatabase, private val db: NotasDatabase,
private val settings: SettingsRepository, private val settings: SettingsRepository,
private val status: SyncStatusStore, private val status: SyncStatusStore,
private val session: String = "android",
) { ) {
private suspend fun api(): ApiClient? { private suspend fun api(): ApiClient? {
@@ -50,6 +51,8 @@ class SyncEngine(
SyncStatusStore.syncMutex.withLock { SyncStatusStore.syncMutex.withLock {
try { try {
runSync(trigger) runSync(trigger)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("SyncEngine", "fullSync($trigger) falhou", e) android.util.Log.e("SyncEngine", "fullSync($trigger) falhou", e)
status.set { it.copy(phase = SyncPhase.Error, message = e.message ?: "erro interno") } status.set { it.copy(phase = SyncPhase.Error, message = e.message ?: "erro interno") }
@@ -106,6 +109,21 @@ class SyncEngine(
// ------------------------------------------------------------------ push // ------------------------------------------------------------------ push
private suspend fun push(client: ApiClient, author: String): SyncOutcome { private suspend fun push(client: ApiClient, author: String): SyncOutcome {
// auto-reconciliação: notas sujas sem entrada na fila (ex.: só mudaram
// metadados, ou ficaram presas de versões anteriores do app)
for (n in db.notes().all()) {
if (n.dirty && !n.pendingDelete &&
!db.outbox().has(n.id, "create") && !db.outbox().has(n.id, "update")
) {
db.outbox().enqueue(
OutboxEntity(
noteId = n.id,
op = if (n.isLocalOnly) "create" else "update",
queuedAt = System.currentTimeMillis(),
),
)
}
}
val queue = db.outbox().queue() val queue = db.outbox().queue()
if (queue.isEmpty()) return SyncOutcome.Success(0, 0, 0) if (queue.isEmpty()) return SyncOutcome.Success(0, 0, 0)
var pushed = 0 var pushed = 0
@@ -152,6 +170,12 @@ class SyncEngine(
), ),
) )
db.outbox().drop(entry.seq) db.outbox().drop(entry.seq)
if (!clean && !fresh.pendingDelete) {
// conteúdo mudou durante o push: mantém na fila
db.outbox().enqueue(
OutboxEntity(noteId = fresh.id, op = "update", queuedAt = System.currentTimeMillis()),
)
}
pushed++ pushed++
} }
is ApiResult.Conflict -> conflicts += resolveConflict(client, note, resp.serverHash) is ApiResult.Conflict -> conflicts += resolveConflict(client, note, resp.serverHash)
@@ -316,23 +340,10 @@ class SyncEngine(
author = author, author = author,
message = "sync android", message = "sync android",
baseHash = serverHash, baseHash = serverHash,
session = this@SyncEngine.session,
) )
private fun NoteDetailDto.toEntity() = NoteEntity( private fun NoteDetailDto.toEntity() = toNoteEntity()
id = id,
title = title,
format = format,
categories = (categories ?: emptyList()).joinToString(","),
tags = (tags ?: emptyList()).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) { private fun describe(r: ApiResult<*>): String = when (r) {
is ApiResult.Server -> "HTTP ${r.code} ${r.message}" is ApiResult.Server -> "HTTP ${r.code} ${r.message}"
@@ -22,10 +22,15 @@ class SyncWorker(context: Context, params: WorkerParameters) :
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val db = NotasDatabase.get(applicationContext) val db = NotasDatabase.get(applicationContext)
val engine = SyncEngine(db, SettingsRepository(applicationContext), SyncStatusStore) val engine = SyncEngine(
db,
SettingsRepository(applicationContext),
SyncStatusStore,
com.labexposed.notas.domain.SessionId.get(applicationContext),
)
return when (val outcome = engine.fullSync(trigger = inputData.getString(TRIGGER) ?: "auto")) { 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.NetworkDown -> Result.retry()
is com.labexposed.notas.domain.SyncOutcome.Failed -> Result.retry() is com.labexposed.notas.domain.SyncOutcome.Failed -> Result.failure()
is com.labexposed.notas.domain.SyncOutcome.Unauthorized -> Result.success() is com.labexposed.notas.domain.SyncOutcome.Unauthorized -> Result.success()
else -> Result.success() else -> Result.success()
} }
@@ -13,8 +13,10 @@ import com.labexposed.notas.domain.SettingsRepository
import com.labexposed.notas.domain.SyncStatusStore import com.labexposed.notas.domain.SyncStatusStore
import com.labexposed.notas.domain.SyncUiState import com.labexposed.notas.domain.SyncUiState
import com.labexposed.notas.sync.SyncScheduler import com.labexposed.notas.sync.SyncScheduler
import android.content.Context
import java.util.UUID import java.util.UUID
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -22,10 +24,14 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import com.labexposed.notas.data.remote.SseClient
import com.labexposed.notas.data.remote.SseEvent
import com.labexposed.notas.data.remote.toNoteEntity
sealed class ListEvent { sealed class ListEvent {
data class ConnectionOk(val notes: Int) : ListEvent() data class ConnectionOk(val notes: Int) : ListEvent()
data class ConnectionFail(val message: String) : ListEvent() data class ConnectionFail(val message: String) : ListEvent()
data class RemoteChange(val title: String) : ListEvent()
} }
class NotasViewModel(app: Application) : AndroidViewModel(app) { class NotasViewModel(app: Application) : AndroidViewModel(app) {
@@ -54,8 +60,104 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
private val _events = MutableStateFlow<ListEvent?>(null) private val _events = MutableStateFlow<ListEvent?>(null)
val events: StateFlow<ListEvent?> = _events val events: StateFlow<ListEvent?> = _events
/** id de sessão estável por instalação (mesmo valor usado no presence/SSE). */
val sid: String = com.labexposed.notas.domain.SessionId.get(getApplication<android.app.Application>())
private var sseJob: Job? = null
private var sseApi: ApiClient? = null
/** noteId → nº de outras sessões com a nota aberta (presença). */
val presence = MutableStateFlow<Map<String, Int>>(emptyMap())
/** notas sujas que mudaram remotamente (banner no editor). */
val remoteChanged = MutableStateFlow<Set<String>>(emptySet())
/** id da nota atualmente aberta no editor (para refresh ao vivo via SSE). */
@Volatile
var openNoteId: String? = null
/** conteúdo novo para o editor aberto (consumido e limpo pela UI). */
val editorRefresh = MutableStateFlow<Pair<String, String>?>(null)
fun onResumed() {
viewModelScope.launch {
val s = settingsRepo.current()
if (!s.configured) return@launch
if (sseJob?.isActive != true) {
val client = SseClient(s.serverUrl, s.token, sid)
sseApi = ApiClient(s.serverUrl, s.token)
sseJob = client.connect(viewModelScope) { ev -> handleSse(ev) }
}
SyncScheduler.runNow(getApplication(), "resume")
}
}
fun onPaused() {
sseJob?.cancel()
sseJob = null
}
private fun handleSse(ev: SseEvent) = viewModelScope.launch(Dispatchers.IO) {
when (ev) {
is SseEvent.Presence -> {
val map = mutableMapOf<String, Int>()
for (s in ev.sessions) {
if (s.session == sid) continue
for (n in s.notes ?: emptyList()) map[n] = (map[n] ?: 0) + 1
}
presence.value = map
}
is SseEvent.Saved -> {
if (ev.session == sid) return@launch
val note = db.notes().byId(ev.noteId) ?: return@launch
if (ev.deleted) {
if (!note.dirty) {
db.notes().delete(ev.noteId)
db.outbox().dropFor(ev.noteId)
_events.value = ListEvent.RemoteChange(ev.title)
}
return@launch
}
if (note.serverHash == ev.hash) return@launch
if (!note.dirty) {
val detail = sseApi?.getNote(ev.noteId)
if (detail is ApiResult.Ok) {
db.notes().upsert(detail.value.toNoteEntity())
_events.value = ListEvent.RemoteChange(ev.title)
if (openNoteId == ev.noteId || db.meta().get("rekey:$openNoteId") == ev.noteId) {
openNoteId?.let { editorRefresh.value = it to detail.value.content }
}
}
} else {
remoteChanged.value = remoteChanged.value + ev.noteId
}
}
}
}
/** Nota abriu e mudou remotamente: recarrega do servidor sob demanda. */
fun reloadFromServer(id: String, then: ((NoteEntity?) -> Unit)? = null) = viewModelScope.launch(Dispatchers.IO) {
val detail = sseApi?.getNote(id)
if (detail is ApiResult.Ok) {
db.notes().upsert(detail.value.toNoteEntity())
remoteChanged.value = remoteChanged.value - id
}
then?.invoke(db.notes().byId(id))
}
fun dismissRemoteChange(id: String) {
remoteChanged.value = remoteChanged.value - id
}
fun renderMarkdown(content: String, onResult: (String) -> Unit) = viewModelScope.launch(Dispatchers.IO) {
val html = sseApi?.renderMd(content)
if (html is ApiResult.Ok) onResult(html.value) else onResult("<p>…sem conexão para renderizar…</p>")
}
fun saveSettings(s: Settings) = viewModelScope.launch { fun saveSettings(s: Settings) = viewModelScope.launch {
settingsRepo.save(s) settingsRepo.save(s)
sseApi = if (s.configured) ApiClient(s.serverUrl, s.token) else null
onPaused(); onResumed()
SyncScheduler.runNow(getApplication(), "config") SyncScheduler.runNow(getApplication(), "config")
} }
@@ -100,7 +202,7 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
return if (db.notes().byId(mapped) != null) mapped else null return if (db.notes().byId(mapped) != null) mapped else null
} }
/** Salva edição local + enfileira mutação + agenda sync (debounce no editor). */ /** Salva edição local (conteúdo OU metadados) + enfileira + agenda sync. */
suspend fun saveNoteSync( suspend fun saveNoteSync(
id: String, id: String,
title: String, title: String,
@@ -112,6 +214,8 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
val existing = db.notes().byId(targetId) ?: return val existing = db.notes().byId(targetId) ?: return
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val contentChanged = existing.content != content val contentChanged = existing.content != content
val metaChanged = existing.title != title || existing.format != format ||
existing.categories != categories.joinToString(",")
db.notes().upsert( db.notes().upsert(
existing.copy( existing.copy(
id = targetId, id = targetId,
@@ -124,7 +228,7 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
conflictNote = "", conflictNote = "",
), ),
) )
if (contentChanged) { if (contentChanged || metaChanged) {
// nota em create pendente já está na fila com o conteúdo atual (upsert acima) // nota em create pendente já está na fila com o conteúdo atual (upsert acima)
if (!db.outbox().has(targetId, "create") && !db.outbox().has(targetId, "update")) { if (!db.outbox().has(targetId, "create") && !db.outbox().has(targetId, "update")) {
db.outbox().enqueue(OutboxEntity(noteId = targetId, op = "update", queuedAt = now)) db.outbox().enqueue(OutboxEntity(noteId = targetId, op = "update", queuedAt = now))
@@ -0,0 +1,20 @@
package com.labexposed.notas.ui
import com.labexposed.notas.data.local.NoteEntity
internal fun filterNotes(
notes: List<NoteEntity>,
query: String,
categoryId: String? = null,
tag: String? = null,
): List<NoteEntity> {
val normalizedQuery = query.trim()
return notes.filter { note ->
(categoryId == null || note.categories.split(",").contains(categoryId)) &&
(tag == null || note.tags.split(",").contains(tag)) &&
(normalizedQuery.isEmpty() ||
note.title.contains(normalizedQuery, ignoreCase = true) ||
note.content.contains(normalizedQuery, ignoreCase = true) ||
note.tags.split(",").any { it.contains(normalizedQuery, ignoreCase = true) })
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,247 @@
package com.labexposed.notas.ui
import android.annotation.SuppressLint
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import org.json.JSONObject
/**
* Editor WYSIWYG / Preview em WebView nativo (sem dependências extras).
* A página carrega um contenteditable + serializador html→markdown (porta do
* app web) + sanitizador; a ponte AndroidBridge devolve markdown ao Kotlin.
*/
class WysiwygController {
internal var web: WebView? = null
private var ready = false
private var pendingHtml: String? = null
private var pendingMd: ((String) -> Unit)? = null
var onChanged: (() -> Unit)? = null
private val main = android.os.Handler(android.os.Looper.getMainLooper())
/** WebView só aceita chamadas na main thread — sempre postar. */
private fun onMain(block: (WebView) -> Unit) {
val w = web ?: return
if (Looper.myLooper() == Looper.getMainLooper()) block(w) else main.post { if (web === w) block(w) }
}
fun setHtml(html: String) {
if (ready && web != null) {
onMain { it.evaluateJavascript("setHtml(${JSONObject.quote(html)})", null) }
} else {
pendingHtml = html
}
}
fun exec(cmd: String, arg: String? = null) {
val js = if (arg != null) "execCmd('${cmd}', ${JSONObject.quote(arg)})" else "execCmd('${cmd}')"
onMain { it.evaluateJavascript(js, null) }
}
fun requestMd(cb: (String) -> Unit) {
pendingMd = cb
onMain { it.evaluateJavascript("AndroidBridge.deliver(getMd())", null) }
}
inner class Bridge {
@JavascriptInterface
fun onReady() {
ready = true
main.post {
pendingHtml?.let { h ->
web?.evaluateJavascript("setHtml(${JSONObject.quote(h)})", null)
pendingHtml = null
}
}
}
@JavascriptInterface
fun onChanged() {
main.post { onChanged?.invoke() }
}
@JavascriptInterface
fun deliver(md: String) {
main.post {
val cb = pendingMd
pendingMd = null
cb?.invoke(md)
}
}
}
internal fun reset() {
ready = false
pendingHtml = null
pendingMd = null
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun EditorWebView(
controller: WysiwygController,
dark: Boolean,
modifier: Modifier = Modifier,
) {
val bridge = remember(controller) { controller.Bridge() }
AndroidView(
modifier = modifier,
factory = { ctx ->
WebView(ctx).apply {
controller.web = this
controller.reset()
settings.javaScriptEnabled = true
settings.domStorageEnabled = false
settings.allowFileAccess = false
settings.allowContentAccess = false
settings.builtInZoomControls = false
setBackgroundColor(0)
addJavascriptInterface(bridge, "AndroidBridge")
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String?) {
view.evaluateJavascript("applyTheme(" + dark + ")", null)
}
override fun doUpdateVisitedHistory(view: WebView, url: String?, reload: Boolean) {
// links clicados no preview não navegam fora do app
if (url?.startsWith("http") == true) view.loadUrl("about:blank")
}
}
loadDataWithBaseURL("file:///android_asset/", WYSIWYG_PAGE, "text/html", "UTF-8", null)
}
},
onRelease = {
controller.web = null
it.destroy()
},
)
}
// Página do editor: contenteditable + html2md + sanitização + comandos.
// Sem template literals/backticks para conviver com raw string do Kotlin.
private val WYSIWYG_PAGE = """<!DOCTYPE html>
<html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<style>
:root{--fg:#222;--bg:#fff;--mut:#888;--acc:#1c71d8;--line:#e2e2e2;--soft:#f3f3f3}
.dark{--fg:#e6e6e6;--bg:#1e1e1e;--mut:#9a9a9a;--acc:#78aeff;--line:#3a3a3a;--soft:#2a2a2a}
html,body{margin:0;padding:0;background:var(--bg);color:var(--fg)}
#ed{padding:16px 18px;font:15px/1.6 -apple-system,Roboto,sans-serif;outline:none;min-height:70vh;word-break:break-word}
#ed[contenteditable="true"]:focus{outline:none}
#ed h1{font-size:1.6em;border-bottom:1px solid var(--line);padding-bottom:.2em}
#ed h2{font-size:1.3em}#ed h3{font-size:1.15em}
#ed blockquote{margin:.6em 0;padding:.2em .9em;border-left:3px solid var(--acc);color:var(--mut);background:var(--soft)}
#ed pre{background:var(--soft);border:1px solid var(--line);border-radius:6px;padding:10px;overflow-x:auto}
#ed code{background:var(--soft);padding:1px 5px;border-radius:4px;font-family:monospace}
#ed pre code{background:none;padding:0}
#ed a{color:var(--acc)}
#ed table{border-collapse:collapse}#ed th,#ed td{border:1px solid var(--line);padding:4px 8px}
#ed img{max-width:100%}
#ed ul,#ed ol{padding-left:1.6em}
</style></head><body>
<div id="ed" contenteditable="true" spellcheck="false"></div>
<script>
var FORMAT='md'; var EDITABLE=true; var DEB=0;
function applyTheme(d){document.documentElement.className=d?'dark':'';}
function sanitize(html){
var d=document.createElement('div'); d.innerHTML=html;
var bad=d.querySelectorAll('script,iframe,object,embed');
for(var i=0;i<bad.length;i++) bad[i].remove();
var all=d.querySelectorAll('*');
for(var j=0;j<all.length;j++){
var attrs=all[j].attributes;
for(var k=attrs.length-1;k>=0;k--){ if(/^on/i.test(attrs[k].name)) all[j].removeAttribute(attrs[k].name); }
}
return d.innerHTML;
}
function setHtml(h){ EDITABLE=document.getElementById('ed').getAttribute('contenteditable')==='true';
document.getElementById('ed').innerHTML=sanitize(h||''); }
function setEditable(v){ EDITABLE=v; document.getElementById('ed').setAttribute('contenteditable',v?'true':'false'); }
function setFormat(f){ FORMAT=f; }
function execCmd(c,a){ document.getElementById('ed').focus();
if(c==='formatBlock'&&a){document.execCommand('formatBlock',false,'<'+a+'>');}
else document.execCommand(c,false,a||null); }
function esc(s){return s.replace(/[\\`~_*\[\]]/g,'\\$&').replace(/^#(?=\s|$)/gm,'\\#');}
function inline(node){
if(node.nodeType===3) return esc(node.nodeValue);
var out='';
for(var i=0;i<node.childNodes.length;i++){
var c=node.childNodes[i];
if(c.nodeType===3){ out+=esc(c.nodeValue); continue; }
if(c.nodeType!==1) continue;
var t=c.tagName, inner=inline(c);
if(t==='BR'){out+='\n';}
else if(t==='STRONG'||t==='B'){out+='**'+inner+'**';}
else if(t==='EM'||t==='I'){out+='*'+inner+'*';}
else if(t==='DEL'||t==='S'||t==='STRIKE'){out+='~~'+inner+'~~';}
else if(t==='CODE'){out+='`'+inner+'`';}
else if(t==='A'){out+='['+inner+']('+(c.getAttribute('href')||'')+')';}
else if(t==='IMG'){out+='!['+(c.getAttribute('alt')||'')+']('+(c.getAttribute('src')||'')+')';}
else if(t==='INPUT'){/* tratado no LI */}
else{out+=inner;}
}
return out;
}
function tableMd(el){
var rows=el.querySelectorAll('tr'); if(!rows.length) return '';
var cells=function(tr){var a=[];for(var i=0;i<tr.children.length;i++)a.push(inline(tr.children[i]).trim().replace(/\|/g,'\\|'));return a;};
var head=cells(rows[0]); var body=[];
for(var r=1;r<rows.length;r++) body.push(cells(rows[r]));
var line=function(cs){return '| '+cs.join(' | ')+' |';};
var sep=head.map(function(){return '---';});
var out=[line(head),line(sep)];
for(var b=0;b<body.length;b++) out.push(line(body[b]));
return out.join('\n');
}
function block(root){
var parts=[];
for(var i=0;i<root.childNodes.length;i++){
var el=root.childNodes[i];
if(el.nodeType===3){var tx=esc(el.nodeValue).trim(); if(tx)parts.push(tx); continue;}
if(el.nodeType!==1) continue;
var t=el.tagName, chunk=null;
if(/^H[1-6]$/.test(t)) chunk='#'.repeat(+t[1])+' '+inline(el).trim();
else if(t==='P'){ var nested=el.querySelector('ul,ol,blockquote,pre,table');
chunk=(nested?block(el):inline(el)).trim(); }
else if(t==='UL'||t==='OL'){
var items=[]; var lis=el.children;
for(var li=0;li<lis.length;li++){
var cb=lis[li].querySelector('input[type=checkbox]');
var marker=(t==='UL')?'- ':(li+1)+'. ';
var text=inline(lis[li]).trim();
items.push(cb?marker+(cb.checked?'[x] ':'[ ] ')+text:marker+text);
}
chunk=items.join('\n');
}
else if(t==='BLOCKQUOTE'){ var inner=el.children.length?block(el):inline(el).trim();
chunk=inner.split('\n').map(function(l){return '> '+l;}).join('\n'); }
else if(t==='PRE') chunk='```\n'+el.textContent.replace(/\n+$/,'')+'\n```';
else if(t==='HR') chunk='---';
else if(t==='TABLE') chunk=tableMd(el);
else if(t==='BR') chunk=null;
else { var s=inline(el).trim(); chunk=s; }
if(chunk!==null&&chunk!=='') parts.push(chunk);
}
return parts.join('\n\n');
}
function getMd(){
var ed=document.getElementById('ed');
if(FORMAT==='html') return ed.innerHTML;
return block(ed);
}
document.getElementById('ed').addEventListener('input',function(){
clearTimeout(DEB);
DEB=setTimeout(function(){ if(EDITABLE) AndroidBridge.onChanged(); },350);
});
AndroidBridge.onReady();
</script></body></html>
"""
@@ -3,6 +3,7 @@ package com.labexposed.notas.ui.theme
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
@@ -10,8 +11,12 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// seed Greybird/Mousepad-like // seed Greybird/Mousepad-like (fallback quando dynamic color indisponível)
private val Light = lightColorScheme( private val Light = lightColorScheme(
primary = Color(0xFF1C71D8), primary = Color(0xFF1C71D8),
secondary = Color(0xFF3584E4), secondary = Color(0xFF3584E4),
@@ -24,6 +29,16 @@ private val Dark = darkColorScheme(
secondary = Color(0xFF3584E4), secondary = Color(0xFF3584E4),
) )
private val NotasTypography = Typography(
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp,
),
)
@Composable @Composable
fun NotasTheme(content: @Composable () -> Unit) { fun NotasTheme(content: @Composable () -> Unit) {
val ctx = LocalContext.current val ctx = LocalContext.current
@@ -34,5 +49,9 @@ fun NotasTheme(content: @Composable () -> Unit) {
isSystemInDarkTheme() -> Dark isSystemInDarkTheme() -> Dark
else -> Light else -> Light
} }
MaterialTheme(colorScheme = scheme, content = content) MaterialTheme(
colorScheme = scheme,
typography = NotasTypography,
content = content,
)
} }
@@ -0,0 +1,29 @@
package com.labexposed.notas.ui
import com.labexposed.notas.data.local.NoteEntity
import org.junit.Assert.assertEquals
import org.junit.Test
class NoteSearchTest {
@Test
fun queryMatchesTitleOrContentWithoutCaseSensitivity() {
val notes = listOf(
note("1", title = "Plano semanal", content = "rascunho"),
note("2", title = "Diário", content = "Revisar PLANO amanhã"),
note("3", title = "Compras", content = "leite e pão"),
)
val result = filterNotes(notes, query = "plano")
assertEquals(listOf("1", "2"), result.map { it.id })
}
private fun note(id: String, title: String, content: String) = NoteEntity(
id = id,
title = title,
format = "md",
content = content,
createdAt = 1L,
updatedAt = 1L,
)
}
+5 -5
View File
@@ -1,8 +1,8 @@
// Arquivo raiz — só declara plugins; versão centralizada aqui. // Arquivo raiz — só declara plugins; versão centralizada aqui.
plugins { plugins {
id("com.android.application") version "8.7.3" apply false id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.2.20" apply false
id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false id("com.google.devtools.ksp") version "2.2.20-2.0.4" apply false
} }
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
Vendored Regular → Executable
View File
File mode changed.