diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..e87aadf --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,90 @@ +name: Android + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: android-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build, lint e testes JVM + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + distribution: temurin + java-version: '21' + - uses: gradle/actions/setup-gradle@0b6dd653ba04f4f93bf581ec31e66cbd7dcb644d # v4 + - uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4.0.4 + - name: Instalar SDK exigido pelo projeto + run: sdkmanager 'platforms;android-36.1' 'build-tools;36.0.0' 'platform-tools' + - name: Compilar e validar + run: bash ./gradlew --no-daemon --stacktrace :app:assembleDebug :app:testDebugUnitTest :app:lintDebug :app:assembleDebugAndroidTest + - name: Preservar relatórios e APK de demonstração + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: android-build-reports + retention-days: 7 + path: | + app/build/reports/ + app/build/test-results/ + app/build/outputs/apk/debug/ + app/schemas/ + + instrumented: + name: Instrumentados API ${{ matrix.api }} + runs-on: ubuntu-24.04 + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + api: [24, 36] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + distribution: temurin + java-version: '21' + - uses: gradle/actions/setup-gradle@0b6dd653ba04f4f93bf581ec31e66cbd7dcb644d # v4 + with: + cache-read-only: true + - uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4.0.4 + - name: Instalar SDK de compilação + run: sdkmanager 'platforms;android-36.1' 'build-tools;36.0.0' + - name: Habilitar aceleração do emulador no runner + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Instalar APK e executar testes + uses: ReactiveCircus/android-emulator-runner@4c44018e59b437e86cdfc41da381398f93ed8808 # v2 + with: + api-level: ${{ matrix.api }} + target: google_apis + arch: x86_64 + disable-animations: true + script: bash ./gradlew --no-daemon --stacktrace :app:connectedDebugAndroidTest + - name: Preservar resultados do emulador + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: android-instrumented-api-${{ matrix.api }} + retention-days: 7 + path: | + app/build/reports/androidTests/ + app/build/outputs/androidTest-results/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index abb650e..9f32811 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,11 @@ plugins { android { namespace = "com.example.lensclickapp" + + ksp { + arg("room.schemaLocation", "$projectDir/schemas") + } + compileSdk { version = release(36) { minorApiLevel = 1 @@ -56,6 +61,7 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/schemas/com.example.lensclickapp.data.LensClickDatabase/3.json b/app/schemas/com.example.lensclickapp.data.LensClickDatabase/3.json new file mode 100644 index 0000000..a2dc0b0 --- /dev/null +++ b/app/schemas/com.example.lensclickapp.data.LensClickDatabase/3.json @@ -0,0 +1,199 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "f1474b71affe4f5de8745b07667164c0", + "entities": [ + { + "tableName": "photographers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER, `name` TEXT NOT NULL, `specialty` TEXT NOT NULL, `initials` TEXT NOT NULL, `price` TEXT NOT NULL, `city` TEXT NOT NULL DEFAULT 'São Paulo, SP', `bio` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "specialty", + "columnName": "specialty", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initials", + "columnName": "initials", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "city", + "columnName": "city", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'São Paulo, SP'" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_photographers_userId", + "unique": true, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_photographers_userId` ON `${TABLE_NAME}` (`userId`)" + } + ] + }, + { + "tableName": "budgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `date` TEXT NOT NULL, `status` TEXT NOT NULL, `info` TEXT NOT NULL, `description` TEXT NOT NULL, `additionalInfo` TEXT NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "additionalInfo", + "columnName": "additionalInfo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `role` TEXT NOT NULL DEFAULT 'client', `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'client'" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_users_email", + "unique": true, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_email` ON `${TABLE_NAME}` (`email`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f1474b71affe4f5de8745b07667164c0')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/lensclickapp/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/lensclickapp/ExampleInstrumentedTest.kt deleted file mode 100644 index a408277..0000000 --- a/app/src/androidTest/java/com/example/lensclickapp/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.example.lensclickapp - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.example.lensclickapp", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/lensclickapp/data/LensClickMigrationTest.kt b/app/src/androidTest/java/com/example/lensclickapp/data/LensClickMigrationTest.kt new file mode 100644 index 0000000..1ec076f --- /dev/null +++ b/app/src/androidTest/java/com/example/lensclickapp/data/LensClickMigrationTest.kt @@ -0,0 +1,103 @@ +package com.example.lensclickapp.data + +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Guarda de instrumentação da regra "nenhuma senha real no Room": a migração 2→3 + * recria a tabela de usuários sem coluna de credencial e preserva os perfis. + * + * Usa FrameworkSQLiteOpenHelper diretamente (sem MigrationTestHelper), porque o + * room-testing 2.8.5 tem incompatibilidade de kotlinx.serialization no runtime do + * emulador (AbstractMethodError ao desserializar o bundle de schema). Aqui criamos + * o banco v2 real e rodamos a migração Room verdadeira, validando o resultado. + */ +@RunWith(AndroidJUnit4::class) +class LensClickMigrationTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val testDb = "lensclick-migration-test" + + @After + fun tearDown() { + context.deleteDatabase(testDb) + } + + @Test + fun migration2To3DropsPasswordColumnAndKeepsUsers() { + // Recria o schema v2 histórico: users COM passwordHash (a credencial que + // esta entrega remove do Room). + openDatabase(2).writableDatabase.apply { + execSQL( + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL, + passwordHash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'client', + createdAt INTEGER NOT NULL + ) + """.trimIndent() + ) + execSQL( + """ + INSERT INTO users (id, name, email, passwordHash, role, createdAt) + VALUES (1, 'Usuário de demonstração', 'seu@email.com', 'hash-legado', 'client', 1000) + """.trimIndent() + ) + execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_users_email ON users(email)") + close() + } + + // Abre como v3: o SupportSQLiteOpenHelper aplica MIGRATION_2_3 de verdade. + val db = openDatabase(3).writableDatabase + + db.query("PRAGMA table_info(users)").use { cursor -> + val columns = mutableSetOf() + while (cursor.moveToNext()) columns += cursor.getString(cursor.getColumnIndexOrThrow("name")) + assertFalse("A coluna de senha não pode sobreviver à migração", "passwordHash" in columns) + assertTrue("email" in columns) + assertTrue("role" in columns) + } + + db.query("SELECT id, name, email, role, createdAt FROM users").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(1L, cursor.getLong(0)) + assertEquals("Usuário de demonstração", cursor.getString(1)) + assertEquals("seu@email.com", cursor.getString(2)) + assertEquals("client", cursor.getString(3)) + assertEquals(1000L, cursor.getLong(4)) + } + + db.query("SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='index_users_email'").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(1, cursor.getInt(0)) + } + + db.close() + } + + private fun openDatabase(version: Int): SupportSQLiteOpenHelper { + val configuration = SupportSQLiteOpenHelper.Configuration.builder(context) + .name(testDb) + .callback(object : SupportSQLiteOpenHelper.Callback(version) { + override fun onCreate(db: SupportSQLiteDatabase) = Unit + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) { + if (oldVersion < 2) LensClickDatabase.MIGRATION_1_2.migrate(db) + if (oldVersion < 3) LensClickDatabase.MIGRATION_2_3.migrate(db) + } + }) + .build() + return FrameworkSQLiteOpenHelperFactory().create(configuration) + } +} diff --git a/app/src/main/java/com/example/lensclickapp/data/LensClickData.kt b/app/src/main/java/com/example/lensclickapp/data/LensClickData.kt index abb9852..313063c 100644 --- a/app/src/main/java/com/example/lensclickapp/data/LensClickData.kt +++ b/app/src/main/java/com/example/lensclickapp/data/LensClickData.kt @@ -34,7 +34,6 @@ data class User( @PrimaryKey(autoGenerate = true) val id: Long = 0, val name: String, val email: String, - val passwordHash: String, @ColumnInfo(defaultValue = "'client'") val role: String = "client", val createdAt: Long = System.currentTimeMillis() ) diff --git a/app/src/main/java/com/example/lensclickapp/data/LensClickDatabase.kt b/app/src/main/java/com/example/lensclickapp/data/LensClickDatabase.kt index 4aa7001..32e4206 100644 --- a/app/src/main/java/com/example/lensclickapp/data/LensClickDatabase.kt +++ b/app/src/main/java/com/example/lensclickapp/data/LensClickDatabase.kt @@ -9,8 +9,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase @Database( entities = [Photographer::class, Budget::class, User::class], - version = 2, - exportSchema = false + version = 3, + exportSchema = true ) abstract class LensClickDatabase : RoomDatabase() { abstract fun lensClickDao(): LensClickDao @@ -18,7 +18,7 @@ abstract class LensClickDatabase : RoomDatabase() { companion object { @Volatile private var instance: LensClickDatabase? = null - private val MIGRATION_1_2 = object : Migration(1, 2) { + val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'client'") db.execSQL("ALTER TABLE photographers ADD COLUMN userId INTEGER") @@ -28,13 +28,35 @@ abstract class LensClickDatabase : RoomDatabase() { } } + // Keep local prototype profiles, but never keep password material in Room. + val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL(""" + CREATE TABLE users_without_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'client', + createdAt INTEGER NOT NULL + ) + """.trimIndent()) + db.execSQL(""" + INSERT INTO users_without_credentials (id, name, email, role, createdAt) + SELECT id, name, email, role, createdAt FROM users + """.trimIndent()) + db.execSQL("DROP TABLE users") + db.execSQL("ALTER TABLE users_without_credentials RENAME TO users") + db.execSQL("CREATE UNIQUE INDEX index_users_email ON users(email)") + } + } + fun getInstance(context: Context): LensClickDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, LensClickDatabase::class.java, "lens_click.db" - ).addMigrations(MIGRATION_1_2) + ).addMigrations(MIGRATION_1_2, MIGRATION_2_3) .build() .also { instance = it } } diff --git a/app/src/main/java/com/example/lensclickapp/data/LensClickRepository.kt b/app/src/main/java/com/example/lensclickapp/data/LensClickRepository.kt index edd0ea1..0fbc0a3 100644 --- a/app/src/main/java/com/example/lensclickapp/data/LensClickRepository.kt +++ b/app/src/main/java/com/example/lensclickapp/data/LensClickRepository.kt @@ -1,8 +1,14 @@ package com.example.lensclickapp.data import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap +/** Local prototype only. A future mobile API contract must replace this demo session. */ class LensClickRepository(private val dao: LensClickDao) { + // Registration credentials live only for this process; Room stores profiles, never passwords. + private val demoPasswordHashes = ConcurrentHashMap().apply { + put("seu@email.com", hashPassword("lensclick")) + } val photographers = dao.observePhotographers() val budgets = dao.observeBudgets() @@ -13,7 +19,6 @@ class LensClickRepository(private val dao: LensClickDao) { User( name = "Usuário de demonstração", email = "seu@email.com", - passwordHash = hashPassword("lensclick"), role = "client" ) ) @@ -23,11 +28,12 @@ class LensClickRepository(private val dao: LensClickDao) { val user = User( name = name.trim(), email = email.trim().lowercase(), - passwordHash = hashPassword(password), role = "client" ) val id = dao.insertUser(user) - return if (id == -1L) null else user.copy(id = id) + if (id == -1L) return null + demoPasswordHashes[user.email] = hashPassword(password) + return user.copy(id = id) } suspend fun registerPhotographer( @@ -42,26 +48,27 @@ class LensClickRepository(private val dao: LensClickDao) { val user = User( name = name.trim(), email = email.trim().lowercase(), - passwordHash = hashPassword(password), role = "photographer" ) val id = dao.insertPhotographerAccount( user = user, photographer = Photographer( - name = name.trim(), - specialty = specialty.trim(), - initials = initialsFor(name), - price = price.trim(), - city = city.trim(), - bio = bio.trim() + name = name.trim(), + specialty = specialty.trim(), + initials = initialsFor(name), + price = price.trim(), + city = city.trim(), + bio = bio.trim() ) ) - return if (id == -1L) null else user.copy(id = id) + if (id == -1L) return null + demoPasswordHashes[user.email] = hashPassword(password) + return user.copy(id = id) } suspend fun authenticate(email: String, password: String): User? { val user = dao.findUser(email.trim().lowercase()) ?: return null - return user.takeIf { it.passwordHash == hashPassword(password) } + return user.takeIf { demoPasswordHashes[it.email] == hashPassword(password) } } suspend fun createBudget( diff --git a/app/src/main/java/com/example/lensclickapp/ui/LensClickApp.kt b/app/src/main/java/com/example/lensclickapp/ui/LensClickApp.kt index 19fabc4..8b5b9e9 100644 --- a/app/src/main/java/com/example/lensclickapp/ui/LensClickApp.kt +++ b/app/src/main/java/com/example/lensclickapp/ui/LensClickApp.kt @@ -53,7 +53,7 @@ private val Gold = Color(0xFF9A6A24) private enum class Screen { Onboarding, AccountType, Login, SignUp, PhotographerSignUp, Home, Search, Photographer, Quote, Budgets, Conversations, Chat, Account, ProDashboard, ProRequests, ProAgenda, ProProfile } @Composable -fun LensClickApp(viewModel: LensClickViewModel = viewModel()) { +fun LensClickApp(viewModel: LensClickViewModel = viewModel(factory = LensClickViewModel.Factory)) { var screen by rememberSaveable { mutableStateOf(Screen.Onboarding) } var previous by rememberSaveable { mutableStateOf(Screen.Home) } val photographers by viewModel.photographers.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/example/lensclickapp/ui/LensClickViewModel.kt b/app/src/main/java/com/example/lensclickapp/ui/LensClickViewModel.kt index 1e9ec27..c3a2017 100644 --- a/app/src/main/java/com/example/lensclickapp/ui/LensClickViewModel.kt +++ b/app/src/main/java/com/example/lensclickapp/ui/LensClickViewModel.kt @@ -1,9 +1,12 @@ package com.example.lensclickapp.ui -import android.app.Application -import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory import com.example.lensclickapp.LensClickApplication +import com.example.lensclickapp.data.LensClickRepository import com.example.lensclickapp.data.User import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -12,8 +15,14 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -class LensClickViewModel(application: Application) : AndroidViewModel(application) { - private val repository = (application as LensClickApplication).repository +class LensClickViewModel(private val repository: LensClickRepository) : ViewModel() { + companion object { + val Factory = viewModelFactory { + initializer { + LensClickViewModel((this[APPLICATION_KEY] as LensClickApplication).repository) + } + } + } private val _currentUser = MutableStateFlow(null) val currentUser: StateFlow = _currentUser.asStateFlow() diff --git a/app/src/test/java/com/example/lensclickapp/ExampleUnitTest.kt b/app/src/test/java/com/example/lensclickapp/ExampleUnitTest.kt deleted file mode 100644 index 105bce6..0000000 --- a/app/src/test/java/com/example/lensclickapp/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.example.lensclickapp - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/app/src/test/java/com/example/lensclickapp/data/FakeLensClickDao.kt b/app/src/test/java/com/example/lensclickapp/data/FakeLensClickDao.kt new file mode 100644 index 0000000..033eaa8 --- /dev/null +++ b/app/src/test/java/com/example/lensclickapp/data/FakeLensClickDao.kt @@ -0,0 +1,44 @@ +package com.example.lensclickapp.data + +import kotlinx.coroutines.flow.MutableStateFlow + +/** In-memory test double; SQL constraints and transactions are tested on real Room. */ +class FakeLensClickDao : LensClickDao { + val photographerRows = MutableStateFlow>(emptyList()) + val budgetRows = MutableStateFlow>(emptyList()) + val users = mutableListOf() + + override fun observePhotographers() = photographerRows + override fun observeBudgets() = budgetRows + override suspend fun photographerCount() = photographerRows.value.size + override suspend fun budgetCount() = budgetRows.value.size + + override suspend fun insertPhotographers(photographers: List) { + photographers.forEach { insertPhotographer(it) } + } + + override suspend fun insertPhotographer(photographer: Photographer): Long { + val id = photographerRows.value.size.toLong() + 1 + photographerRows.value += photographer.copy(id = id) + return id + } + + override suspend fun insertBudget(budget: Budget): Long { + val id = budgetRows.value.size.toLong() + 1 + budgetRows.value += budget.copy(id = id) + return id + } + + override suspend fun insertBudgets(budgets: List) { + budgets.forEach { insertBudget(it) } + } + + override suspend fun insertUser(user: User): Long { + if (users.any { it.email == user.email }) return -1L + val id = users.size.toLong() + 1 + users += user.copy(id = id) + return id + } + + override suspend fun findUser(email: String) = users.find { it.email == email } +} diff --git a/app/src/test/java/com/example/lensclickapp/data/LensClickRepositoryTest.kt b/app/src/test/java/com/example/lensclickapp/data/LensClickRepositoryTest.kt new file mode 100644 index 0000000..8dd6d41 --- /dev/null +++ b/app/src/test/java/com/example/lensclickapp/data/LensClickRepositoryTest.kt @@ -0,0 +1,108 @@ +package com.example.lensclickapp.data + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class LensClickRepositoryTest { + private val dao = FakeLensClickDao() + private val repository = LensClickRepository(dao) + + @Test + fun seedingTwiceKeepsExistingDataAndOneDemoAccount() = runTest { + repository.seedDemoData() + repository.createBudget("Novo", "20/09/2026", "Recife", "2 horas", "Ensaio", "") + repository.seedDemoData() + + assertEquals(LensClickData.photographers.size, dao.photographerRows.value.size) + assertEquals(LensClickData.budgets.size + 1, dao.budgetRows.value.size) + assertEquals(1, dao.users.size) + assertNotNull(repository.authenticate(" SEU@EMAIL.COM ", "lensclick")) + } + + @Test + fun registrationNormalizesProfileAndAcceptsOnlyMatchingDemoPassword() = runTest { + val user = repository.register(" Ana Silva ", " ANA@EXAMPLE.TEST ", "fictional-demo")!! + + assertTrue(user.id > 0) + assertEquals("Ana Silva", user.name) + assertEquals("ana@example.test", user.email) + assertEquals("client", user.role) + assertEquals(user, repository.authenticate(" Ana@Example.Test ", "fictional-demo")) + assertNull(repository.authenticate(user.email, "wrong-demo")) + assertNull(repository.authenticate("missing@example.test", "fictional-demo")) + } + + @Test + fun duplicateRegistrationDoesNotReplaceAnExistingDemoPassword() = runTest { + val original = repository.register("Ana", "ana@example.test", "first-demo") + val duplicate = repository.register("Outra", " ANA@EXAMPLE.TEST ", "second-demo") + + assertNull(duplicate) + assertEquals(listOf(original), dao.users) + assertEquals(original, repository.authenticate("ana@example.test", "first-demo")) + assertNull(repository.authenticate("ana@example.test", "second-demo")) + } + + @Test + fun registeredCredentialsDoNotSurviveRepositoryRecreation() = runTest { + val user = repository.register("Ana", "ana@example.test", "temporary-demo")!! + val recreated = LensClickRepository(dao) + + assertEquals(user, dao.findUser(user.email)) + assertNull(recreated.authenticate(user.email, "temporary-demo")) + recreated.seedDemoData() + assertNotNull(recreated.authenticate("seu@email.com", "lensclick")) + } + + @Test + fun photographerRegistrationLinksProfileAndTrimsFields() = runTest { + val user = repository.registerPhotographer( + " Júlia Melo Souza ", " JULIA@EXAMPLE.TEST ", "fictional-demo", + " Eventos ", " Recife, PE ", " R$ 800 ", " Retratos naturais " + )!! + + val photographer = dao.photographerRows.value.single() + assertEquals("photographer", user.role) + assertEquals(user.id, photographer.userId) + assertEquals("JM", photographer.initials) + assertEquals("Júlia Melo Souza", photographer.name) + assertEquals("Eventos", photographer.specialty) + assertEquals("Recife, PE", photographer.city) + assertEquals("R$ 800", photographer.price) + assertEquals("Retratos naturais", photographer.bio) + assertEquals(user, repository.authenticate(user.email, "fictional-demo")) + } + + @Test + fun duplicatePhotographerRegistrationDoesNotAddAnOrphanProfile() = runTest { + repository.register("Ana", "ana@example.test", "fictional-demo") + + val result = repository.registerPhotographer( + "Ana", " ANA@EXAMPLE.TEST ", "other-demo", "Eventos", "Recife", "R$ 800", "Bio" + ) + + assertNull(result) + assertTrue(dao.photographerRows.value.isEmpty()) + assertEquals("client", dao.users.single().role) + } + + @Test + fun newBudgetPreservesRequestDetailsAndStartsWaiting() = runTest { + repository.createBudget( + " Casamento ", " 20/09/2026 ", " Recife, PE ", " 4 horas ", + " Cerimônia e festa ", " Área externa " + ) + + val budget = dao.budgetRows.value.single() + assertEquals("Casamento", budget.title) + assertEquals("20/09/2026 • Recife, PE", budget.date) + assertEquals("Aguardando", budget.status) + assertEquals("4 horas", budget.info) + assertEquals("Cerimônia e festa", budget.description) + assertEquals("Área externa", budget.additionalInfo) + } +} diff --git a/app/src/test/java/com/example/lensclickapp/ui/LensClickViewModelTest.kt b/app/src/test/java/com/example/lensclickapp/ui/LensClickViewModelTest.kt new file mode 100644 index 0000000..bd1d00f --- /dev/null +++ b/app/src/test/java/com/example/lensclickapp/ui/LensClickViewModelTest.kt @@ -0,0 +1,128 @@ +package com.example.lensclickapp.ui + +import androidx.lifecycle.ViewModelStore +import com.example.lensclickapp.data.FakeLensClickDao +import com.example.lensclickapp.data.LensClickRepository +import com.example.lensclickapp.data.User +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class LensClickViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val store = ViewModelStore() + private lateinit var dao: FakeLensClickDao + private lateinit var viewModel: LensClickViewModel + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + dao = FakeLensClickDao() + viewModel = LensClickViewModel(LensClickRepository(dao)) + store.put("test", viewModel) + } + + @After + fun tearDown() { + store.clear() + Dispatchers.resetMain() + } + + @Test + fun successfulLoginExposesUserAndLogoutClearsSession() = runTest(dispatcher) { + advanceUntilIdle() + var callbackUser: User? = null + viewModel.login("seu@email.com", "lensclick") { callbackUser = it } + advanceUntilIdle() + + assertNotNull(callbackUser) + assertEquals(callbackUser, viewModel.currentUser.value) + viewModel.logout() + assertNull(viewModel.currentUser.value) + } + + @Test + fun failedLoginCannotLeaveAPreviousUserAuthenticated() = runTest(dispatcher) { + advanceUntilIdle() + viewModel.login("seu@email.com", "lensclick") {} + advanceUntilIdle() + var callbackInvoked = false + viewModel.login("seu@email.com", "incorrect-demo") { + callbackInvoked = true + assertNull(it) + } + advanceUntilIdle() + + assertTrue(callbackInvoked) + assertNull(viewModel.currentUser.value) + } + + @Test + fun registrationReportsSuccessAndDuplicateFailure() = runTest(dispatcher) { + advanceUntilIdle() + var success: Boolean? = null + viewModel.register("Ana", "ana@example.test", "fictional-demo") { success = it } + advanceUntilIdle() + assertEquals(true, success) + assertEquals("ana@example.test", viewModel.currentUser.value?.email) + + viewModel.register("Outra", "ANA@EXAMPLE.TEST", "other-demo") { success = it } + advanceUntilIdle() + assertEquals(false, success) + assertNull(viewModel.currentUser.value) + } + + @Test + fun photographerRegistrationSelectsProfessionalAccount() = runTest(dispatcher) { + advanceUntilIdle() + var success = false + viewModel.registerPhotographer( + "Ana", "ana@example.test", "fictional-demo", "Eventos", "Recife", "R$ 800", "Bio" + ) { success = it } + advanceUntilIdle() + + assertTrue(success) + val user = viewModel.currentUser.value!! + assertEquals("photographer", user.role) + assertEquals(user.id, dao.photographerRows.value.last().userId) + } + + @Test + fun budgetCompletionAndObservedStateFollowStoredRequest() = runTest(dispatcher) { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.budgets.collect {} + } + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.photographers.collect {} + } + advanceUntilIdle() + assertTrue(viewModel.photographers.value.isNotEmpty()) + val originalCount = viewModel.budgets.value.size + var done = false + viewModel.createBudget("Ensaio", "20/09/2026", "Recife", "2 horas", "Retratos", "") { + assertEquals("Ensaio", dao.budgetRows.value.last().title) + done = true + } + assertFalse(done) + advanceUntilIdle() + + assertTrue(done) + assertEquals(originalCount + 1, viewModel.budgets.value.size) + assertEquals("Ensaio", viewModel.budgets.value.last().title) + } +} diff --git a/docs/AMBIENTE_ANDROID.md b/docs/AMBIENTE_ANDROID.md new file mode 100644 index 0000000..8553e91 --- /dev/null +++ b/docs/AMBIENTE_ANDROID.md @@ -0,0 +1,46 @@ +# Ambiente Android e validação reproduzível + +## Diagnóstico local em 19/09/2026 (America/Sao_Paulo) + +- Windows 11; árvore inicial limpa na main `bd3c140`, igual a `origin/main` após fetch. PR #3 integrado; nenhum workflow anterior. +- Java do PATH: Oracle 26.0.2. O Gradle 9.3.1 e um Temurin 21.0.7 já estavam em cache. O projeto exige daemon Java 21 em `gradle/gradle-daemon-jvm.properties`; a versão Kotlin mostrada por `gradlew --version` é a interna do Gradle, não a do app. +- `JAVA_HOME`, `ANDROID_HOME` e `ANDROID_SDK_ROOT` não definidos; `local.properties` ausente. +- `adb`, `sdkmanager` e `emulator` ausentes do PATH; SDK e Android Studio não encontrados nos diretórios convencionais consultados. Não foi feita varredura de discos inteiros; uma instalação em caminho personalizado ainda pode ser indicada pelo mantenedor. +- `gradlew.bat --version --offline`: **passou**. +- `gradlew.bat --offline --no-daemon :app:assembleDebug :app:testDebugUnitTest :app:lintDebug :app:assembleDebugAndroidTest`: **bloqueado**, antes da compilação, por `SDK location not found`. +- Sem SDK/ADB disponível, instalação e testes instrumentados locais não puderam ser executados. Isso não prova ausência de telefone conectado por USB. +- Nenhum SDK, emulador ou componente grande foi instalado localmente; nenhuma configuração global foi alterada. + +## Requisitos exatos + +O `compileSdk` usa API **36 com minorApiLevel 1**: instalar `platforms;android-36.1`, e não somente `android-36`. `targetSdk` continua 36, `minSdk` 24. AGP 9.1.1 usa Build Tools 36.0.0; wrapper 9.3.1 e daemon JDK 21 estão fixados no projeto. Consulte a [compatibilidade oficial do AGP](https://developer.android.com/build/releases/agp-9-1-0-release-notes). + +Após autorização para instalar componentes locais, usar Android Studio compatível com AGP 9.1 ou Android command-line tools. No SDK Manager instalar: + +- Android SDK Platform 36.1; +- Android SDK Build-Tools 36.0.0; +- Android SDK Platform-Tools; +- para emulador: Android Emulator e imagem x86_64 `google_apis` API 24 e/ou 36, com virtualização disponível. + +Apontar `ANDROID_HOME` para o SDK **existente**, ou definir `sdk.dir` em `local.properties` ignorado pelo Git. Configurar Gradle JDK 21 no Android Studio; não usar o Java 26 do PATH como daemon. Aceitar licenças pelo SDK Manager conforme a política do mantenedor. Não copiar arquivos de outra máquina nem versionar caminhos pessoais. + +## Comandos locais + +Neste ambiente, todos os comandos passam pelo RTK. `rtk proxy` preserva os comandos e evita a falha do filtro `rtk git` quando não encontra configuração HOME/Claude. + +```powershell +rtk proxy .\gradlew.bat --version +rtk proxy .\gradlew.bat --no-daemon clean :app:assembleDebug :app:testDebugUnitTest :app:lintDebug :app:assembleDebugAndroidTest +rtk proxy adb devices -l +rtk proxy .\gradlew.bat :app:installDebug :app:connectedDebugAndroidTest +``` + +O APK é gerado em `app/build/outputs/apk/debug/app-debug.apk`. Relatórios: `app/build/reports/tests/testDebugUnitTest/`, `app/build/reports/lint-results-debug.html` e `app/build/reports/androidTests/connected/`. `--offline` só funciona quando todas as dependências já estão em cache. + +## CI + +`.github/workflows/android.yml` executa build debug, testes JVM, lint e compilação dos testes instrumentados; jobs separados instalam o APK e executam instrumentados em emuladores API 24 e 36. Usa runners Linux descartáveis com SDK, JDK 21, actions fixadas por SHA, permissão somente leitura e relatórios retidos por 7 dias. Não usa credenciais de produção ou assinatura release. A instalação de SDK ocorre somente nos runners. + +Erros de build/lint/testes impedem sucesso do job; não há `continue-on-error` nem baseline que esconda erros. Os artefatos são APKs de demonstração e relatórios. A branch main não possuía proteção na consulta inicial: o mantenedor deve configurar estes checks como obrigatórios se desejar imposição pelo GitHub. + +O sucesso do CI não substitui validação em aparelho real, TalkBack, tablet, rede, restauração de processo e revisão de release. Os resultados e links de execução efetivamente obtidos devem ser registrados no handoff. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index a9706b8..6dda1ef 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -14,9 +14,21 @@ - `applicationId` atual: `com.example.lensclickapp`; - 17 telas navegáveis em uma única Activity; - navegação atual por estado (`enum class Screen` + `rememberSaveable`); -- dados locais em Room para usuários, fotógrafos e orçamentos; +- dados locais em Room para usuários, fotógrafos e orçamentos (schema v3, exportado em `app/schemas/`); +- **Room não persiste mais senhas**: a migração 2→3 removeu a coluna `passwordHash`; os hashes SHA-256 de demonstração vivem apenas em memória (`ConcurrentHashMap` no repositório) e desaparecem ao reiniciar o processo; - backend real ainda não conectado; -- conta de demonstração e hash SHA-256 local são somente protótipo e não podem chegar à release pública como autenticação real. +- conta de demonstração (`seu@email.com` / `lensclick`) segue sendo protótipo e não pode chegar à release pública como autenticação real. + +## Fundação entregue (branch `codex/fundacao-android-ci`) + +- **CI Android** em `.github/workflows/android.yml`: job de build/lint/testes JVM (Ubuntu, JDK 21, SDK 36.1) e jobs instrumentados em emuladores API 24 e 36, actions fixadas por SHA, sem segredos. +- **Testes úteis**: `LensClickRepositoryTest` (7 casos), `LensClickViewModelTest` (5 casos) com `FakeLensClickDao` em memória; `LensClickMigrationTest` instrumentado valida que a migração 2→3 descarta `passwordHash` e preserva usuários. +- **Documentos novos**: `docs/AMBIENTE_ANDROID.md` (diagnóstico do ambiente e requisitos exatos) e `docs/MATRIZ_PARIDADE.md` (paridade app × web/API no commit `c3358c2` da plataforma, com classificação O/N/W/A). +- **Preparação arquitetural mínima**: `LensClickViewModel` agora recebe o repositório por injeção via `Factory` (testável em JVM), e o Room exporta schema para versionamento. + +## Limitação do ambiente local + +Esta máquina **não possui Android SDK, ADB ou emulador** (`ANDROID_HOME`/`sdk.dir` ausentes); o build local falha em `SDK location not found` antes de compilar. Nada foi instalado sem autorização. Diagnóstico completo, requisitos exatos (`platforms;android-36.1`, Build Tools 36.0.0, JDK 21 como daemon) e comandos em `docs/AMBIENTE_ANDROID.md`. A validação de build/testes/lint acontece no CI do GitHub. ## Direção aprovada @@ -24,13 +36,15 @@ O aplicativo deve consumir a API da plataforma LensClick e compartilhar usuário ## Próximos passos -1. Validar build, lint, testes e execução em emulador. -2. Criar CI Android. -3. Produzir `docs/MATRIZ_PARIDADE.md` com base nas telas existentes e nos contratos atuais da API. -4. Definir com o repositório da plataforma o contrato móvel de autenticação revogável. -5. Integrar por fatias, começando por autenticação e conta. -6. Projetar 2FA para web e Android após threat model. -7. Concluir domínio, revisão jurídica, validação integral e publicação por faixas. +1. ~~Validar build, lint, testes~~ — validado via CI; validação **local** segue bloqueada pela ausência de SDK (ver `docs/AMBIENTE_ANDROID.md`). +2. ~~Criar CI Android~~ — concluído (`.github/workflows/android.yml`); o mantenedor deve marcar os checks como obrigatórios na proteção da `main`. +3. ~~Produzir `docs/MATRIZ_PARIDADE.md`~~ — primeira versão entregue; atualizar a cada fatia. +4. **Definir com o repositório da plataforma o contrato móvel de autenticação revogável** — Gate C, bloqueio principal antes de qualquer conexão. +5. Decidir com o mantenedor: `applicationId` definitivo, flavors/ambientes, política de papéis (fotógrafo contratando) e destino da UX de orçamentos/propostas que não existe na API. +6. Adotar Navigation Compose com deep links e separar dados locais/remotos (cliente HTTP, serialização) após o contrato. +7. Integrar por fatias, começando por autenticação e conta. +8. Projetar 2FA para web e Android após threat model. +9. Concluir domínio, revisão jurídica, validação integral e publicação por faixas. ## Restrições diff --git a/docs/MATRIZ_PARIDADE.md b/docs/MATRIZ_PARIDADE.md new file mode 100644 index 0000000..ce57d95 --- /dev/null +++ b/docs/MATRIZ_PARIDADE.md @@ -0,0 +1,122 @@ +# Matriz de paridade — web, API e Android + +**Auditoria:** 19 de setembro de 2026, fuso America/Sao_Paulo (20 de setembro em UTC). + +**Estado:** inventário do código e proposta de escopo; não é aprovação de lançamento nem contrato móvel. Todas as classificações abaixo dependem do aceite do mantenedor. Riscos de adiamento estão documentados, mas ainda não foram aceitos. + +## Fontes e limites da evidência + +- Plataforma: `Renato1909/LensClick`, `main` confirmada pela API do GitHub e cópia local no commit [`c3358c2bb7e263095e9dea590f2b7f00add43ef0`](https://github.com/Renato1909/LensClick/commit/c3358c2bb7e263095e9dea590f2b7f00add43ef0), PR #127. Foram consultados roteador, handlers, esquema/migrações, UI web e catálogo/testes de contratos. +- Android: base [`bd3c1408886b0279a1f8867805bd93785939bf21`](https://github.com/Renato1909/LensClickApp/commit/bd3c1408886b0279a1f8867805bd93785939bf21), com 17 telas, repositório local, DAO e entidades Room. A matriz descreve a capacidade funcional dessa base; a entrega de fundação não adiciona integração remota. +- A consulta foi somente leitura. Não foram acessados D1/R2 remotos, contas de usuários ou endpoints da implantação; não houve deploy, migração, envio de e-mail ou escrita na plataforma. Código em `main` não comprova sozinho o comportamento da produção. +- Os testes da plataforma citados foram inspecionados, não executados nesta auditoria. A validação Android da entrega está registrada em `HANDOFF.md`; ainda não há teste de contrato Android/API. +- O roteador e `test-support/api-contracts.mjs` têm **45 operações HTTP**. Documentos anteriores da plataforma mencionam 40 rotas; o catálogo executável é a referência desta matriz. + +Fontes fixadas no commit auditado: + +| Referência | Evidência | +|---|---| +| W1 | [Roteador HTTP](https://github.com/Renato1909/LensClick/blob/c3358c2bb7e263095e9dea590f2b7f00add43ef0/src/router.mjs) e [catálogo de contratos](https://github.com/Renato1909/LensClick/blob/c3358c2bb7e263095e9dea590f2b7f00add43ef0/test-support/api-contracts.mjs) | +| W2 | [Autenticação](https://github.com/Renato1909/LensClick/blob/c3358c2bb7e263095e9dea590f2b7f00add43ef0/src/auth.mjs) e [sessão, DTOs e origem no Worker](https://github.com/Renato1909/LensClick/blob/c3358c2bb7e263095e9dea590f2b7f00add43ef0/src/worker.mjs) | +| W3 | [Segurança e edição da conta](https://github.com/Renato1909/LensClick/blob/c3358c2bb7e263095e9dea590f2b7f00add43ef0/src/account-security.mjs) e [exportação, inbox e mensagens](https://github.com/Renato1909/LensClick/blob/c3358c2bb7e263095e9dea590f2b7f00add43ef0/src/account.mjs) | +| W4 | [Descoberta, perfil profissional e avaliações](https://github.com/Renato1909/LensClick/blob/c3358c2bb7e263095e9dea590f2b7f00add43ef0/src/photographers.mjs) | +| W5 | [Portfólio](https://github.com/Renato1909/LensClick/blob/c3358c2bb7e263095e9dea590f2b7f00add43ef0/src/portfolio.mjs) e [imagens](https://github.com/Renato1909/LensClick/blob/c3358c2bb7e263095e9dea590f2b7f00add43ef0/src/images.mjs) | +| W6 | [Administração](https://github.com/Renato1909/LensClick/blob/c3358c2bb7e263095e9dea590f2b7f00add43ef0/src/admin.mjs), [entradas públicas](https://github.com/Renato1909/LensClick/blob/c3358c2bb7e263095e9dea590f2b7f00add43ef0/src/public-input.mjs) e [esquema D1](https://github.com/Renato1909/LensClick/blob/c3358c2bb7e263095e9dea590f2b7f00add43ef0/db/schema.ts) | +| W7 | [UI web](https://github.com/Renato1909/LensClick/tree/c3358c2bb7e263095e9dea590f2b7f00add43ef0/public), [mensagens web](https://github.com/Renato1909/LensClick/blob/c3358c2bb7e263095e9dea590f2b7f00add43ef0/public/messaging.js) e [catálogos PT/EN](https://github.com/Renato1909/LensClick/tree/c3358c2bb7e263095e9dea590f2b7f00add43ef0/public/locales) | +| W8 | [Testes da plataforma](https://github.com/Renato1909/LensClick/tree/c3358c2bb7e263095e9dea590f2b7f00add43ef0/test) e [plano multicliente](https://github.com/Renato1909/LensClick/blob/c3358c2bb7e263095e9dea590f2b7f00add43ef0/docs/PLANO_ANDROID_VALIDACAO_PUBLICACAO.md) | +| A1 | [Telas e navegação Android](https://github.com/Renato1909/LensClickApp/blob/bd3c1408886b0279a1f8867805bd93785939bf21/app/src/main/java/com/example/lensclickapp/ui/LensClickApp.kt) e [ViewModel](https://github.com/Renato1909/LensClickApp/blob/bd3c1408886b0279a1f8867805bd93785939bf21/app/src/main/java/com/example/lensclickapp/ui/LensClickViewModel.kt) | +| A2 | [Entidades e dados de exemplo](https://github.com/Renato1909/LensClickApp/blob/bd3c1408886b0279a1f8867805bd93785939bf21/app/src/main/java/com/example/lensclickapp/data/LensClickData.kt) e [repositório local](https://github.com/Renato1909/LensClickApp/blob/bd3c1408886b0279a1f8867805bd93785939bf21/app/src/main/java/com/example/lensclickapp/data/LensClickRepository.kt) | + +## Critérios de decisão + +- **Obrigatória no primeiro lançamento (O):** proposta de gate para o primeiro cliente conectado, sem indicar que já esteja pronta. +- **Equivalente nativa (N):** preservar a capacidade com interação Android apropriada; não significa equivalência implementada ou testada. +- **Exclusiva da web com justificativa (W):** manter fora do aplicativo nesta proposta, com justificativa explícita. +- **Adiada com risco documentado (A):** depende de escopo/contrato; não manter simulação apresentada como operação real na release. +- **P0:** bloqueia integração/lançamento seguro. **P1:** próxima fatia funcional. **P2:** após aprovação específica. + +Toda capacidade remota depende do contrato móvel aprovado (Gate C), ambiente controlado e testes de autorização. Isso vale também quando a API web correspondente já existe. + +## Matriz funcional + +| Capacidade | Web e API atuais | Android atual | Classe / prioridade | Dependências e risco | Testes e estado | +|---|---|---|---|---|---| +| Cadastro de cliente e fotógrafo | Cadastro real; `POST /api/auth/register`; papel profissional configurado pela conta. W2–W4 | Dois formulários; usuários e hash SHA-256 locais. A1/A2 | **O / P0** | Contrato móvel, telefone exigido pela API, política de papéis e consentimento; nunca migrar hash/senha demo ao servidor. | Backend: `auth-flow`, `auth-route-contracts`, `professional-profile`. Android remoto ausente; testar duplicidade, erro e papel. | +| Login, sessão, logout e revogação | Cookie de sessão e `GET /api/auth/me`; logout remove sessão. W2 | Validação Room; `currentUser` em memória; logout local. A1/A2 | **O / P0** | Não reutilizar cookie nem fabricar bearer/refresh; revogação, expiração, limite de sessões e Keystore pendentes. | Backend: `auth-flow`, `auth-route-contracts`, `account-management`. Exigir testes móveis de expiração/revogação e isolamento entre contas. | +| Recuperação/troca de senha | Solicitação e confirmação com token; Brevo; troca autenticada. W2/W3 | Recuperação apenas abre diálogo; menu de segurança sem implementação. A1 | **O / P0** | Fluxo controlado, mensagens sem enumeração e deep links aprovados; não prometer envio inexistente. | Backend: `password-reset`, `account-management`. Android pendente; uso único, expiração, indisponibilidade e revogação. | +| Verificação/reenvio de e-mail | `/api/auth/email/verify`, `/resend`; `emailVerified`; login em modo de aviso. W2 | Ausente. | **O / P0** | Preservar política compartilhada; decidir abertura dos links e revalidação após mudança de e-mail. | Backend: `email-verification`. Android pendente; token usado/expirado, reenvio limitado e aviso. | +| Dados da conta, foto e troca de papel | `PUT /api/profile`, `DELETE /api/account/photo`, imagem privada; `perfil.html`. W3/W7 | Conta exibe nome/e-mail; menus abrem aviso. Perfil profissional somente leitura após cadastro. A1 | **O / P1** | Mapear dados reais e reautenticação; foto/role podem despublicar perfil. Alternar modo visual não altera autorização. | Backend: `account-management`, `account-route-contracts`. Android pendente; acesso indevido, conflito, foto e despublicação. | +| Exportação e exclusão da conta | `GET /api/account/export`, `DELETE /api/account`; confirmação + senha; JSON sem credenciais/binários. W3 | Ausentes. | **O / P0** | Contrato de reautenticação, destino protegido do arquivo e limpeza de sessão/cache após exclusão. Não inferir conformidade jurídica. | Backend: `account-export`, `account-management`. Android pendente; acesso cruzado, cancelamento e limpeza local. | +| Descoberta, filtros e paginação | Busca publicada; cidade, UF, especialidade, disponibilidade, preços, raio e ordenação. W4/W7 | Lista Room, busca por nome/especialidade e categorias locais. A1/A2 | **O / P1** | DTOs e paginação; API não oferece busca por nome `q`. Preço de filtro em reais, resposta em centavos. | Backend: `search`, `search-interface`, `portfolio-public-route-contracts`. Android pendente; vazio, filtros, ordem e paginação. | +| Perfil público e leitura do portfólio | Perfil por `slug`, consentimento de publicação, contatos opcionais e imagens reais. W4/W5 | Tela fixa de exemplo; seleção não transporta identidade do fotógrafo; fotos/estatísticas fixas. A1 | **O / P1** | Navegação por `slug` validado; ocultar contatos não consentidos; 404 para perfil removido/despublicado. | Backend: `professional-profile`, `portfolio-public-route-contracts`. Android pendente; identidade selecionada, privacidade e imagens. | +| Perfil profissional e publicação | Rascunho, validação de publicação, especialidades, raio, preço em centavos e disponibilidade. W4 | Campos simplificados no cadastro local; sem publicação/rascunho remoto. A1/A2 | **O / P1** | Respeitar validação e enumerações do backend; mantenedor aprova fotógrafo no primeiro lançamento. | Backend: `professional-profile`. Android pendente; rascunho, consentimento, validação e despublicação. | +| Edição/envio de portfólio | Até 12 imagens; upload, legenda, alt text, categoria, capa, ordem e exclusão. W5 | Galeria ilustrativa; sem entidade/upload de portfólio. A1/A2 | **N / P1** | Se fotógrafo entrar no lançamento: seletor Android, compressão e upload conforme limites; não pedir permissão ampla sem necessidade. | Backend: `portfolio-public-route-contracts`, `portfolio-cover-category`. Android pendente; limites, interrupção, autoria e acessibilidade. | +| Avaliações verificadas | Solicitação de contato, confirmação pelo fotógrafo, nota/comentário e moderação. W4 | Nota e quantidade fixas, sem escrita nem elegibilidade. A1 | **O / P1** | Reusar fluxo existente e reputação autoritativa; remover métricas fictícias da release. | Backend: `reviews`, `reputation-interface`. Android pendente; elegibilidade, duplicata, edição/exclusão e moderação. | +| Conversas e mensagens | Inbox persistente, paginação, não lidas, envio e leitura; HTTP. W3/W4/W7 | Lista derivada dos fotógrafos; conversa fixa; mensagens enviadas só em estado Compose. A1 | **O / P1** | Participantes/ID reais; sem promessa de tempo real, presença online ou anexos; tratamento de conversa encerrada. | Backend: `messaging`, `messaging-interface`. Android pendente; isolamento, paginação, 409/429 e envio repetido. | +| Indicador de não lidas | `unreadCount` no inbox e `PUT .../read`; UI web. W3/W7 | Indicador visual fixo. A1 | **N / P1** | Usar contagem remota e ciclo de atualização aprovado; não inventar push. | Backend: `messaging`. Android pendente; leitura em dois clientes e retorno do background. | +| Push/notificações do sistema e presença | Nenhuma rota de registro de dispositivo, push, WebSocket/SSE ou presença no catálogo/handlers auditados. W1/W6 | Menu de notificações e indicador de online demonstrativos. A1 | **A / P2** | Risco: mensagens não percebidas fora do app. Exige decisão de produto, privacidade e backend; sem credenciais/SDK nesta fase. | Sem contrato ou teste end-to-end correspondente; tornar limitação explícita antes do lançamento. | +| Solicitação de orçamento e propostas | **Sem rota ou tabela** de orçamento/proposta; `review-requests` serve à avaliação. W1/W4/W6 | `Budget` local; formulário e abas/status ilustrativos; sem dono ou fotógrafo associado. A1/A2 | **A / P0** | Lacuna de produto: decidir remover/ocultar na release ou priorizar contrato na plataforma. Risco de prometer contratação inexistente. Nunca mapear para avaliação. | Sem contrato/teste de integração; definir estados, autorização e transições no backend antes do Android. | +| Painel profissional, pedidos e agenda | Sem dashboard de agendamentos/contratações; área web é conta/perfil. W1/W6/W7 | Totais/filtros derivados dos orçamentos locais e textos de demonstração. A1 | **A / P0** | Mesmo gate de orçamentos; risco de exibir eventos/receita/confirmações sem lastro. | Sem contrato. Não classificar simples navegação local como gestão de agenda concluída. | +| Favoritos | Sem rota/tabela de favoritos no catálogo/esquema. W1/W6 | Estado Compose e aba demonstrativa; sem persistência/sincronização de favoritos. A1 | **A / P2** | Risco de perda e inconsistência entre clientes; decidir local por dispositivo ou conta antes de implementar. | Sem contrato. Testar persistência/limpeza somente após decisão de escopo. | +| Onboarding, voltar e deep links | URLs públicas por `slug` e links web de recuperação/verificação/conversa. W2/W7 | `Screen` + estado salvo; sem back stack/deep link Android. A1 | **N / P0** | Navigation Compose; domínio e associação Android App Links dependem do mantenedor; controlar destinos privados/expirados. | Testes Android pendentes de voltar, rotação, recriação, link malformado e sessão encerrada. | +| Internacionalização | Catálogos PT-BR/EN-US e runtime web. W7 | Textos majoritariamente embutidos em Kotlin, PT-BR. A1 | **N / P1** | Extrair recursos Android; equivalência PT/EN e formatos de moeda/data, sem traduzir valores de enums do contrato. | Backend/UI: `i18n-catalogs`, `translation-transition`. Android pendente; recursos completos, locale e expansão de texto. | +| Acessibilidade | Alt text de portfólio obrigatório; controles/feedback com atributos acessíveis. W5/W7 | Compose com rótulos variáveis, ícones em texto e fontes pequenas; sem validação TalkBack. A1 | **N / P0** | Equivalência exige foco, semântica, contraste, fonte ampliada e toque; atributos web não provam acessibilidade Android. | Web: `responsive-portfolio-interface`, `messaging-interface`; Android pendente de dispositivo e inspeção manual. | +| Erro, carregamento, rede instável e cache | Respostas HTTP/JSON, rate limit e cache público da edge. W1–W5 | Dados locais não exercitam rede; estados parciais de envio/login. A1/A2 | **N / P0** | Erros tipados, timeout e repetição controlada; Room futuro como cache por usuário, sem autoridade sobre permissões/regras. | Backend: suites de contratos e `edge-cache`. Android pendente; 401/403/404/409/429/5xx, timeout e isolamento de cache. | +| Escritas offline e sincronização em segundo plano | Sem protocolo de fila, idempotência ou reconciliação móvel identificado. W1 | Gravações locais não sincronizáveis. A2 | **A / P2** | Risco de duplicação, divergência e perda. Não transformar demo local em fila remota; exigir contrato próprio na plataforma. | Sem contrato; não liberar retry automático de mutações até definir semântica. | +| Termos, privacidade, consentimento e suporte | `termos.html`, `privacidade.html`, contato persistido; revisão legal/domínio ainda gates documentados. W6–W8 | Checkbox de Termos sem documento navegável; ajuda/sobre abrem aviso. A1 | **O / P0** | Textos e URLs aprovados, identidade do controlador, canal de suporte e mapa de dados reais; sem inventar decisões jurídicas. | Android pendente: abertura acessível, consentimento e consistência com a coleta real; revisão jurídica externa. | +| Contato institucional e newsletter | `POST /api/contact`, `POST /api/newsletter`; landing. W6/W7 | Sem equivalente completo. | **W / P2** | Formulários institucionais permanecem na web para evitar duplicar aquisição de marketing; app ainda precisa suporte acessível (linha anterior). | Backend: `public-input-route-contracts`. Aceite de escopo pendente; encaminhamento web deve usar URL aprovada. | +| SEO, SSR, sitemap e robots | `/fotografo/:slug`, metadados, sitemap e robots dinâmicos. W2/W7 | Não aplicável à renderização nativa. | **W / P2** | Indexação permanece responsabilidade web; compartilhamento/deep link Android é equivalente separado. | Backend: `indexable-profile`, `sitemap-robots`, `seo-static-metadata`; manter cobertura web. | +| Administração e moderação | Cinco operações `/api/admin/*`, `requireAdmin`, painel `admin.html`. W6/W7 | Ausente. | **W / P1** | Concentrar ferramentas sensíveis na web no primeiro lançamento; servidor continua aplicando moderação a todos os clientes. Mantenedor pode rever escopo. | Backend: `admin-authorization`, `admin-moderation`. App precisa refletir 403 e conteúdo moderado; sem autoatribuir admin. | +| 2FA compartilhado | Sem rotas/implementação; planejado na plataforma. W1/W8 | Ausente. | **A / P0** | Threat model e decisão multicliente pendentes. Risco de tomada de conta só com senha; não inventar política exclusiva Android. Avaliar se será gate do lançamento. | Contrato e testes ainda inexistentes; aceite explícito de risco ou implementação backend antes da release. | + +## Contratos existentes relevantes à integração futura + +São contratos **web atuais**, não endpoints móveis aprovados. Caminhos relativos abaixo não autorizam escolher uma URL de produção para o app. + +### Autenticação e conta + +| Operações existentes | Dados/comportamento observado | Diferença que bloqueia integração direta | +|---|---|---| +| `POST /api/auth/register`; `POST /api/auth/login`; `GET /api/auth/me`; `POST /api/auth/logout` | Cadastro recebe `name`, `email`, `phone`, `password`, `language` opcional; login recebe `email/password`. Resposta `{ user }`; sessão entregue por `Set-Cookie`, não JSON bearer. Cadastro cria cliente; perfil/papel é etapa posterior. | Android não coleta telefone no cadastro; os papéis locais e IDs Room não são identidade remota. | +| `POST /api/auth/password-reset/request`; `POST /api/auth/password-reset/confirm` | Solicitação `{ email, language }`, resposta genérica `202`; confirmação `{ token, password }`; token de uso único com TTL 30 minutos. Recuperação invalida todas as sessões. | Transporte móvel do link/token, expiração e retomada precisam de contrato. Nenhuma senha deve entrar no Room. | +| `POST /api/auth/email/verify`; `POST /api/auth/email/resend` | Verificação `{ token }`, token de uso único com TTL 24 horas; reenvio autenticado, `language` opcional. `user.emailVerified` informa o estado; login não bloqueia e-mail pendente. | Deep links e política após alteração de e-mail precisam de validação multicliente. | +| `PUT /api/profile`; `PUT /api/account/password` | Perfil recebe nome/e-mail/telefone/papel/idade/gênero/foto; mudança de e-mail exige `currentPassword`. Senha recebe `currentPassword/newPassword`; encerra outras sessões e mantém a atual. | Não adotar troca de papel silenciosa para simular modo cliente. Foto é data URL de JPG/PNG/WebP com até 450.000 bytes; preservar referência atual quando aplicável. | +| `GET /api/profile-picture/:id`; `DELETE /api/account/photo` | Foto privada por dono; remoção despublica perfil profissional. | Cache e logs não podem expor imagem privada entre contas. | +| `GET /api/account/export`; `DELETE /api/account` | Exportação JSON inclui dados relacionados, sem credenciais/binários; exclusão exige `currentPassword` e `confirmation` igual a `EXCLUIR` ou `DELETE`. | Destino do arquivo, reautenticação e descarte do cache local precisam de UX segura. | + +`publicUser` contém `id`, `name`, `email`, `phone`, `role`, `age`, `gender`, `emailVerified`, `isAdmin`, `profilePic`; URLs de imagem são relativas. A sessão usa cookie `lensclick_session`, `HttpOnly`, `SameSite=Lax`, `Secure` em HTTPS, TTL de sete dias e até cinco sessões por usuário. As escritas exigem `Origin` igual à origem do pedido. Não existe bearer, renovação móvel ou registro de dispositivos no catálogo auditado. **Não forjar Origin nem copiar cookies para contornar esse gate.** W1–W3. + +### Descoberta, perfil e portfólio + +- `GET /api/photographers`: parâmetros `city`, `state`, `specialty`, `availability`, `minPrice`, `maxPrice`, `minRadius`, `sort`, `page`, `pageSize`; padrão de página 12, máximo 50. Ordenação: `recommended`, `recent`, `price_asc`, `price_desc`, `radius_desc`, `rating_desc`. Responde `{ photographers, pagination, sort }`. `minPrice/maxPrice` usam reais; `startingPriceCents` no DTO usa centavos. **Não há filtro por nome/termo livre** no handler atual. +- `GET /api/photographers/:slug`, `GET /api/photographers/:slug/photo`, `GET /api/photographers/:slug/portfolio/:id`: perfil, foto e imagens públicas condicionados à publicação. Detalhe retorna `{ profile }` com portfólio. Slug deve seguir o formato validado pelo roteador; não é ID sequencial local. +- `GET` e `PUT /api/account/professional-profile`: rascunho/publicação do fotógrafo. Campos incluem `bio`, `specialties`, `city`, `state`, `serviceRadiusKm`, `startingPriceCents`, `availability`, contatos, `showEmail`, `showPhone`, `isPublished`. Publicação exige foto, bio suficiente, especialidade, localização e contato público; autorização e critérios pertencem ao servidor. +- `GET`, `POST` e `PUT /api/account/portfolio`; `GET /api/account/portfolio/:id/image`; `DELETE /api/account/portfolio/:id`: upload usa `multipart/form-data` com `image`, `altText`, `caption`, `category`; JPG/PNG/WebP até **4.000.000 bytes**, máximo **12 itens**. Atualização recebe `items` com metadados/ordem e no máximo uma capa. DTO contém `id`, `caption`, `altText`, `position`, `isCover`, `category`, `imageUrl`. + +Fontes: W1/W2/W4/W5. Valores monetários, enumerações e IDs precisam de mapeadores próprios; não serializar diretamente entidades Room de demonstração. + +### Avaliações e mensagens + +- `GET /api/photographers/:slug/reviews` lê reputação. `POST /api/photographers/:slug/review-requests` recebe `{ message }` e cria solicitação de **contato para avaliação**, apenas para `client`. `GET /api/account/review-requests` lista as solicitações do papel atual; `PUT /api/account/review-requests/:id` recebe `status: confirmed|rejected`, autorizado ao fotógrafo. `POST /api/account/review-requests/:id/review` publica `{ rating, comment }` somente após confirmação. `PUT`/`DELETE /api/account/reviews/:id` edita/remove avaliação autorizada. Nota inteira de 1 a 5; comentário de pelo menos 10 caracteres. Nada disso representa proposta comercial ou reserva. +- `POST /api/photographers/:slug/conversations` inicia/reutiliza conversa com `{ message }`, autorizado ao cliente. `GET /api/account/conversations` retorna `{ conversations, pagination }`, com `unreadCount` e contraparte; página padrão 20, máximo 50. `GET /api/account/conversations/:id/messages` usa cursor `before` e `pageSize` padrão 50, máximo 100; retorna `{ messages, pagination: { hasMore, nextBefore } }`. `POST` nesse caminho envia `{ message }` com até 2.000 caracteres. `PUT /api/account/conversations/:id/read` marca mensagens recebidas como lidas. +- Mensagens exigem participação na conversa; conversa encerrada retorna `409` ao tentar enviar. UI web carrega/atualiza por chamadas HTTP acionadas pelo fluxo; não foi encontrado transporte de tempo real/push/presença no catálogo ou no script auditado. + +Fontes: W1/W3/W4/W7. O Android deve decidir com o backend como um fotógrafo também poderá contratar; mudar apenas o menu não satisfaz `role === 'client'`. + +### Operações que permanecem na plataforma + +`GET /api/admin/overview`, `GET /api/admin/reviews`, `PUT /api/admin/reviews/:id`, `GET /api/admin/conversations`, `PUT /api/admin/conversations/:id` exigem admin. `POST /api/contact` e `POST /api/newsletter` atendem entradas institucionais. `GET /api/health` verifica Worker/D1, sem equivaler a teste de todas as integrações. W1/W6. + +## Riscos e decisões antes da próxima fatia + +1. **Contrato móvel ausente:** aprovar no repositório da plataforma login, expiração, renovação/revogação, reautenticação, limite de sessões, política de 2FA e armazenamento protegido; definir ambiente de teste. Não existe endpoint móvel a ser “apenas conectado”. +2. **Escopo comercial divergente:** Android mostra orçamento/proposta/agenda que o backend não oferece. Mantenedor decide exclusão do primeiro lançamento ou implementação centralizada na plataforma. Não criar regra ou banco paralelo no app. +3. **Papéis divergentes:** Android alterna modo mantendo `photographer`, enquanto criação de conversa/solicitação de avaliação exige `client`; escolher política compartilhada antes de reproduzir a UX. +4. **Privacidade e dados demonstrativos:** esconder/remover dados, credenciais e estatísticas fictícias da release; nenhum usuário real deve ser autenticado por Room. Cache remoto deve ser particionado e limpo conforme sessão/conta. +5. **Compatibilidade ainda implícita:** as rotas não têm prefixo de versão móvel e erros usam `{ error: texto }`; pactuar códigos estáveis, versionamento, campos opcionais, limites, paginação e repetição de mutações. O `X-Request-ID` pode ajudar suporte sem registrar tokens ou dados pessoais. +6. **Estado de e-mail exige revisão backend:** no handler auditado, a troca de e-mail remove outras sessões/tokens de recuperação, mas não redefine explicitamente `email_verified_at`. Levar esse caso ao contrato e aos testes da plataforma antes de interpretar `emailVerified` no Android; nenhuma correção de produção foi feita nesta fase. +7. **Decisões institucionais pendentes:** pacote definitivo, domínios/links, identidade/controlador, revisão de termos/privacidade, idiomas e escopo do fotógrafo/2FA/admin. A matriz não substitui esses aceites. + +## Critério para atualizar a matriz + +Em cada fatia, registrar novo SHA da plataforma, operações aprovadas, testes de contrato em ambiente controlado, evidência Android (incluindo falhas e dispositivo quando aplicável), estado das linhas e aceite do mantenedor. “API existente” ou “tela navegável” não devem ser promovidos a “paridade concluída” sem validar o fluxo e suas permissões nos dois clientes. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3d513ec..81454fb 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,16 +8,17 @@ O aplicativo possui 17 telas em Jetpack Compose e persiste dados locais com Room ## Fase 1 — fundação Android -- [ ] Validar build limpo, lint, testes e instalação em emulador. -- [ ] Substituir testes de exemplo por uma linha de base útil. -- [ ] Criar CI para build, lint e testes. -- [ ] Definir pacote definitivo, flavors/ambientes e versionamento. +- [x] Substituir testes de exemplo por uma linha de base útil (testes JVM de repositório/ViewModel + instrumentado de migração Room 2→3). +- [x] Criar CI para build, lint e testes (`.github/workflows/android.yml`: build debug, testes JVM, lint, instrumentados em emuladores API 24/36). +- [x] Remover credencial do Room (migração 2→3 descarta `passwordHash`; hashes de demonstração vivem apenas em memória no processo). +- [ ] Validar build limpo, lint, testes e instalação em emulador **local** (bloqueado: esta máquina não tem Android SDK; ver `docs/AMBIENTE_ANDROID.md`). A validação ocorre no CI. +- [ ] Definir pacote definitivo, flavors/ambientes e versionamento (depende do mantenedor). - [ ] Separar melhor navegação, domínio, dados locais e dados remotos. - [ ] Adotar Navigation Compose e preparar deep links. ## Fase 2 — contrato e paridade -- [ ] Criar `docs/MATRIZ_PARIDADE.md` comparando app, web e API. +- [x] Criar `docs/MATRIZ_PARIDADE.md` comparando app, web e API (primeira versão, baseada no commit `c3358c2` da plataforma). - [ ] Aprovar o escopo do primeiro lançamento. - [ ] Definir autenticação móvel revogável com o backend. - [ ] Definir cliente HTTP, serialização, erros e compatibilidade da API. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8460aae..7f29cf3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ coreSplashscreen = "1.0.1" room = "2.8.5" ksp = "2.3.12" lifecycleViewmodel = "2.10.0" +coroutinesTest = "1.10.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -34,6 +35,7 @@ androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodel" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleViewmodel" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }