F2.4 Android: agregar evidencias fotográficas al Hallazgo

This commit is contained in:
2026-09-07 08:48:36 -03:00
parent cb69cea3a9
commit c4c6dd6c24
@@ -6,15 +6,24 @@ 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.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.HttpException 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
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Path
import java.io.File
import java.time.Instant
data class FieldFindingAct( data class FieldFindingAct(
val id: String, val id: String,
@@ -64,6 +73,30 @@ data class FieldFindingItem(
val correctionDueOn: String? = null, val correctionDueOn: String? = null,
) )
data class FieldFindingEvidence(
val id: String,
val findingId: String,
val kind: String,
val purpose: String,
val originalName: String,
val mimeType: String,
val sizeBytes: Long,
val sha256: String,
val title: String? = null,
val description: String? = null,
val capturedAt: String? = null,
val latitude: Double? = null,
val longitude: Double? = null,
val accuracyM: Double? = null,
val deviceLabel: String? = null,
val source: String,
val createdAt: String,
)
data class FieldFindingEvidenceListResponse(
val data: List<FieldFindingEvidence> = emptyList(),
)
data class FieldFindingOptionsResponse( data class FieldFindingOptionsResponse(
val act: FieldFindingAct, val act: FieldFindingAct,
val capture: CaptureStatus = CaptureStatus(), val capture: CaptureStatus = CaptureStatus(),
@@ -104,13 +137,36 @@ private interface FieldFindingsApi {
@Body request: CreateFieldFindingRequest, @Body request: CreateFieldFindingRequest,
): FieldFindingCreateResponse ): FieldFindingCreateResponse
@GET("inspection-findings/{findingId}/evidence")
suspend fun evidence(
@Header("Authorization") authorization: String,
@Path("findingId") findingId: String,
): FieldFindingEvidenceListResponse
@Multipart
@POST("inspection-findings/{findingId}/evidence")
suspend fun uploadEvidence(
@Header("Authorization") authorization: String,
@Path("findingId") findingId: String,
@Part file: MultipartBody.Part,
@Part("kind") kind: RequestBody,
@Part("purpose") purpose: RequestBody,
@Part("title") title: RequestBody?,
@Part("description") description: RequestBody?,
@Part("capturedAt") capturedAt: RequestBody,
@Part("latitude") latitude: RequestBody,
@Part("longitude") longitude: RequestBody,
@Part("accuracyM") accuracyM: RequestBody?,
@Part("deviceLabel") deviceLabel: RequestBody,
): FieldFindingEvidence
@POST("auth/mobile/refresh") @POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
} }
/** /**
* Cliente separado para F2.3. Comparte el almacén cifrado de sesión de la APK, * Cliente de campo para Hallazgos y sus evidencias append-only.
* pero mantiene el contrato de Hallazgos desacoplado del cliente F2.2. * Comparte el almacén cifrado de sesión y nunca persiste la contraseña.
*/ */
class FieldFindingsRepository(context: Context) { class FieldFindingsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val store = SecureSessionStore(context.applicationContext)
@@ -136,6 +192,42 @@ class FieldFindingsRepository(context: Context) {
api.create("Bearer ${session.accessToken}", visitId, assetId, request) api.create("Bearer ${session.accessToken}", visitId, assetId, request)
} }
suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse = authorized { session ->
api.evidence("Bearer ${session.accessToken}", findingId)
}
suspend fun uploadObservationPhoto(
findingId: String,
file: File,
latitude: Double,
longitude: Double,
accuracyM: Double?,
title: String? = null,
description: String? = null,
capturedAt: String = Instant.now().toString(),
): FieldFindingEvidence = authorized { session ->
val text = "text/plain".toMediaType()
val part = MultipartBody.Part.createFormData(
"file",
file.name,
file.asRequestBody("image/jpeg".toMediaType()),
)
api.uploadEvidence(
authorization = "Bearer ${session.accessToken}",
findingId = findingId,
file = part,
kind = "PHOTO".toRequestBody(text),
purpose = "OBSERVATION".toRequestBody(text),
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracyM = accuracyM?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
)
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T { private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada") var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try { try {