From 373cf8cccb7db8932eeb60d63dafac1732b1d51e Mon Sep 17 00:00:00 2001 From: enlineawork Date: Tue, 8 Sep 2026 17:14:32 -0300 Subject: [PATCH] refactor(android): share encrypted mobile session coordinator --- .../korexlabs/dhinspeccion/data/DhMobile.kt | 127 +++++++++--------- 1 file changed, 63 insertions(+), 64 deletions(-) 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 index b7e5eec..213a297 100644 --- 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 @@ -7,8 +7,6 @@ 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 @@ -69,6 +67,7 @@ data class StoredSession( val displayName: String, val accessToken: String, val refreshToken: String, + val mustChangePassword: Boolean = false, ) // ---------- Inspections ---------- @@ -137,6 +136,17 @@ data class ChecklistItem( 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, +) + data class ChecklistSummary( val generation: Int = 0, val stale: Boolean = false, @@ -292,9 +302,6 @@ 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 @@ -393,6 +400,7 @@ class SecureSessionStore(context: Context) { displayName = json.optString("displayName", json.getString("username")), accessToken = json.getString("accessToken"), refreshToken = json.getString("refreshToken"), + mustChangePassword = json.optBoolean("mustChangePassword", false), ) }.getOrElse { clear() @@ -403,19 +411,26 @@ class SecureSessionStore(context: Context) { 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, + return save( + StoredSession( + userId = response.user.id, + username = response.user.username, + displayName = displayName, + accessToken = response.accessToken, + refreshToken = response.refreshToken, + mustChangePassword = response.user.mustChangePassword, + ), ) + } + + fun save(stored: StoredSession): StoredSession { val json = JSONObject() .put("userId", stored.userId) .put("username", stored.username) .put("displayName", stored.displayName) .put("accessToken", stored.accessToken) .put("refreshToken", stored.refreshToken) + .put("mustChangePassword", stored.mustChangePassword) .toString() val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.ENCRYPT_MODE, key()) @@ -449,8 +464,7 @@ class SecureSessionStore(context: Context) { } class DhRepository(context: Context) { - private val store = SecureSessionStore(context.applicationContext) - private val refreshMutex = Mutex() + private val sessions = MobileSessionCoordinator.get(context.applicationContext) private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val api: DhApi = Retrofit.Builder() .baseUrl(BuildConfig.API_BASE_URL) @@ -459,42 +473,45 @@ class DhRepository(context: Context) { .build() .create(DhApi::class.java) - fun currentSession(): StoredSession? = store.load() + fun currentSession(): StoredSession? = sessions.currentSession() 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() { - val session = store.load() + val session = sessions.currentSession() 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) } - suspend fun visit(id: String): VisitDetail = authorized { session -> + suspend fun visit(id: String): VisitDetail = sessions.authorized { session -> 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) } - 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) } - 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) } - 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) } - 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) } @@ -503,7 +520,7 @@ class DhRepository(context: Context) { assetId: String, canonicalAssetId: String, reason: String, - ): FieldInventoryMergeResult = authorized { session -> + ): FieldInventoryMergeResult = sessions.authorized { session -> api.mergeFieldAsset( "Bearer ${session.accessToken}", visitId, @@ -520,46 +537,28 @@ class DhRepository(context: Context) { 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 + ): FieldPhotoResponse { + val response = sessions.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), + ) } + file.delete() + return response } companion object {