refactor(android): share encrypted mobile session coordinator

This commit is contained in:
2026-09-08 17:14:32 -03:00
parent 18238d0c3d
commit 373cf8cccb
@@ -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<String, Any?>
@@ -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 <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
): 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 {