From 216a813f50b2a017d2cd807aadf8b36687d37bbb Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sun, 6 Sep 2026 16:38:21 -0300 Subject: [PATCH] =?UTF-8?q?android:=20implementar=20contrato=20API=20y=20s?= =?UTF-8?q?esi=C3=B3n=20cifrada?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../korexlabs/dhinspeccion/data/DhMobile.kt | 531 ++++++++++++++++++ 1 file changed, 531 insertions(+) create mode 100644 android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt 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 new file mode 100644 index 0000000..882b729 --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt @@ -0,0 +1,531 @@ +package com.korexlabs.dhinspeccion.data + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +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 +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import retrofit2.HttpException +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query +import java.io.File +import java.security.KeyStore +import java.time.Instant +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +// ---------- Auth ---------- + +data class LoginRequest( + val identifier: String, + val password: String, + val deviceLabel: String = "DH Android", +) + +data class RefreshRequest(val refreshToken: String) + +data class MobileUser( + val id: String, + val username: String, + val firstName: String? = null, + val lastName: String? = null, + val email: String? = null, + val mustChangePassword: Boolean = false, + val roles: List = emptyList(), + val permissions: List = emptyList(), +) + +data class MobileSessionResponse( + val user: MobileUser, + val accessToken: String, + val refreshToken: String, + val accessExpiresInSeconds: Long, +) + +data class StoredSession( + val userId: String, + val username: String, + val displayName: String, + val accessToken: String, + val refreshToken: String, +) + +// ---------- Inspections ---------- + +data class AssetSummary( + val id: String, + val code: String, + val name: String, + val typeName: String? = null, +) + +data class PersonSummary( + val id: String, + val username: String? = null, + val firstName: String? = null, + val lastName: String? = null, +) + +data class VisitSummary( + val id: String, + val code: String, + val title: String? = null, + val objective: String? = null, + val status: String, + val scopeAsset: AssetSummary? = null, + val operationalArea: AssetSummary? = null, + val operatorCompany: AssetSummary? = null, + val leadInspector: PersonSummary? = null, + val plannedStartAt: String? = null, + val actualStartedAt: String? = null, + val actualClosedAt: String? = null, + val instructions: String? = null, + val checklistGeneration: Int = 0, + val assetCount: Int = 0, + val memberCount: Int = 0, +) + +data class VisitMeta( + val page: Int, + val pageSize: Int, + val total: Int, + val totalPages: Int, +) + +data class VisitListResponse(val data: List, val meta: VisitMeta) + +data class PlannedAsset( + val id: String, + val code: String, + val name: String, + val typeName: String? = null, + val included: Boolean = true, + val planningSource: String? = null, + val exclusionReason: String? = null, +) + +data class ChecklistItem( + val id: String, + val findingId: String? = null, + val findingCode: String? = null, + val findingTitle: String? = null, + val findingStatus: String? = null, + val severity: Int? = null, + val itemKind: String? = null, + val referenceOn: String? = null, + val asset: AssetSummary? = null, + val assetIncluded: Boolean = true, +) + +data class ChecklistSummary( + val generation: Int = 0, + val stale: Boolean = false, + val antecedents: Int = 0, + val companyOverdue: Int = 0, + val verificationOverdue: Int = 0, + val upcomingControls: Int = 0, + val actionableAssets: Int = 0, + val excludedAssets: Int = 0, + val items: List = emptyList(), +) + +data class VisitDetail( + val id: String, + val code: String, + val title: String? = null, + val objective: String? = null, + val status: String, + val operationalArea: AssetSummary? = null, + val operatorCompany: AssetSummary? = null, + val leadInspector: PersonSummary? = null, + val plannedStartAt: String? = null, + val actualStartedAt: String? = null, + val actualClosedAt: String? = null, + val instructions: String? = null, + val assets: List = emptyList(), + val planningAssets: List = emptyList(), + val team: List = emptyList(), + val checklist: ChecklistSummary = ChecklistSummary(), +) + +// ---------- Field inventory ---------- + +data class FieldContext( + val visitId: String? = null, + val visitCode: String? = null, + val areaId: String? = null, + val areaCode: String? = null, + val areaName: String? = null, + val companyId: String? = null, + val companyCode: String? = null, + val companyName: String? = null, +) + +data class FieldInventoryItem( + val id: String, + val code: String, + val name: String, + val commonName: String? = null, + val informationStatus: String? = null, + val dataOrigin: String? = null, + val type: AssetSummary? = null, + val parent: AssetSummary? = null, + val selectedInInspection: Boolean = false, + val captureRequired: Boolean = false, + val hasGeometry: Boolean = false, + val fieldPhotoCount: Int = 0, + val readyForFinding: Boolean = true, +) + +data class FieldInventoryListResponse( + val context: FieldContext, + val data: List, +) + +data class FieldAttributeDefinition( + val id: String, + val code: String, + val name: String, + val dataType: String, + val isRequired: Boolean = false, + val unit: String? = null, + val options: Any? = null, + val sortOrder: Int = 0, +) + +data class FieldType( + val id: String, + val code: String, + val name: String, + val description: String? = null, + val attributes: List = emptyList(), +) + +data class FieldTypeResponse( + val context: FieldContext, + val parent: AssetSummary, + val data: List, +) + +data class CaptureStatus( + val captureRequired: Boolean = false, + val hasGeometry: Boolean = false, + val creationGpsCaptured: Boolean = false, + val fieldPhotoCount: Int = 0, + val readyForFinding: Boolean = true, +) + +data class FieldAssetDetail( + val context: FieldContext? = null, + val asset: FieldInventoryItem, + val selectedInInspection: Boolean = true, + val capture: CaptureStatus = CaptureStatus(), +) + +data class CreateFieldInventoryRequest( + val typeId: String, + val parentId: String? = null, + val code: String? = null, + val name: String, + val commonName: String? = null, + val description: String? = null, + val discoveryNotes: String? = null, + val attributes: Map, + val deviceLatitude: Double, + val deviceLongitude: Double, + val deviceAccuracyM: Double? = null, + val deviceCapturedAt: String, + val deviceLabel: String = "DH Android", +) + +data class FieldPhotoResponse( + val capture: CaptureStatus, +) + +interface DhApi { + @POST("auth/mobile/login") + suspend fun login(@Body request: LoginRequest): MobileSessionResponse + + @POST("auth/mobile/refresh") + suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse + + @POST("auth/mobile/logout") + suspend fun logout(@Header("Authorization") authorization: String): Map + + @GET("inspection-visits") + suspend fun visits( + @Header("Authorization") authorization: String, + @Query("inspectorId") inspectorId: String, + @Query("pageSize") pageSize: Int = 100, + ): VisitListResponse + + @GET("inspection-visits/{id}") + suspend fun visit( + @Header("Authorization") authorization: String, + @Path("id") id: String, + ): VisitDetail + + @POST("inspection-visits/{id}/start") + suspend fun startVisit( + @Header("Authorization") authorization: String, + @Path("id") id: String, + ): VisitDetail + + @GET("inspection-visits/{visitId}/field-inventory") + suspend fun fieldInventory( + @Header("Authorization") authorization: String, + @Path("visitId") visitId: String, + @Query("search") search: String? = null, + @Query("parentId") parentId: String? = null, + @Query("limit") limit: Int = 80, + ): FieldInventoryListResponse + + @GET("inspection-visits/{visitId}/field-inventory/types") + suspend fun fieldTypes( + @Header("Authorization") authorization: String, + @Path("visitId") visitId: String, + @Query("parentId") parentId: String? = null, + ): FieldTypeResponse + + @POST("inspection-visits/{visitId}/field-inventory/{assetId}/select") + suspend fun selectFieldAsset( + @Header("Authorization") authorization: String, + @Path("visitId") visitId: String, + @Path("assetId") assetId: String, + ): FieldAssetDetail + + @POST("inspection-visits/{visitId}/field-inventory") + suspend fun createFieldAsset( + @Header("Authorization") authorization: String, + @Path("visitId") visitId: String, + @Body request: CreateFieldInventoryRequest, + ): FieldAssetDetail + + @Multipart + @POST("inspection-visits/{visitId}/field-inventory/{assetId}/photos") + suspend fun uploadFieldPhoto( + @Header("Authorization") authorization: String, + @Path("visitId") visitId: String, + @Path("assetId") assetId: String, + @Part file: MultipartBody.Part, + @Part("deviceLatitude") latitude: okhttp3.RequestBody, + @Part("deviceLongitude") longitude: okhttp3.RequestBody, + @Part("deviceAccuracyM") accuracy: okhttp3.RequestBody?, + @Part("deviceCapturedAt") capturedAt: okhttp3.RequestBody, + @Part("deviceLabel") deviceLabel: okhttp3.RequestBody, + @Part("exifLatitude") exifLatitude: okhttp3.RequestBody?, + @Part("exifLongitude") exifLongitude: okhttp3.RequestBody?, + @Part("exifCapturedAt") exifCapturedAt: okhttp3.RequestBody?, + ): FieldPhotoResponse +} + +class SecureSessionStore(context: Context) { + private val prefs = context.getSharedPreferences("dh_v2_mobile_session", Context.MODE_PRIVATE) + private val alias = "dh_v2_mobile_session_key" + + fun load(): StoredSession? { + val encoded = prefs.getString("payload", null) ?: return null + return runCatching { + val parts = encoded.split('.', limit = 2) + require(parts.size == 2) + val iv = Base64.decode(parts[0], Base64.NO_WRAP) + val encrypted = Base64.decode(parts[1], Base64.NO_WRAP) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, iv)) + val json = JSONObject(String(cipher.doFinal(encrypted), Charsets.UTF_8)) + StoredSession( + userId = json.getString("userId"), + username = json.getString("username"), + displayName = json.optString("displayName", json.getString("username")), + accessToken = json.getString("accessToken"), + refreshToken = json.getString("refreshToken"), + ) + }.getOrElse { + clear() + null + } + } + + fun save(response: MobileSessionResponse): StoredSession { + val displayName = listOfNotNull(response.user.firstName, response.user.lastName) + .joinToString(" ").trim().ifBlank { response.user.username } + val stored = StoredSession( + userId = response.user.id, + username = response.user.username, + displayName = displayName, + accessToken = response.accessToken, + refreshToken = response.refreshToken, + ) + val json = JSONObject() + .put("userId", stored.userId) + .put("username", stored.username) + .put("displayName", stored.displayName) + .put("accessToken", stored.accessToken) + .put("refreshToken", stored.refreshToken) + .toString() + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key()) + val encrypted = cipher.doFinal(json.toByteArray(Charsets.UTF_8)) + val payload = Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + "." + + Base64.encodeToString(encrypted, Base64.NO_WRAP) + prefs.edit().putString("payload", payload).apply() + return stored + } + + fun clear() { + prefs.edit().clear().apply() + } + + private fun key(): SecretKey { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (keyStore.getKey(alias, null) as? SecretKey)?.let { return it } + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + generator.init( + KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(true) + .build(), + ) + return generator.generateKey() + } +} + +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) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + .create(DhApi::class.java) + + fun currentSession(): StoredSession? = store.load() + + suspend fun login(identifier: String, password: String): StoredSession = + store.save(api.login(LoginRequest(identifier.trim(), password))) + + suspend fun logout() { + val session = store.load() + if (session != null) runCatching { api.logout("Bearer ${session.accessToken}") } + store.clear() + } + + suspend fun visits(): VisitListResponse = authorized { session -> + api.visits("Bearer ${session.accessToken}", session.userId) + } + + suspend fun visit(id: String): VisitDetail = authorized { session -> + api.visit("Bearer ${session.accessToken}", id) + } + + suspend fun startVisit(id: String): VisitDetail = authorized { session -> + api.startVisit("Bearer ${session.accessToken}", id) + } + + suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null) = authorized { session -> + api.fieldInventory("Bearer ${session.accessToken}", visitId, search?.takeIf { it.isNotBlank() }, parentId) + } + + suspend fun fieldTypes(visitId: String, parentId: String?) = authorized { session -> + api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId) + } + + suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session -> + api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId) + } + + suspend fun createFieldAsset(visitId: String, request: CreateFieldInventoryRequest) = authorized { session -> + api.createFieldAsset("Bearer ${session.accessToken}", visitId, request) + } + + suspend fun uploadFieldPhoto( + visitId: String, + assetId: String, + file: File, + latitude: Double, + longitude: Double, + accuracyM: Double?, + capturedAt: String = Instant.now().toString(), + ): FieldPhotoResponse = authorized { session -> + val text = "text/plain".toMediaType() + val body = file.asRequestBody("image/jpeg".toMediaType()) + val part = MultipartBody.Part.createFormData("file", file.name, body) + api.uploadFieldPhoto( + authorization = "Bearer ${session.accessToken}", + visitId = visitId, + assetId = assetId, + file = part, + latitude = latitude.toString().toRequestBody(text), + longitude = longitude.toString().toRequestBody(text), + accuracy = accuracyM?.toString()?.toRequestBody(text), + capturedAt = capturedAt.toRequestBody(text), + deviceLabel = "DH Android".toRequestBody(text), + exifLatitude = latitude.toString().toRequestBody(text), + exifLongitude = longitude.toString().toRequestBody(text), + exifCapturedAt = capturedAt.toRequestBody(text), + ) + } + + private suspend fun 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 + } + } + + companion object { + fun humanError(error: Throwable): String { + if (error is HttpException) { + val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull() + val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull() + if (!message.isNullOrBlank()) return message + return "Error HTTP ${error.code()}" + } + return error.message ?: "Ocurrió un error inesperado" + } + + fun newOperationId(): String = UUID.randomUUID().toString() + } +}