From ad72f3505cc0c7dae6b89994b907cbd295efd99e Mon Sep 17 00:00:00 2001 From: enlineawork Date: Tue, 8 Sep 2026 17:11:54 -0300 Subject: [PATCH] feat(android): centralize mobile session refresh and password change --- .../data/MobileSessionCoordinator.kt | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileSessionCoordinator.kt diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileSessionCoordinator.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileSessionCoordinator.kt new file mode 100644 index 0000000..4798691 --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileSessionCoordinator.kt @@ -0,0 +1,98 @@ +package com.korexlabs.dhinspeccion.data + +import android.content.Context +import com.korexlabs.dhinspeccion.BuildConfig +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import okhttp3.OkHttpClient +import retrofit2.HttpException +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.POST + +data class ChangeMobilePasswordRequest( + val currentPassword: String, + val newPassword: String, +) + +data class ChangeMobilePasswordResponse( + val status: String, + val mustChangePassword: Boolean, +) + +private interface MobileSessionApi { + @POST("auth/mobile/refresh") + suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse + + @POST("auth/change-password") + suspend fun changePassword( + @Header("Authorization") authorization: String, + @Body request: ChangeMobilePasswordRequest, + ): ChangeMobilePasswordResponse +} + +/** + * Único coordinador de sesión del proceso Android. + * + * Todos los repositorios comparten el mismo gate de refresh para que un token rotativo no sea + * consumido en paralelo por Inventario, Hallazgos y Actas. + */ +class MobileSessionCoordinator private constructor(context: Context) { + private val store = SecureSessionStore(context.applicationContext) + private val gate = SessionRefreshGate() + private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() + private val api: MobileSessionApi = Retrofit.Builder() + .baseUrl(BuildConfig.API_BASE_URL) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + .create(MobileSessionApi::class.java) + + fun currentSession(): StoredSession? = store.load() + + fun save(response: MobileSessionResponse): StoredSession = store.save(response) + + fun clear() = store.clear() + + 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 = gate.refreshIfNeeded( + previousRefreshToken = session.refreshToken, + load = store::load, + save = store::save, + clear = store::clear, + refresh = { token -> api.refresh(RefreshRequest(token)) }, + ) + return block(session) + } + + suspend fun changePassword(currentPassword: String, newPassword: String): StoredSession { + val sessionUsed = authorized { session -> + api.changePassword( + authorization = "Bearer ${session.accessToken}", + request = ChangeMobilePasswordRequest(currentPassword, newPassword), + ) + session + } + val latest = store.load() ?: sessionUsed + return store.save(latest.copy(mustChangePassword = false)) + } + + companion object { + @Volatile + private var instance: MobileSessionCoordinator? = null + + fun get(context: Context): MobileSessionCoordinator = + instance ?: synchronized(this) { + instance ?: MobileSessionCoordinator(context.applicationContext).also { instance = it } + } + } +}