Compare commits

...
Author SHA1 Message Date
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
12 changed files with 325 additions and 26 deletions
+90 -18
View File
@@ -1,31 +1,32 @@
name: Android APK
# F3.2: genera la APK debug verificable antes de promover la integración multi-Acta.
name: Android CI / RC
on:
push:
branches:
- 'feature/f2-2*'
- 'feature/f2-3*'
- 'feature/f2-4*'
- 'feature/f3-1*'
- 'feature/f3-2*'
branches: [main]
paths:
- 'android-app/**'
- 'api-v3/src/**'
- '.github/workflows/android.yml'
pull_request:
branches: [main]
paths:
- 'android-app/**'
- 'api-v3/src/auth/**'
- 'api-v3/src/**'
- '.github/workflows/android.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: dhv2-android-${{ github.ref }}
cancel-in-progress: true
jobs:
build-debug-apk:
android:
name: Android · lint, tests, debug APK, release compile
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 35
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -47,18 +48,89 @@ jobs:
with:
gradle-version: '8.13'
- name: Assemble debug
working-directory: android-app
run: gradle --no-daemon :app:assembleDebug
- name: Validate mobile security and identity contract
run: |
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
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
with:
name: DH-Inspeccion-F3.2-0.12.0-debug
path: android-app/app/build/outputs/apk/debug/app-debug.apk
name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-debug
path: android-app/dist/*
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
+7
View File
@@ -11,6 +11,11 @@
**/.vite/
**/coverage/
# Android / Gradle local state
android-app/.gradle/
android-app/**/build/
android-app/local.properties
# Backups / exports
*.zip
*.tar.gz
@@ -38,6 +43,8 @@ Thumbs.db
*.key
*.p12
*.pfx
*.jks
*.keystore
id_rsa
id_ed25519
*_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"
minSdk = 26
targetSdk = 36
versionCode = 19
versionName = "0.12.0"
versionCode = 20
versionName = "0.13.0-rc1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -46,6 +46,11 @@ android {
}
kotlinOptions.jvmTarget = "17"
lint {
abortOnError = true
checkReleaseBuilds = true
}
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_COARSE_LOCATION" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<application
android:allowBackup="false"
android:label="@string/app_name"
@@ -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)
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."))
return@suspendCancellableCoroutine
}
@@ -721,7 +721,9 @@ private fun f3HasLocation(context: Context): Boolean =
f3HasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
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."))
return@suspendCancellableCoroutine
}
@@ -383,7 +383,9 @@ private fun findingHasLocation(context: Context): Boolean =
findingHasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
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."))
return@suspendCancellableCoroutine
}
@@ -426,13 +426,15 @@ private fun hasActLocation(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
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"))
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
LocationServices.getFusedLocationProviderClient(context)
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
val client = LocationServices.getFusedLocationProviderClient(context)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
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,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")))
}
}