Compare commits

...
Author SHA1 Message Date
admin be15f31073 feat(auth): expose audited mobile password change 2026-09-08 17:18:56 -03:00
admin 33584d545f refactor(android): share auth and clean uploaded signatures 2026-09-08 17:16:55 -03:00
admin 516445fea6 refactor(android): share auth and clean uploaded finding photos 2026-09-08 17:16:03 -03:00
admin 480cb6c691 fix(android): remove duplicate checklist model 2026-09-08 17:15:25 -03:00
admin 373cf8cccb refactor(android): share encrypted mobile session coordinator 2026-09-08 17:14:32 -03:00
admin 18238d0c3d test(android): prove rotating refresh is serialized 2026-09-08 17:13:07 -03:00
admin ad72f3505c feat(android): centralize mobile session refresh and password change 2026-09-08 17:11:54 -03:00
admin a976d40d45 feat(android): serialize rotating mobile refresh tokens 2026-09-08 17:11:30 -03:00
admin b387929023 fix(android): make signature GPS permission guard lint-visible 2026-09-08 17:06:10 -03:00
admin abffe12cc9 fix(android): make finding GPS permission guard lint-visible 2026-09-08 17:05:14 -03:00
admin 8a1940f002 fix(android): make field GPS permission guard lint-visible 2026-09-08 17:01:56 -03:00
admin c3fecc3e7a fix(android): make legacy GPS permission guard lint-visible 2026-09-08 17:00:43 -03:00
admin b5fe7835af fix(android): declare camera hardware optional 2026-09-08 16:59:21 -03:00
admin 6e063e701f test(android): accept documented absence as terminal outcome 2026-09-08 16:55:35 -03:00
admin 0f5d425fd1 fix(android): mirror documented absent company outcome 2026-09-08 16:55:12 -03:00
admin 8a6c182452 ci(android): print complete lint failures 2026-09-08 16:52:45 -03:00
admin 7b2a510018 ci(android): guard mobile security and app identity 2026-09-08 16:44:54 -03:00
admin 1d713fd771 test(android): pin RC identity and production API target 2026-09-08 16:44:30 -03:00
admin d3a404ec82 chore(android): ignore local builds and signing material 2026-09-08 16:43:47 -03:00
admin 6a34c86cfb docs(android): define F5 RC release contract 2026-09-08 16:39:59 -03:00
admin d406e40315 ci(android): promote F5 Android to release barrier 2026-09-08 16:39:44 -03:00
admin 5f5f376acb build(android): cut 0.13.0-rc1 and enforce lint 2026-09-08 16:39:08 -03:00
admin 26a48d3b4f test(android): cover F5 field workflow guards 2026-09-08 16:38:38 -03:00
admin 1d25207c76 feat(android): add testable F5 workflow rules 2026-09-08 16:38:19 -03:00
admin 1dc3282055 F4 hotfix · metadata de health
Corrige la fase publicada por el health de la API para reflejar F4 y agrega una prueba de regresión sin cambiar la versión funcional.
2026-09-08 16:18:58 -03:00
19 changed files with 649 additions and 208 deletions
+90 -18
View File
@@ -1,31 +1,32 @@
name: Android APK name: Android CI / RC
# F3.2: genera la APK debug verificable antes de promover la integración multi-Acta.
on: on:
push: push:
branches: branches: [main]
- 'feature/f2-2*'
- 'feature/f2-3*'
- 'feature/f2-4*'
- 'feature/f3-1*'
- 'feature/f3-2*'
paths: paths:
- 'android-app/**' - 'android-app/**'
- 'api-v3/src/**'
- '.github/workflows/android.yml' - '.github/workflows/android.yml'
pull_request: pull_request:
branches: [main]
paths: paths:
- 'android-app/**' - 'android-app/**'
- 'api-v3/src/auth/**' - 'api-v3/src/**'
- '.github/workflows/android.yml' - '.github/workflows/android.yml'
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: read contents: read
concurrency:
group: dhv2-android-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
build-debug-apk: android:
name: Android · lint, tests, debug APK, release compile
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 35
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -47,18 +48,89 @@ jobs:
with: with:
gradle-version: '8.13' gradle-version: '8.13'
- name: Assemble debug - name: Validate mobile security and identity contract
working-directory: android-app run: |
run: gradle --no-daemon :app:assembleDebug set -Eeuo pipefail
grep -Fq 'applicationId = "com.korexlabs.dhinspeccion"' android-app/app/build.gradle.kts
grep -Fq 'applicationIdSuffix = ".debug"' android-app/app/build.gradle.kts
grep -Fq 'buildConfigField("String", "API_BASE_URL", "\"https://dhv2.korexlabs.com/api/v3/\"")' android-app/app/build.gradle.kts
grep -Fq 'android:allowBackup="false"' android-app/app/src/main/AndroidManifest.xml
grep -Fq 'android:usesCleartextTraffic="false"' android-app/app/src/main/AndroidManifest.xml
- name: Unit tests - name: Android lint
working-directory: android-app
run: gradle --no-daemon :app:lintDebug
- name: Print complete lint failures
if: failure()
run: |
report="android-app/app/build/intermediates/lint_intermediate_text_report/debug/lintReportDebug/lint-results-debug.txt"
if [ -f "$report" ]; then
echo '========== ANDROID LINT =========='
cat "$report"
fi
- name: Android unit tests
working-directory: android-app working-directory: android-app
run: gradle --no-daemon :app:testDebugUnitTest run: gradle --no-daemon :app:testDebugUnitTest
- name: Upload APK - name: Require real unit-test results
run: |
set -Eeuo pipefail
result="$(find android-app/app/build/test-results/testDebugUnitTest -type f -name 'TEST-*.xml' -print -quit)"
test -n "$result"
grep -Eq '<testsuite[^>]+tests="[1-9][0-9]*"' "$result"
- name: Assemble debug APK
working-directory: android-app
run: gradle --no-daemon :app:assembleDebug
- name: Compile unsigned release variant
working-directory: android-app
run: gradle --no-daemon :app:assembleRelease
- name: Package RC artifact and checksum
id: package
run: |
set -Eeuo pipefail
version="$(sed -n 's/^[[:space:]]*versionName = "\([^"]*\)"/\1/p' android-app/app/build.gradle.kts | head -n1)"
code="$(sed -n 's/^[[:space:]]*versionCode = \([0-9][0-9]*\)/\1/p' android-app/app/build.gradle.kts | head -n1)"
test -n "$version"
test -n "$code"
short_sha="${GITHUB_SHA::12}"
mkdir -p android-app/dist
apk="android-app/dist/DH-Inspeccion-${version}-vc${code}-${short_sha}-debug.apk"
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
sha256sum "$apk" > "${apk}.sha256"
{
echo "version=$version"
echo "versionCode=$code"
echo "commit=$GITHUB_SHA"
echo "artifact=$(basename "$apk")"
echo "applicationId=com.korexlabs.dhinspeccion.debug"
echo "apiBaseUrl=https://dhv2.korexlabs.com/api/v3/"
echo "channel=DEBUG_RC"
} > android-app/dist/release-metadata.txt
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "version_code=$code" >> "$GITHUB_OUTPUT"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
- name: Upload debug RC
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: DH-Inspeccion-F3.2-0.12.0-debug name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-debug
path: android-app/app/build/outputs/apk/debug/app-debug.apk path: android-app/dist/*
if-no-files-found: error if-no-files-found: error
retention-days: 30
- name: Upload Android diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: android-diagnostics-${{ github.sha }}
path: |
android-app/app/build/reports/lint-results-debug.html
android-app/app/build/reports/tests/testDebugUnitTest/**
android-app/app/build/test-results/testDebugUnitTest/**
if-no-files-found: ignore
retention-days: 14 retention-days: 14
+7
View File
@@ -11,6 +11,11 @@
**/.vite/ **/.vite/
**/coverage/ **/coverage/
# Android / Gradle local state
android-app/.gradle/
android-app/**/build/
android-app/local.properties
# Backups / exports # Backups / exports
*.zip *.zip
*.tar.gz *.tar.gz
@@ -38,6 +43,8 @@ Thumbs.db
*.key *.key
*.p12 *.p12
*.pfx *.pfx
*.jks
*.keystore
id_rsa id_rsa
id_ed25519 id_ed25519
*_github *_github
+39
View File
@@ -0,0 +1,39 @@
# DH Inspección Android · contrato de release F5
## Estado de esta etapa
La primera candidata de F5 es `0.13.0-rc1` (`versionCode 20`). Su objetivo es convertir el cliente Android en una barrera verificable del repositorio antes del piloto de campo.
## Barrera obligatoria
Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar el workflow `Android CI / RC`:
1. Android lint.
2. Unit tests Android reales (el job falla si no existe ningún XML de tests con al menos una prueba).
3. `assembleDebug`.
4. `assembleRelease` para verificar que la variante productiva compile.
5. Empaquetado del APK debug RC con SHA-256 y metadata de commit/versionado.
El APK debug usa `com.korexlabs.dhinspeccion.debug`; es deliberadamente independiente de la app productiva y sirve para QA/piloto técnico sin sobrescribir una instalación release histórica.
## Firma release
La clave histórica de firma NO se versiona ni se reemplaza. La variante release se compila en CI, pero el APK de distribución final deberá firmarse con la clave histórica y comprobarse antes de instalarlo como actualización de `com.korexlabs.dhinspeccion`.
No se debe generar una clave nueva para "resolver" una falta de acceso: eso rompería la continuidad de actualización de las tablets que ya tengan una versión firmada con la clave anterior.
## Evidencia mínima de cada candidata
El artefacto de CI contiene:
- APK debug RC;
- archivo `.sha256`;
- `release-metadata.txt` con versión, versionCode, commit, applicationId, API base y canal.
Para un release de campo definitivo se agregará además:
- APK release firmada;
- huella/certificado de firma comprobado contra la versión histórica;
- prueba de actualización sobre una tablet con versión anterior;
- smoke funcional contra producción;
- registro del SHA Git exacto que originó la APK.
+7 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion" applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 19 versionCode = 20
versionName = "0.12.0" versionName = "0.13.0-rc1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -46,6 +46,11 @@ android {
} }
kotlinOptions.jvmTarget = "17" kotlinOptions.jvmTarget = "17"
lint {
abortOnError = true
checkReleaseBuilds = true
}
packaging.resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" packaging.resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
} }
@@ -6,6 +6,10 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<application <application
android:allowBackup="false" android:allowBackup="false"
android:label="@string/app_name" android:label="@string/app_name"
@@ -7,8 +7,6 @@ import android.util.Base64
import com.korexlabs.dhinspeccion.BuildConfig import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -69,6 +67,7 @@ data class StoredSession(
val displayName: String, val displayName: String,
val accessToken: String, val accessToken: String,
val refreshToken: String, val refreshToken: String,
val mustChangePassword: Boolean = false,
) )
// ---------- Inspections ---------- // ---------- Inspections ----------
@@ -292,9 +291,6 @@ interface DhApi {
@POST("auth/mobile/login") @POST("auth/mobile/login")
suspend fun login(@Body request: LoginRequest): MobileSessionResponse suspend fun login(@Body request: LoginRequest): MobileSessionResponse
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
@POST("auth/mobile/logout") @POST("auth/mobile/logout")
suspend fun logout(@Header("Authorization") authorization: String): Map<String, Any?> suspend fun logout(@Header("Authorization") authorization: String): Map<String, Any?>
@@ -393,6 +389,7 @@ class SecureSessionStore(context: Context) {
displayName = json.optString("displayName", json.getString("username")), displayName = json.optString("displayName", json.getString("username")),
accessToken = json.getString("accessToken"), accessToken = json.getString("accessToken"),
refreshToken = json.getString("refreshToken"), refreshToken = json.getString("refreshToken"),
mustChangePassword = json.optBoolean("mustChangePassword", false),
) )
}.getOrElse { }.getOrElse {
clear() clear()
@@ -403,19 +400,26 @@ class SecureSessionStore(context: Context) {
fun save(response: MobileSessionResponse): StoredSession { fun save(response: MobileSessionResponse): StoredSession {
val displayName = listOfNotNull(response.user.firstName, response.user.lastName) val displayName = listOfNotNull(response.user.firstName, response.user.lastName)
.joinToString(" ").trim().ifBlank { response.user.username } .joinToString(" ").trim().ifBlank { response.user.username }
val stored = StoredSession( return save(
userId = response.user.id, StoredSession(
username = response.user.username, userId = response.user.id,
displayName = displayName, username = response.user.username,
accessToken = response.accessToken, displayName = displayName,
refreshToken = response.refreshToken, accessToken = response.accessToken,
refreshToken = response.refreshToken,
mustChangePassword = response.user.mustChangePassword,
),
) )
}
fun save(stored: StoredSession): StoredSession {
val json = JSONObject() val json = JSONObject()
.put("userId", stored.userId) .put("userId", stored.userId)
.put("username", stored.username) .put("username", stored.username)
.put("displayName", stored.displayName) .put("displayName", stored.displayName)
.put("accessToken", stored.accessToken) .put("accessToken", stored.accessToken)
.put("refreshToken", stored.refreshToken) .put("refreshToken", stored.refreshToken)
.put("mustChangePassword", stored.mustChangePassword)
.toString() .toString()
val cipher = Cipher.getInstance("AES/GCM/NoPadding") val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key()) cipher.init(Cipher.ENCRYPT_MODE, key())
@@ -449,8 +453,7 @@ class SecureSessionStore(context: Context) {
} }
class DhRepository(context: Context) { class DhRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val sessions = MobileSessionCoordinator.get(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: DhApi = Retrofit.Builder() private val api: DhApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -459,42 +462,45 @@ class DhRepository(context: Context) {
.build() .build()
.create(DhApi::class.java) .create(DhApi::class.java)
fun currentSession(): StoredSession? = store.load() fun currentSession(): StoredSession? = sessions.currentSession()
suspend fun login(identifier: String, password: String): StoredSession = suspend fun login(identifier: String, password: String): StoredSession =
store.save(api.login(LoginRequest(identifier.trim(), password))) sessions.save(api.login(LoginRequest(identifier.trim(), password)))
suspend fun changePassword(currentPassword: String, newPassword: String): StoredSession =
sessions.changePassword(currentPassword, newPassword)
suspend fun logout() { suspend fun logout() {
val session = store.load() val session = sessions.currentSession()
if (session != null) runCatching { api.logout("Bearer ${session.accessToken}") } if (session != null) runCatching { api.logout("Bearer ${session.accessToken}") }
store.clear() sessions.clear()
} }
suspend fun visits(): VisitListResponse = authorized { session -> suspend fun visits(): VisitListResponse = sessions.authorized { session ->
api.visits("Bearer ${session.accessToken}", session.userId) api.visits("Bearer ${session.accessToken}", session.userId)
} }
suspend fun visit(id: String): VisitDetail = authorized { session -> suspend fun visit(id: String): VisitDetail = sessions.authorized { session ->
api.visit("Bearer ${session.accessToken}", id) api.visit("Bearer ${session.accessToken}", id)
} }
suspend fun startVisit(id: String): VisitDetail = authorized { session -> suspend fun startVisit(id: String): VisitDetail = sessions.authorized { session ->
api.startVisit("Bearer ${session.accessToken}", id) api.startVisit("Bearer ${session.accessToken}", id)
} }
suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null) = authorized { session -> suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null) = sessions.authorized { session ->
api.fieldInventory("Bearer ${session.accessToken}", visitId, search?.takeIf { it.isNotBlank() }, parentId) api.fieldInventory("Bearer ${session.accessToken}", visitId, search?.takeIf { it.isNotBlank() }, parentId)
} }
suspend fun fieldTypes(visitId: String, parentId: String?) = authorized { session -> suspend fun fieldTypes(visitId: String, parentId: String?) = sessions.authorized { session ->
api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId) api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId)
} }
suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session -> suspend fun selectFieldAsset(visitId: String, assetId: String) = sessions.authorized { session ->
api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId) api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId)
} }
suspend fun createFieldAsset(visitId: String, request: CreateFieldInventoryRequest) = authorized { session -> suspend fun createFieldAsset(visitId: String, request: CreateFieldInventoryRequest) = sessions.authorized { session ->
api.createFieldAsset("Bearer ${session.accessToken}", visitId, request) api.createFieldAsset("Bearer ${session.accessToken}", visitId, request)
} }
@@ -503,7 +509,7 @@ class DhRepository(context: Context) {
assetId: String, assetId: String,
canonicalAssetId: String, canonicalAssetId: String,
reason: String, reason: String,
): FieldInventoryMergeResult = authorized { session -> ): FieldInventoryMergeResult = sessions.authorized { session ->
api.mergeFieldAsset( api.mergeFieldAsset(
"Bearer ${session.accessToken}", "Bearer ${session.accessToken}",
visitId, visitId,
@@ -520,46 +526,28 @@ class DhRepository(context: Context) {
longitude: Double, longitude: Double,
accuracyM: Double?, accuracyM: Double?,
capturedAt: String = Instant.now().toString(), capturedAt: String = Instant.now().toString(),
): FieldPhotoResponse = authorized { session -> ): FieldPhotoResponse {
val text = "text/plain".toMediaType() val response = sessions.authorized { session ->
val body = file.asRequestBody("image/jpeg".toMediaType()) val text = "text/plain".toMediaType()
val part = MultipartBody.Part.createFormData("file", file.name, body) val body = file.asRequestBody("image/jpeg".toMediaType())
api.uploadFieldPhoto( val part = MultipartBody.Part.createFormData("file", file.name, body)
authorization = "Bearer ${session.accessToken}", api.uploadFieldPhoto(
visitId = visitId, authorization = "Bearer ${session.accessToken}",
assetId = assetId, visitId = visitId,
file = part, assetId = assetId,
latitude = latitude.toString().toRequestBody(text), file = part,
longitude = longitude.toString().toRequestBody(text), latitude = latitude.toString().toRequestBody(text),
accuracy = accuracyM?.toString()?.toRequestBody(text), longitude = longitude.toString().toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text), accuracy = accuracyM?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text), capturedAt = capturedAt.toRequestBody(text),
exifLatitude = latitude.toString().toRequestBody(text), deviceLabel = "DH Android".toRequestBody(text),
exifLongitude = longitude.toString().toRequestBody(text), exifLatitude = latitude.toString().toRequestBody(text),
exifCapturedAt = capturedAt.toRequestBody(text), exifLongitude = longitude.toString().toRequestBody(text),
) exifCapturedAt = capturedAt.toRequestBody(text),
} )
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
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
} }
file.delete()
return response
} }
companion object { companion object {
@@ -4,15 +4,12 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.HttpException
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body import retrofit2.http.Body
@@ -165,18 +162,14 @@ private interface FieldFindingsApi {
@Part("accuracyM") accuracyM: RequestBody?, @Part("accuracyM") accuracyM: RequestBody?,
@Part("deviceLabel") deviceLabel: RequestBody, @Part("deviceLabel") deviceLabel: RequestBody,
): FieldFindingEvidence ): FieldFindingEvidence
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
} }
/** /**
* Cliente de campo para Hallazgos y sus evidencias append-only. * Cliente de campo para Hallazgos y sus evidencias append-only.
* F3.2 exige que la APK identifique explícitamente el Acta activa. * F5 comparte autenticación con Inventario y Actas para serializar refresh tokens rotativos.
*/ */
class FieldFindingsRepository(context: Context) { class FieldFindingsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val sessions = MobileSessionCoordinator.get(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: FieldFindingsApi = Retrofit.Builder() private val api: FieldFindingsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -186,7 +179,7 @@ class FieldFindingsRepository(context: Context) {
.create(FieldFindingsApi::class.java) .create(FieldFindingsApi::class.java)
suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse = suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse =
authorized { session -> sessions.authorized { session ->
api.options("Bearer ${session.accessToken}", visitId, assetId, actId) api.options("Bearer ${session.accessToken}", visitId, assetId, actId)
} }
@@ -194,11 +187,11 @@ class FieldFindingsRepository(context: Context) {
visitId: String, visitId: String,
assetId: String, assetId: String,
request: CreateFieldFindingRequest, request: CreateFieldFindingRequest,
): FieldFindingCreateResponse = authorized { session -> ): FieldFindingCreateResponse = sessions.authorized { session ->
api.create("Bearer ${session.accessToken}", visitId, assetId, request) api.create("Bearer ${session.accessToken}", visitId, assetId, request)
} }
suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse = authorized { session -> suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse = sessions.authorized { session ->
api.evidence("Bearer ${session.accessToken}", findingId) api.evidence("Bearer ${session.accessToken}", findingId)
} }
@@ -211,48 +204,30 @@ class FieldFindingsRepository(context: Context) {
title: String? = null, title: String? = null,
description: String? = null, description: String? = null,
capturedAt: String = Instant.now().toString(), capturedAt: String = Instant.now().toString(),
): FieldFindingEvidence = authorized { session -> ): FieldFindingEvidence {
val text = "text/plain".toMediaType() val response = sessions.authorized { session ->
val part = MultipartBody.Part.createFormData( val text = "text/plain".toMediaType()
"file", val part = MultipartBody.Part.createFormData(
file.name, "file",
file.asRequestBody("image/jpeg".toMediaType()), file.name,
) file.asRequestBody("image/jpeg".toMediaType()),
api.uploadEvidence( )
authorization = "Bearer ${session.accessToken}", api.uploadEvidence(
findingId = findingId, authorization = "Bearer ${session.accessToken}",
file = part, findingId = findingId,
kind = "PHOTO".toRequestBody(text), file = part,
purpose = "OBSERVATION".toRequestBody(text), kind = "PHOTO".toRequestBody(text),
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text), purpose = "OBSERVATION".toRequestBody(text),
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text), title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text), description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
latitude = latitude.toString().toRequestBody(text), capturedAt = capturedAt.toRequestBody(text),
longitude = longitude.toString().toRequestBody(text), latitude = latitude.toString().toRequestBody(text),
accuracyM = accuracyM?.toString()?.toRequestBody(text), longitude = longitude.toString().toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text), accuracyM = accuracyM?.toString()?.toRequestBody(text),
) deviceLabel = "DH Android".toRequestBody(text),
} )
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
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
} }
file.delete()
return response
} }
} }
@@ -4,15 +4,12 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.HttpException
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body import retrofit2.http.Body
@@ -303,14 +300,10 @@ private interface MobileActsApi {
@Path("visitId") visitId: String, @Path("visitId") visitId: String,
@Body request: MobileCloseVisitRequest, @Body request: MobileCloseVisitRequest,
): VisitDetail ): VisitDetail
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
} }
class MobileActsRepository(context: Context) { class MobileActsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val sessions = MobileSessionCoordinator.get(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileActsApi = Retrofit.Builder() private val api: MobileActsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -319,11 +312,11 @@ class MobileActsRepository(context: Context) {
.build() .build()
.create(MobileActsApi::class.java) .create(MobileActsApi::class.java)
suspend fun list(visitId: String): MobileActListResponse = authorized { session -> suspend fun list(visitId: String): MobileActListResponse = sessions.authorized { session ->
api.listActs("Bearer ${session.accessToken}", visitId) api.listActs("Bearer ${session.accessToken}", visitId)
} }
suspend fun get(actId: String): MobileActDetail = authorized { session -> suspend fun get(actId: String): MobileActDetail = sessions.authorized { session ->
api.act("Bearer ${session.accessToken}", actId) api.act("Bearer ${session.accessToken}", actId)
} }
@@ -332,7 +325,7 @@ class MobileActsRepository(context: Context) {
assetId: String, assetId: String,
visitCode: String, visitCode: String,
urgency: String = "NON_URGENT", urgency: String = "NON_URGENT",
): MobileActDetail = authorized { session -> ): MobileActDetail = sessions.authorized { session ->
api.createAct( api.createAct(
"Bearer ${session.accessToken}", "Bearer ${session.accessToken}",
visitId, visitId,
@@ -350,20 +343,20 @@ class MobileActsRepository(context: Context) {
val detail = get(actId) val detail = get(actId)
if (detail.assets.any { it.id == assetId }) return detail if (detail.assets.any { it.id == assetId }) return detail
val ids = (detail.assets.map { it.id } + assetId).distinct() val ids = (detail.assets.map { it.id } + assetId).distinct()
return authorized { session -> return sessions.authorized { session ->
api.updateAct("Bearer ${session.accessToken}", actId, UpdateMobileActRequest(ids)) api.updateAct("Bearer ${session.accessToken}", actId, UpdateMobileActRequest(ids))
} }
} }
suspend fun closure(actId: String): MobileActClosure = authorized { session -> suspend fun closure(actId: String): MobileActClosure = sessions.authorized { session ->
api.closure("Bearer ${session.accessToken}", actId) api.closure("Bearer ${session.accessToken}", actId)
} }
suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure = authorized { session -> suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure = sessions.authorized { session ->
api.responsible("Bearer ${session.accessToken}", actId, request) api.responsible("Bearer ${session.accessToken}", actId, request)
} }
suspend fun lock(actId: String): MobileActClosure = authorized { session -> suspend fun lock(actId: String): MobileActClosure = sessions.authorized { session ->
api.lock("Bearer ${session.accessToken}", actId) api.lock("Bearer ${session.accessToken}", actId)
} }
@@ -394,7 +387,7 @@ class MobileActsRepository(context: Context) {
statement = statement, statement = statement,
) )
suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure = authorized { session -> suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure = sessions.authorized { session ->
api.companyOutcome( api.companyOutcome(
"Bearer ${session.accessToken}", "Bearer ${session.accessToken}",
actId, actId,
@@ -402,11 +395,11 @@ class MobileActsRepository(context: Context) {
) )
} }
suspend fun seal(actId: String): MobileActClosure = authorized { session -> suspend fun seal(actId: String): MobileActClosure = sessions.authorized { session ->
api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest()) api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest())
} }
suspend fun closeVisit(visitId: String): VisitDetail = authorized { session -> suspend fun closeVisit(visitId: String): VisitDetail = sessions.authorized { session ->
api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest()) api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest())
} }
@@ -419,53 +412,35 @@ class MobileActsRepository(context: Context) {
company: Boolean, company: Boolean,
manifestation: String? = null, manifestation: String? = null,
statement: String? = null, statement: String? = null,
): MobileActClosure = authorized { session -> ): MobileActClosure {
val text = "text/plain".toMediaType() val response = sessions.authorized { session ->
val file = MultipartBody.Part.createFormData( val text = "text/plain".toMediaType()
"file", val filePart = MultipartBody.Part.createFormData(
png.name, "file",
png.asRequestBody("image/png".toMediaType()), png.name,
) png.asRequestBody("image/png".toMediaType()),
val consent = "true".toRequestBody(text)
val signedAt = Instant.now().toString().toRequestBody(text)
val device = "DH Android".toRequestBody(text)
val lat = latitude?.toString()?.toRequestBody(text)
val lon = longitude?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.toString()?.toRequestBody(text)
if (company) {
api.signCompany(
"Bearer ${session.accessToken}", actId, file, consent, signedAt,
lat, lon, accuracy, device,
manifestation?.toRequestBody(text),
statement?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
)
} else {
api.signInspector(
"Bearer ${session.accessToken}", actId, file, consent, signedAt,
lat, lon, accuracy, device,
) )
val consent = "true".toRequestBody(text)
val signedAt = Instant.now().toString().toRequestBody(text)
val device = "DH Android".toRequestBody(text)
val lat = latitude?.toString()?.toRequestBody(text)
val lon = longitude?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.toString()?.toRequestBody(text)
if (company) {
api.signCompany(
"Bearer ${session.accessToken}", actId, filePart, consent, signedAt,
lat, lon, accuracy, device,
manifestation?.toRequestBody(text),
statement?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
)
} else {
api.signInspector(
"Bearer ${session.accessToken}", actId, filePart, consent, signedAt,
lat, lon, accuracy, device,
)
}
} }
} png.delete()
return response
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
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
}
} }
} }
@@ -0,0 +1,98 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.POST
data class ChangeMobilePasswordRequest(
val currentPassword: String,
val newPassword: String,
)
data class ChangeMobilePasswordResponse(
val status: String,
val mustChangePassword: Boolean,
)
private interface MobileSessionApi {
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
@POST("auth/change-password")
suspend fun changePassword(
@Header("Authorization") authorization: String,
@Body request: ChangeMobilePasswordRequest,
): ChangeMobilePasswordResponse
}
/**
* Único coordinador de sesión del proceso Android.
*
* Todos los repositorios comparten el mismo gate de refresh para que un token rotativo no sea
* consumido en paralelo por Inventario, Hallazgos y Actas.
*/
class MobileSessionCoordinator private constructor(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val gate = SessionRefreshGate()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileSessionApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(MobileSessionApi::class.java)
fun currentSession(): StoredSession? = store.load()
fun save(response: MobileSessionResponse): StoredSession = store.save(response)
fun clear() = store.clear()
suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = gate.refreshIfNeeded(
previousRefreshToken = session.refreshToken,
load = store::load,
save = store::save,
clear = store::clear,
refresh = { token -> api.refresh(RefreshRequest(token)) },
)
return block(session)
}
suspend fun changePassword(currentPassword: String, newPassword: String): StoredSession {
val sessionUsed = authorized { session ->
api.changePassword(
authorization = "Bearer ${session.accessToken}",
request = ChangeMobilePasswordRequest(currentPassword, newPassword),
)
session
}
val latest = store.load() ?: sessionUsed
return store.save(latest.copy(mustChangePassword = false))
}
companion object {
@Volatile
private var instance: MobileSessionCoordinator? = null
fun get(context: Context): MobileSessionCoordinator =
instance ?: synchronized(this) {
instance ?: MobileSessionCoordinator(context.applicationContext).also { instance = it }
}
}
}
@@ -0,0 +1,32 @@
package com.korexlabs.dhinspeccion.data
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Serializa la rotación del refresh token a nivel de proceso.
*
* El backend rota el token en cada refresh. Si dos repositorios reciben 401 al mismo tiempo,
* sólo el primero debe consumir el token anterior; el segundo reutiliza la sesión ya renovada.
*/
class SessionRefreshGate {
private val mutex = Mutex()
suspend fun refreshIfNeeded(
previousRefreshToken: String,
load: () -> StoredSession?,
save: (MobileSessionResponse) -> StoredSession,
clear: () -> Unit,
refresh: suspend (String) -> MobileSessionResponse,
): StoredSession = mutex.withLock {
val latest = load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
save(refresh(previousRefreshToken))
} catch (error: Throwable) {
clear()
throw error
}
}
}
@@ -0,0 +1,54 @@
package com.korexlabs.dhinspeccion.domain
/**
* UX-side mirrors of server invariants used to prevent invalid field actions before a request is sent.
* The API remains authoritative; these rules must never be used to weaken backend validation.
*/
object MobileWorkflowRules {
private val validUrgencies = setOf("URGENT", "NON_URGENT")
private val terminalCompanyOutcomes = setOf("SIGNED", "REFUSED", "ABSENT")
fun canStartInspection(status: String): Boolean = status == "PLANNED"
fun hasDraftAct(statuses: Iterable<String>): Boolean = statuses.any { it == "DRAFT" }
fun canCreateAct(
visitStatus: String,
actStatuses: Iterable<String>,
hasSelectedInventory: Boolean,
urgency: String,
): Boolean =
visitStatus == "IN_PROGRESS" &&
!hasDraftAct(actStatuses) &&
hasSelectedInventory &&
urgency in validUrgencies
fun canCreateFieldInventory(visitStatus: String): Boolean = visitStatus == "IN_PROGRESS"
fun canRegisterFinding(
visitStatus: String,
selectedActStatus: String?,
inventoryReadyForFinding: Boolean,
): Boolean =
visitStatus == "IN_PROGRESS" &&
selectedActStatus == "DRAFT" &&
inventoryReadyForFinding
fun canLockAct(actStatus: String?, responsibleDefined: Boolean): Boolean =
actStatus == "DRAFT" && responsibleDefined
fun canSealAct(
actStatus: String?,
inspectorSigned: Boolean,
companyOutcomeStatus: String?,
): Boolean =
actStatus == "LOCKED" &&
inspectorSigned &&
companyOutcomeStatus in terminalCompanyOutcomes
fun canCloseInspection(visitStatus: String, actStatuses: Iterable<String>): Boolean {
if (visitStatus != "IN_PROGRESS") return false
val active = actStatuses.filter { it != "CANCELLED" }
return active.isNotEmpty() && active.all { it == "SEALED" }
}
}
@@ -541,7 +541,9 @@ private fun hasLocation(context: Context): Boolean =
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellableCoroutine { continuation -> private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellableCoroutine { continuation ->
if (!hasLocation(context)) { val fineGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
val coarseGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (!fineGranted && !coarseGranted) {
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación.")) continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
return@suspendCancellableCoroutine return@suspendCancellableCoroutine
} }
@@ -721,7 +721,9 @@ private fun f3HasLocation(context: Context): Boolean =
f3HasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) f3HasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentF3Geo(context: Context): F3GeoSnapshot = suspendCancellableCoroutine { continuation -> private suspend fun currentF3Geo(context: Context): F3GeoSnapshot = suspendCancellableCoroutine { continuation ->
if (!f3HasLocation(context)) { val fineGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
val coarseGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (!fineGranted && !coarseGranted) {
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación.")) continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
return@suspendCancellableCoroutine return@suspendCancellableCoroutine
} }
@@ -383,7 +383,9 @@ private fun findingHasLocation(context: Context): Boolean =
findingHasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) findingHasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = suspendCancellableCoroutine { continuation -> private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = suspendCancellableCoroutine { continuation ->
if (!findingHasLocation(context)) { val fineGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
val coarseGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (!fineGranted && !coarseGranted) {
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación.")) continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
return@suspendCancellableCoroutine return@suspendCancellableCoroutine
} }
@@ -426,13 +426,15 @@ private fun hasActLocation(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo = suspendCancellableCoroutine { continuation -> private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo = suspendCancellableCoroutine { continuation ->
if (!hasActLocation(context)) { val fineGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
val coarseGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (!fineGranted && !coarseGranted) {
continuation.resumeWithException(SecurityException("Ubicación no autorizada")) continuation.resumeWithException(SecurityException("Ubicación no autorizada"))
return@suspendCancellableCoroutine return@suspendCancellableCoroutine
} }
val source = CancellationTokenSource() val source = CancellationTokenSource()
LocationServices.getFusedLocationProviderClient(context) val client = LocationServices.getFusedLocationProviderClient(context)
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location -> .addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible")) if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
@@ -0,0 +1,20 @@
package com.korexlabs.dhinspeccion
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ReleaseMetadataTest {
@Test
fun debugRcKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(20, BuildConfig.VERSION_CODE)
assertEquals("0.13.0-rc1-debug", BuildConfig.VERSION_NAME)
}
@Test
fun rcTargetsOnlyTheHttpsProductionApi() {
assertEquals("https://dhv2.korexlabs.com/api/v3/", BuildConfig.API_BASE_URL)
assertTrue(BuildConfig.API_BASE_URL.startsWith("https://"))
}
}
@@ -0,0 +1,62 @@
package com.korexlabs.dhinspeccion.data
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
class SessionRefreshGateTest {
@Test
fun simultaneous401sConsumeTheRotatingRefreshTokenOnlyOnce() = runBlocking {
val gate = SessionRefreshGate()
val calls = AtomicInteger(0)
var stored: StoredSession? = StoredSession(
userId = "u-1",
username = "inspector",
displayName = "Inspector",
accessToken = "access-old",
refreshToken = "refresh-old",
)
fun response() = MobileSessionResponse(
user = MobileUser(id = "u-1", username = "inspector"),
accessToken = "access-new",
refreshToken = "refresh-new",
accessExpiresInSeconds = 900,
)
val results = listOf(1, 2).map {
async(Dispatchers.Default) {
gate.refreshIfNeeded(
previousRefreshToken = "refresh-old",
load = { stored },
save = { refreshed ->
StoredSession(
userId = refreshed.user.id,
username = refreshed.user.username,
displayName = refreshed.user.username,
accessToken = refreshed.accessToken,
refreshToken = refreshed.refreshToken,
).also { stored = it }
},
clear = { stored = null },
refresh = {
calls.incrementAndGet()
delay(40)
response()
},
)
}
}.awaitAll()
assertEquals(1, calls.get())
assertEquals(listOf("refresh-new", "refresh-new"), results.map { it.refreshToken })
assertNotNull(stored)
assertEquals("access-new", stored?.accessToken)
}
}
@@ -0,0 +1,90 @@
package com.korexlabs.dhinspeccion.domain
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class MobileWorkflowRulesTest {
@Test
fun plannedInspectionCanStartButRunningOrClosedCannot() {
assertTrue(MobileWorkflowRules.canStartInspection("PLANNED"))
assertFalse(MobileWorkflowRules.canStartInspection("IN_PROGRESS"))
assertFalse(MobileWorkflowRules.canStartInspection("CLOSED"))
}
@Test
fun onlyOneDraftActIsAllowedPerInspection() {
assertFalse(MobileWorkflowRules.hasDraftAct(listOf("SEALED", "LOCKED")))
assertTrue(MobileWorkflowRules.hasDraftAct(listOf("SEALED", "DRAFT", "LOCKED")))
}
@Test
fun creatingAnActRequiresRunningInspectionInventoryValidUrgencyAndNoDraft() {
assertTrue(
MobileWorkflowRules.canCreateAct(
visitStatus = "IN_PROGRESS",
actStatuses = listOf("SEALED", "LOCKED"),
hasSelectedInventory = true,
urgency = "NON_URGENT",
),
)
assertTrue(
MobileWorkflowRules.canCreateAct(
visitStatus = "IN_PROGRESS",
actStatuses = emptyList(),
hasSelectedInventory = true,
urgency = "URGENT",
),
)
assertFalse(MobileWorkflowRules.canCreateAct("PLANNED", emptyList(), true, "URGENT"))
assertFalse(MobileWorkflowRules.canCreateAct("IN_PROGRESS", listOf("DRAFT"), true, "URGENT"))
assertFalse(MobileWorkflowRules.canCreateAct("IN_PROGRESS", emptyList(), false, "URGENT"))
assertFalse(MobileWorkflowRules.canCreateAct("IN_PROGRESS", emptyList(), true, "UNKNOWN"))
}
@Test
fun fieldInventoryCanOnlyBeCreatedWhileInspectionIsRunning() {
assertTrue(MobileWorkflowRules.canCreateFieldInventory("IN_PROGRESS"))
assertFalse(MobileWorkflowRules.canCreateFieldInventory("PLANNED"))
assertFalse(MobileWorkflowRules.canCreateFieldInventory("CLOSED"))
}
@Test
fun findingsRequireRunningInspectionDraftActAndReadyInventory() {
assertTrue(MobileWorkflowRules.canRegisterFinding("IN_PROGRESS", "DRAFT", true))
assertFalse(MobileWorkflowRules.canRegisterFinding("PLANNED", "DRAFT", true))
assertFalse(MobileWorkflowRules.canRegisterFinding("IN_PROGRESS", "LOCKED", true))
assertFalse(MobileWorkflowRules.canRegisterFinding("IN_PROGRESS", "DRAFT", false))
assertFalse(MobileWorkflowRules.canRegisterFinding("IN_PROGRESS", null, true))
}
@Test
fun actCanOnlyLockFromDraftAfterResponsibleWasResolved() {
assertTrue(MobileWorkflowRules.canLockAct("DRAFT", true))
assertFalse(MobileWorkflowRules.canLockAct("DRAFT", false))
assertFalse(MobileWorkflowRules.canLockAct("LOCKED", true))
assertFalse(MobileWorkflowRules.canLockAct("SEALED", true))
}
@Test
fun actCanOnlySealAfterInspectorAndCompanyOutcomeAreResolved() {
assertTrue(MobileWorkflowRules.canSealAct("LOCKED", true, "SIGNED"))
assertTrue(MobileWorkflowRules.canSealAct("LOCKED", true, "REFUSED"))
assertTrue(MobileWorkflowRules.canSealAct("LOCKED", true, "ABSENT"))
assertFalse(MobileWorkflowRules.canSealAct("DRAFT", true, "SIGNED"))
assertFalse(MobileWorkflowRules.canSealAct("LOCKED", false, "SIGNED"))
assertFalse(MobileWorkflowRules.canSealAct("LOCKED", true, null))
assertFalse(MobileWorkflowRules.canSealAct("LOCKED", true, "PENDING"))
}
@Test
fun inspectionClosesOnlyWhenEveryNonCancelledActIsSealed() {
assertTrue(MobileWorkflowRules.canCloseInspection("IN_PROGRESS", listOf("SEALED")))
assertTrue(MobileWorkflowRules.canCloseInspection("IN_PROGRESS", listOf("SEALED", "CANCELLED", "SEALED")))
assertFalse(MobileWorkflowRules.canCloseInspection("IN_PROGRESS", emptyList()))
assertFalse(MobileWorkflowRules.canCloseInspection("IN_PROGRESS", listOf("CANCELLED")))
assertFalse(MobileWorkflowRules.canCloseInspection("IN_PROGRESS", listOf("SEALED", "LOCKED")))
assertFalse(MobileWorkflowRules.canCloseInspection("IN_PROGRESS", listOf("SEALED", "DRAFT")))
assertFalse(MobileWorkflowRules.canCloseInspection("CLOSED", listOf("SEALED")))
}
}
+12
View File
@@ -13,6 +13,7 @@ import type {
import { CurrentAuth } from './decorators/current-auth.decorator'; import { CurrentAuth } from './decorators/current-auth.decorator';
import { Public } from './decorators/public.decorator'; import { Public } from './decorators/public.decorator';
import { SkipCsrf } from './decorators/skip-csrf.decorator'; import { SkipCsrf } from './decorators/skip-csrf.decorator';
import { ChangePasswordDto } from './dto/change-password.dto';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { MobileRefreshDto } from './dto/mobile-refresh.dto'; import { MobileRefreshDto } from './dto/mobile-refresh.dto';
import { MobileAuthService } from './mobile-auth.service'; import { MobileAuthService } from './mobile-auth.service';
@@ -53,4 +54,15 @@ export class MobileAuthController {
) { ) {
return this.mobileAuth.logout(principal, request); return this.mobileAuth.logout(principal, request);
} }
@Post('change-password')
@HttpCode(200)
@Throttle({ default: { limit: 10, ttl: 60_000 } })
changePassword(
@CurrentAuth() principal: AuthPrincipal,
@Body() dto: ChangePasswordDto,
@Req() request: RequestWithContext,
) {
return this.mobileAuth.changePassword(principal, dto, request);
}
} }