diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 7c3f53f..b46ee22 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -63,7 +63,7 @@ jobs: - name: Android lint working-directory: android-app - run: gradle --no-daemon :app:lintDebug + run: gradle --no-daemon :app:lintDebug :app:lintRelease - name: Print complete lint failures if: failure() @@ -130,6 +130,14 @@ jobs: if-no-files-found: error retention-days: 30 + - name: Upload unsigned release candidate + uses: actions/upload-artifact@v4 + with: + name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-UNSIGNED + path: android-app/app/build/outputs/apk/release/app-release-unsigned.apk + if-no-files-found: error + retention-days: 30 + - name: Upload Android diagnostics if: always() uses: actions/upload-artifact@v4 diff --git a/android-app/FINAL_RELEASE_CHECKLIST.md b/android-app/FINAL_RELEASE_CHECKLIST.md index 7f21011..e94611a 100644 --- a/android-app/FINAL_RELEASE_CHECKLIST.md +++ b/android-app/FINAL_RELEASE_CHECKLIST.md @@ -1,4 +1,4 @@ -# Checklist final Android 0.19.0 +# Checklist final Android 0.19.1 La candidata sólo se promueve si el mismo SHA cumple: diff --git a/android-app/RELEASE.md b/android-app/RELEASE.md index feef42a..0470ba1 100644 --- a/android-app/RELEASE.md +++ b/android-app/RELEASE.md @@ -1,10 +1,10 @@ -# DH Inspección Android · release final de campo 0.19.0 +# DH Inspección Android · release final de campo 0.19.1 ## Candidata vigente - Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**. -- `versionName`: **0.19.0**. -- `versionCode`: **28**. +- `versionName`: **0.19.1**. +- `versionCode`: **29**. - Application ID release: `com.korexlabs.dhinspeccion`. - Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`. - API: `https://dhv2.korexlabs.com/api/v3/`. @@ -13,7 +13,7 @@ La variante debug es independiente de la app productiva y puede instalarse para ## Procedimiento operativo validado -La APK 0.19.0 fija como recorrido principal de campo: +La APK 0.19.1 fija como recorrido principal de campo: 1. **Iniciar Inspección**. Al iniciarla se habilitan Actas, Hallazgos e Inventario de campo. 2. **Crear o abrir un Acta**. Puede haber varias Actas dentro de una misma Inspección, pero sólo una puede permanecer en elaboración al mismo tiempo. @@ -95,3 +95,16 @@ Antes de distribuir una APK productiva: - realizar actualización sobre al menos una tablet con la versión productiva anterior cuando corresponda; - ejecutar smoke funcional: ingreso, iniciar Inspección, crear Acta, seleccionar/crear Instalación o Subinstalación, GPS/foto, Hallazgo, cerrar Acta → Pendiente de firma, firma/manifestación, cierre de Acta y cierre de Inspección; - registrar el SHA Git y SHA-256 de la APK distribuida. + +## Revisión 0.19.1 + +- Renovación de sesión coordinada entre los cuatro clientes API; no cierra sesión por pérdida de red, HTTP 429/500 o cancelación. +- Una respuesta tardía no puede restaurar una sesión cerrada ni usar la de otro inspector. +- Guardia de escrituras contra doble toque y conteo correcto de operaciones pendientes. +- Búsquedas descartan respuestas antiguas. Cambio de inspección limpia el Acta anterior. +- Mensajes visibles de permisos/GPS en evidencia y respeto de barras de sistema/teclado. +- Diez nuevas pruebas de comportamiento sobre sesión y concurrencia. + +### Alcance real + +Esta candidata requiere conexión. No implementa trabajo offline ni cola persistente de sincronización; un fallo de red no equivale a guardado. Tras un timeout de escritura debe verificarse el registro antes de repetir. No se presenta el artefacto debug como una release productiva. La firma histórica y el smoke en tablet siguen siendo requisitos para distribuir la release final. diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 7db8c79..9457ceb 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.korexlabs.dhinspeccion" minSdk = 26 targetSdk = 36 - versionCode = 28 - versionName = "0.19.0" + versionCode = 29 + versionName = "0.19.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt index 4d08601..0e18317 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt @@ -24,11 +24,14 @@ import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest import com.korexlabs.dhinspeccion.data.StoredSession import com.korexlabs.dhinspeccion.data.VisitDetail import com.korexlabs.dhinspeccion.data.VisitSummary +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import java.io.File import java.time.Instant class MainViewModel(application: Application) : AndroidViewModel(application) { + private val operations = OperationTracker() + private var inventorySearchGeneration = 0 private val repository = DhRepository(application) private val findingsRepository = FieldFindingsRepository(application) private val actsRepository = MobileActsRepository(application) @@ -84,7 +87,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { error = "Ingresá usuario y contraseña." return } - launchBusy { + launchBusy(mutation = true) { session = repository.login(identifier, password) notice = "Sesión iniciada." loadVisitsInternal() @@ -113,7 +116,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } fun openVisit(id: String) = launchBusy { - visit = repository.visit(id) + val loadedVisit = repository.visit(id) + clearActState() + visit = loadedVisit inventory = emptyList() inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id fieldTypes = emptyList() @@ -123,6 +128,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } fun closeVisitView() { + if (busy) return + inventorySearchGeneration += 1 visit = null inventory = emptyList() inventoryParentId = null @@ -135,7 +142,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun startVisit() { val id = visit?.id ?: return - launchBusy { + launchBusy(mutation = true) { visit = repository.startVisit(id) inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id notice = "Inspección iniciada." @@ -172,7 +179,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { error = "Ya existe un Acta en elaboración. Cerrala o cancelala antes de crear la siguiente." return } - launchBusy { + launchBusy(mutation = true) { val created = actsRepository.create( currentVisit.id, asset?.id, @@ -194,8 +201,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun searchInventory(search: String, parentId: String? = null) { val currentVisit = visit ?: return + val generation = ++inventorySearchGeneration launchBusy { - inventory = repository.fieldInventory(currentVisit.id, search, parentId).data + val result = repository.fieldInventory(currentVisit.id, search, parentId).data + if (generation == inventorySearchGeneration && visit?.id == currentVisit.id) inventory = result } } @@ -203,6 +212,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val currentVisit = visit ?: return launchBusy { val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id + inventorySearchGeneration += 1 inventoryParentId = effectiveParentId fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data @@ -216,7 +226,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun selectExisting(item: FieldInventoryItem) { val visitId = visit?.id ?: return - launchBusy { + launchBusy(mutation = true) { selectedFieldAsset = repository.selectFieldAsset(visitId, item.id) notice = "Inventario agregado a la Inspección." reloadCurrentInventory(visitId) @@ -252,7 +262,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { error = "Elegí una clasificación técnica o la opción Otro / no catalogado." return } - launchBusy { + launchBusy(mutation = true) { val request = CreateFieldInventoryRequest( typeId = type.id, parentId = parentId, @@ -283,7 +293,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { error = "Explicá brevemente por qué se trata de un duplicado." return } - launchBusy { + launchBusy(mutation = true) { val result = repository.mergeFieldAsset( visitId = visitId, assetId = source.id, @@ -310,7 +320,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { ) { val visitId = visit?.id ?: return val asset = selectedFieldAsset?.asset ?: return - launchBusy { + launchBusy(mutation = true) { val response = repository.uploadFieldPhoto( visitId = visitId, assetId = asset.id, @@ -345,7 +355,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { error = "Creá o seleccioná el Acta en elaboración antes de registrar Hallazgos." return } - launchBusy { + launchBusy(mutation = true) { selectedAct = actsRepository.ensureAsset(draft.id, assetId) loadActsInternal(visitId, selectDraft = false) loadFindingOptionsInternal(visitId, assetId, draft.id) @@ -379,7 +389,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { error = "La gravedad debe estar entre 1 y 10." return } - launchBusy { + launchBusy(mutation = true) { selectedAct = actsRepository.ensureAsset(actId, assetId) val response = findingsRepository.create( visitId, @@ -410,7 +420,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { title: String? = null, description: String? = null, ) { - launchBusy { + launchBusy(mutation = true) { findingsRepository.uploadObservationPhoto( findingId = findingId, file = file, @@ -442,7 +452,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { error = "Completá nombre, documento y cargo del responsable de la empresa." return } - launchBusy { + launchBusy(mutation = true) { actClosure = actsRepository.setResponsible( actId, MobileResponsibleRequest( @@ -465,7 +475,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { error = "Indicá un motivo de ausencia de al menos 10 caracteres." return } - launchBusy { + launchBusy(mutation = true) { actClosure = actsRepository.setResponsible( actId, MobileResponsibleRequest( @@ -479,7 +489,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun prepareSelectedAct() { val actId = selectedAct?.id ?: return - launchBusy { + launchBusy(mutation = true) { actClosure = actsRepository.lock(actId) refreshSelectedActInternal(actId) notice = "Acta cerrada y pendiente de firma. Su contenido quedó inmutable; las firmas y manifestaciones pueden completarse a continuación." @@ -497,7 +507,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { accuracyM: Double?, ) { val actId = selectedAct?.id ?: return - launchBusy { + launchBusy(mutation = true) { actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM) notice = "Firma del inspector incorporada al Acta." } @@ -512,7 +522,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { statement: String?, ) { val actId = selectedAct?.id ?: return - launchBusy { + launchBusy(mutation = true) { actClosure = actsRepository.signCompany( actId, png, latitude, longitude, accuracyM, manifestation, statement, ) @@ -534,7 +544,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { error = "Indicá un motivo de al menos 10 caracteres." return } - launchBusy { + launchBusy(mutation = true) { actClosure = actsRepository.companyOutcome(actId, status, reason) notice = "Negativa a firmar asentada." } @@ -543,7 +553,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun closeSelectedAct() { val currentVisit = visit ?: return val actId = selectedAct?.id ?: return - launchBusy { + launchBusy(mutation = true) { actClosure = actsRepository.seal(actId) refreshSelectedActInternal(actId) loadActsInternal(currentVisit.id, selectDraft = false) @@ -554,7 +564,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun closeInspection() { val visitId = visit?.id ?: return - launchBusy { + launchBusy(mutation = true) { visit = actsRepository.closeVisit(visitId) loadVisitsInternal() notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina." @@ -637,17 +647,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fieldFindingEvidence = emptyMap() } - private fun launchBusy(block: suspend () -> Unit) { + private fun launchBusy(mutation: Boolean = false, block: suspend () -> Unit) { + if (!operations.begin(mutation)) return + busy = operations.busy viewModelScope.launch { - busy = true error = null try { block() + } catch (cancelled: CancellationException) { + throw cancelled } catch (throwable: Throwable) { error = DhRepository.humanError(throwable) if (repository.currentSession() == null) session = null } finally { - busy = false + operations.end() + busy = operations.busy } } } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/OperationTracker.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/OperationTracker.kt new file mode 100644 index 0000000..7e47a6d --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/OperationTracker.kt @@ -0,0 +1,16 @@ +package com.korexlabs.dhinspeccion + +/** Main-thread gate: reads may overlap; a second write is never queued by a double tap. */ +internal class OperationTracker { + private var pending = 0 + val busy: Boolean get() = pending > 0 + fun begin(mutation: Boolean): Boolean { + if (mutation && busy) return false + pending += 1 + return true + } + fun end() { + check(pending > 0) + pending -= 1 + } +} diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt index 72fe9cd..03861ee 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt @@ -7,8 +7,6 @@ import android.util.Base64 import com.korexlabs.dhinspeccion.BuildConfig import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient @@ -451,7 +449,6 @@ class SecureSessionStore(context: Context) { class DhRepository(context: Context) { private val store = SecureSessionStore(context.applicationContext) - private val refreshMutex = Mutex() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val api: DhApi = Retrofit.Builder() .baseUrl(BuildConfig.API_BASE_URL) @@ -548,23 +545,19 @@ class DhRepository(context: Context) { } catch (error: HttpException) { if (error.code() != 401) throw error } - session = refresh(session.refreshToken) + session = refresh(session) return block(session) } - private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock { - val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada") - if (latest.refreshToken != previousRefreshToken) return@withLock latest - try { - store.save(api.refresh(RefreshRequest(previousRefreshToken))) - } catch (error: Throwable) { - store.clear() - throw error - } - } + private suspend fun refresh(previous: StoredSession): StoredSession = + MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh) + companion object { fun humanError(error: Throwable): String { + if (error is java.io.IOException) { + return "No se pudo conectar. Revisá la señal. Si estabas guardando, comprobá el registro antes de reintentar." + } if (error is HttpException) { val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull() val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull() diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt index 6690364..1c33037 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt @@ -4,8 +4,6 @@ import android.content.Context import com.korexlabs.dhinspeccion.BuildConfig import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient @@ -176,7 +174,6 @@ private interface FieldFindingsApi { */ class FieldFindingsRepository(context: Context) { private val store = SecureSessionStore(context.applicationContext) - private val refreshMutex = Mutex() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val api: FieldFindingsApi = Retrofit.Builder() .baseUrl(BuildConfig.API_BASE_URL) @@ -241,18 +238,11 @@ class FieldFindingsRepository(context: Context) { } catch (error: HttpException) { if (error.code() != 401) throw error } - session = refresh(session.refreshToken) + session = refresh(session) return block(session) } - private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock { - val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada") - if (latest.refreshToken != previousRefreshToken) return@withLock latest - try { - store.save(api.refresh(RefreshRequest(previousRefreshToken))) - } catch (error: Throwable) { - store.clear() - throw error - } - } + private suspend fun refresh(previous: StoredSession): StoredSession = + MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh) + } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt index 68d6f85..e109716 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt @@ -4,8 +4,6 @@ import android.content.Context import com.korexlabs.dhinspeccion.BuildConfig import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient @@ -310,7 +308,6 @@ private interface MobileActsApi { class MobileActsRepository(context: Context) { private val store = SecureSessionStore(context.applicationContext) - private val refreshMutex = Mutex() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val api: MobileActsApi = Retrofit.Builder() .baseUrl(BuildConfig.API_BASE_URL) @@ -454,18 +451,11 @@ class MobileActsRepository(context: Context) { } catch (error: HttpException) { if (error.code() != 401) throw error } - session = refresh(session.refreshToken) + session = refresh(session) return block(session) } - private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock { - val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada") - if (latest.refreshToken != previousRefreshToken) return@withLock latest - try { - store.save(api.refresh(RefreshRequest(previousRefreshToken))) - } catch (error: Throwable) { - store.clear() - throw error - } - } + private suspend fun refresh(previous: StoredSession): StoredSession = + MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh) + } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt index c0a5e64..7b1e56e 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt @@ -4,8 +4,6 @@ import android.content.Context import com.korexlabs.dhinspeccion.BuildConfig import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import okhttp3.OkHttpClient import retrofit2.HttpException import retrofit2.Retrofit @@ -55,7 +53,6 @@ private interface MobileInspectionOpenApi { class MobileInspectionOpenRepository(context: Context) { private val store = SecureSessionStore(context.applicationContext) - private val refreshMutex = Mutex() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val api: MobileInspectionOpenApi = Retrofit.Builder() .baseUrl(BuildConfig.API_BASE_URL) @@ -86,18 +83,11 @@ class MobileInspectionOpenRepository(context: Context) { } catch (error: HttpException) { if (error.code() != 401) throw error } - session = refresh(session.refreshToken) + session = refresh(session) return block(session) } - private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock { - val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada") - if (latest.refreshToken != previousRefreshToken) return@withLock latest - try { - store.save(api.refresh(RefreshRequest(previousRefreshToken))) - } catch (error: Throwable) { - store.clear() - throw error - } - } + private suspend fun refresh(previous: StoredSession): StoredSession = + MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh) + } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileSessionCoordinator.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileSessionCoordinator.kt new file mode 100644 index 0000000..5634267 --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileSessionCoordinator.kt @@ -0,0 +1,32 @@ +package com.korexlabs.dhinspeccion.data + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import retrofit2.HttpException + +/** One refresh lock for every API client sharing the encrypted session. */ +internal object MobileSessionCoordinator { + private val mutex = Mutex() + + suspend fun refresh( + previous: StoredSession, + load: () -> StoredSession?, + save: (MobileSessionResponse) -> StoredSession, + clear: () -> Unit, + request: suspend (RefreshRequest) -> MobileSessionResponse, + ): StoredSession = mutex.withLock { + val latest = load() ?: throw IllegalStateException("Sesión no iniciada") + check(latest.userId == previous.userId) { "La sesión cambió. Volvé a abrir la operación." } + if (latest.refreshToken != previous.refreshToken) return@withLock latest + val response = try { + request(RefreshRequest(previous.refreshToken)) + } catch (error: HttpException) { + // Network failures and server outages must not discard the saved login. + if (error.code() == 401 && load()?.refreshToken == previous.refreshToken) clear() + throw error + } + // A logout or another login during the request must never be undone. + check(load()?.refreshToken == previous.refreshToken) { "La sesión cambió. Volvé a ingresar." } + save(response) + } +} diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt index a0cf697..c002404 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt @@ -75,6 +75,7 @@ fun FieldFindingScreen(model: MainViewModel) { var severityText by rememberSaveable(asset.id) { mutableStateOf("") } var correctionDueOn by rememberSaveable(asset.id) { mutableStateOf("") } + var localError by rememberSaveable { mutableStateOf(null) } var requestedFindingId by remember { mutableStateOf(null) } var pendingPhotoFile by remember { mutableStateOf(null) } var pendingPhotoGeo by remember { mutableStateOf(null) } @@ -110,6 +111,7 @@ fun FieldFindingScreen(model: MainViewModel) { pendingPhotoFindingId = findingId takePicture.launch(uri) } + .onFailure { localError = it.message ?: "No se pudo capturar la ubicación GPS." } } } @@ -122,6 +124,7 @@ fun FieldFindingScreen(model: MainViewModel) { val findingId = requestedFindingId requestedFindingId = null if (camera && location && findingId != null) beginPhoto(findingId) + else localError = "Permití cámara y ubicación para adjuntar una foto con GPS." } fun requestPhoto(findingId: String) { @@ -192,6 +195,7 @@ fun FieldFindingScreen(model: MainViewModel) { } } + localError?.let { Text(it, color = MaterialTheme.colorScheme.error) } model.error?.let { Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) { Text(it, Modifier.padding(12.dp)) diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt index 96b8090..4a7c980 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt @@ -11,6 +11,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff @@ -80,7 +84,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) { } DhTheme { - Surface(Modifier.fillMaxSize()) { + Surface(Modifier.fillMaxSize().safeDrawingPadding().imePadding()) { when { model.session == null -> EnhancedLoginScreen(model) { passwordLoginInFlight = true @@ -106,10 +110,10 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) { @Composable private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) { var identifier by rememberSaveable { mutableStateOf("") } - var password by rememberSaveable { mutableStateOf("") } + var password by remember { mutableStateOf("") } var passwordVisible by rememberSaveable { mutableStateOf(false) } - Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), contentAlignment = Alignment.Center) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) { Image( painter = painterResource(R.drawable.ic_mendoza_launcher_exact), @@ -203,7 +207,7 @@ private fun BiometricUnlockScreen( LaunchedEffect(Unit) { authenticate() } - Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), contentAlignment = Alignment.Center) { Column( Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), diff --git a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/MobileSessionCoordinatorTest.kt b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/MobileSessionCoordinatorTest.kt new file mode 100644 index 0000000..5e3b16b --- /dev/null +++ b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/MobileSessionCoordinatorTest.kt @@ -0,0 +1,105 @@ +package com.korexlabs.dhinspeccion + +import com.korexlabs.dhinspeccion.data.* +import java.io.IOException +import kotlinx.coroutines.* +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.* +import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response + +class MobileSessionCoordinatorTest { + private fun old() = StoredSession("u1", "inspector", "Inspector", "a1", "r1") + private fun response() = MobileSessionResponse(MobileUser("u1", "inspector"), "a2", "r2", 900) + private fun http(code: Int) = HttpException(Response.error(code, "error".toResponseBody())) + + private class Store(var value: StoredSession?) { + var clears = 0 + fun load() = value + fun clear() { clears++; value = null } + fun save(r: MobileSessionResponse): StoredSession = + StoredSession(r.user.id, r.user.username, r.user.username, r.accessToken, r.refreshToken).also { value = it } + } + + @Test fun concurrentClientsRotateTheTokenOnlyOnce() = runBlocking { + val previous = old(); val store = Store(previous); var calls = 0 + val results = coroutineScope { + (1..4).map { + async { + MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { + calls++; delay(20); response() + } + } + }.awaitAll() + } + assertEquals(1, calls) + assertTrue(results.all { it.refreshToken == "r2" }) + assertEquals(0, store.clears) + } + + @Test fun networkAndServerFailuresKeepSessionForRetry() = runBlocking { + for (failure in listOf(IOException("offline"), http(500), http(429))) { + val previous = old(); val store = Store(previous) + try { + MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw failure } + fail("Expected request failure") + } catch (caught: Exception) { assertSame(failure, caught) } + assertEquals(previous, store.value) + assertEquals(0, store.clears) + } + } + + @Test fun invalidRefreshClearsOnlyTheExpiredSession() = runBlocking { + val previous = old(); val store = Store(previous) + try { + MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw http(401) } + fail("Expected unauthorized") + } catch (caught: HttpException) { assertEquals(401, caught.code()) } + assertNull(store.value) + assertEquals(1, store.clears) + } + + @Test fun cancelledRefreshKeepsSession() = runBlocking { + val previous = old(); val store = Store(previous) + try { + MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw CancellationException("cancel") } + fail("Expected cancellation") + } catch (_: CancellationException) { } + assertEquals(previous, store.value) + } + + @Test fun logoutDuringRefreshCannotBeUndone() = runBlocking { + val previous = old(); val store = Store(previous) + try { + MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { + store.clear(); response() + } + fail("Must reject a refresh after logout") + } catch (_: IllegalStateException) { } + assertNull(store.value) + } + + @Test fun staleRequestCannotUseAnotherInspectorsSession() = runBlocking { + val previous = old(); val store = Store(previous.copy(userId = "u2", refreshToken = "other")) + try { + MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { + fail("No refresh allowed for another inspector"); response() + } + fail("Must reject account switch") + } catch (_: IllegalStateException) { } + assertEquals("u2", store.value?.userId) + } + + @Test fun lateUnauthorizedDoesNotClearANewLogin() = runBlocking { + val previous = old(); val store = Store(previous) + try { + MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { + store.value = previous.copy(refreshToken = "new-login") + throw http(401) + } + } catch (_: HttpException) { } + assertEquals("new-login", store.value?.refreshToken) + assertEquals(0, store.clears) + } +} diff --git a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/OperationTrackerTest.kt b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/OperationTrackerTest.kt new file mode 100644 index 0000000..2543f2b --- /dev/null +++ b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/OperationTrackerTest.kt @@ -0,0 +1,28 @@ +package com.korexlabs.dhinspeccion + +import org.junit.Assert.* +import org.junit.Test + +class OperationTrackerTest { + @Test fun doubleTapCannotQueueASecondWrite() { + val tracker = OperationTracker() + assertTrue(tracker.begin(mutation = true)) + assertFalse(tracker.begin(mutation = true)) + tracker.end() + assertFalse(tracker.busy) + assertTrue(tracker.begin(mutation = true)) + } + @Test fun BusyRemainsUntilEveryReadCompletes() { + val tracker = OperationTracker() + assertTrue(tracker.begin(false)); assertTrue(tracker.begin(false)) + tracker.end(); assertTrue(tracker.busy) + assertFalse(tracker.begin(true)) + tracker.end(); assertFalse(tracker.busy) + } + @Test fun readDuringWriteDoesNotReleaseItsGuardEarly() { + val tracker = OperationTracker() + assertTrue(tracker.begin(true)); assertTrue(tracker.begin(false)) + tracker.end(); assertFalse(tracker.begin(true)) + tracker.end(); assertTrue(tracker.begin(true)) + } +} diff --git a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt index 9ee72cf..ccb74d4 100644 --- a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt +++ b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt @@ -8,8 +8,8 @@ class ReleaseMetadataTest { @Test fun debugBuildKeepsSeparateApplicationIdentity() { assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID) - assertEquals(28, BuildConfig.VERSION_CODE) - assertEquals("0.19.0-debug", BuildConfig.VERSION_NAME) + assertEquals(29, BuildConfig.VERSION_CODE) + assertEquals("0.19.1-debug", BuildConfig.VERSION_NAME) } @Test