refactor(android): share auth and clean uploaded finding photos

This commit is contained in:
2026-09-08 17:16:03 -03:00
parent 480cb6c691
commit 516445fea6
@@ -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
} }
} }