refactor(android): share encrypted mobile session coordinator
This commit is contained in:
@@ -7,8 +7,6 @@ import android.util.Base64
|
|||||||
import com.korexlabs.dhinspeccion.BuildConfig
|
import com.korexlabs.dhinspeccion.BuildConfig
|
||||||
import com.squareup.moshi.Moshi
|
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.withLock
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.MultipartBody
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -69,6 +67,7 @@ data class StoredSession(
|
|||||||
val displayName: String,
|
val displayName: String,
|
||||||
val accessToken: String,
|
val accessToken: String,
|
||||||
val refreshToken: String,
|
val refreshToken: String,
|
||||||
|
val mustChangePassword: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------- Inspections ----------
|
// ---------- Inspections ----------
|
||||||
@@ -137,6 +136,17 @@ data class ChecklistItem(
|
|||||||
val assetIncluded: Boolean = true,
|
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(
|
data class ChecklistSummary(
|
||||||
val generation: Int = 0,
|
val generation: Int = 0,
|
||||||
val stale: Boolean = false,
|
val stale: Boolean = false,
|
||||||
@@ -292,9 +302,6 @@ interface DhApi {
|
|||||||
@POST("auth/mobile/login")
|
@POST("auth/mobile/login")
|
||||||
suspend fun login(@Body request: LoginRequest): MobileSessionResponse
|
suspend fun login(@Body request: LoginRequest): MobileSessionResponse
|
||||||
|
|
||||||
@POST("auth/mobile/refresh")
|
|
||||||
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
|
|
||||||
|
|
||||||
@POST("auth/mobile/logout")
|
@POST("auth/mobile/logout")
|
||||||
suspend fun logout(@Header("Authorization") authorization: String): Map<String, Any?>
|
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")),
|
displayName = json.optString("displayName", json.getString("username")),
|
||||||
accessToken = json.getString("accessToken"),
|
accessToken = json.getString("accessToken"),
|
||||||
refreshToken = json.getString("refreshToken"),
|
refreshToken = json.getString("refreshToken"),
|
||||||
|
mustChangePassword = json.optBoolean("mustChangePassword", false),
|
||||||
)
|
)
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
clear()
|
clear()
|
||||||
@@ -403,19 +411,26 @@ class SecureSessionStore(context: Context) {
|
|||||||
fun save(response: MobileSessionResponse): StoredSession {
|
fun save(response: MobileSessionResponse): StoredSession {
|
||||||
val displayName = listOfNotNull(response.user.firstName, response.user.lastName)
|
val displayName = listOfNotNull(response.user.firstName, response.user.lastName)
|
||||||
.joinToString(" ").trim().ifBlank { response.user.username }
|
.joinToString(" ").trim().ifBlank { response.user.username }
|
||||||
val stored = StoredSession(
|
return save(
|
||||||
userId = response.user.id,
|
StoredSession(
|
||||||
username = response.user.username,
|
userId = response.user.id,
|
||||||
displayName = displayName,
|
username = response.user.username,
|
||||||
accessToken = response.accessToken,
|
displayName = displayName,
|
||||||
refreshToken = response.refreshToken,
|
accessToken = response.accessToken,
|
||||||
|
refreshToken = response.refreshToken,
|
||||||
|
mustChangePassword = response.user.mustChangePassword,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(stored: StoredSession): StoredSession {
|
||||||
val json = JSONObject()
|
val json = JSONObject()
|
||||||
.put("userId", stored.userId)
|
.put("userId", stored.userId)
|
||||||
.put("username", stored.username)
|
.put("username", stored.username)
|
||||||
.put("displayName", stored.displayName)
|
.put("displayName", stored.displayName)
|
||||||
.put("accessToken", stored.accessToken)
|
.put("accessToken", stored.accessToken)
|
||||||
.put("refreshToken", stored.refreshToken)
|
.put("refreshToken", stored.refreshToken)
|
||||||
|
.put("mustChangePassword", stored.mustChangePassword)
|
||||||
.toString()
|
.toString()
|
||||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, key())
|
cipher.init(Cipher.ENCRYPT_MODE, key())
|
||||||
@@ -449,8 +464,7 @@ class SecureSessionStore(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class DhRepository(context: Context) {
|
class DhRepository(context: Context) {
|
||||||
private val store = SecureSessionStore(context.applicationContext)
|
private val sessions = MobileSessionCoordinator.get(context.applicationContext)
|
||||||
private val refreshMutex = Mutex()
|
|
||||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
||||||
private val api: DhApi = Retrofit.Builder()
|
private val api: DhApi = Retrofit.Builder()
|
||||||
.baseUrl(BuildConfig.API_BASE_URL)
|
.baseUrl(BuildConfig.API_BASE_URL)
|
||||||
@@ -459,42 +473,45 @@ class DhRepository(context: Context) {
|
|||||||
.build()
|
.build()
|
||||||
.create(DhApi::class.java)
|
.create(DhApi::class.java)
|
||||||
|
|
||||||
fun currentSession(): StoredSession? = store.load()
|
fun currentSession(): StoredSession? = sessions.currentSession()
|
||||||
|
|
||||||
suspend fun login(identifier: String, password: String): StoredSession =
|
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() {
|
suspend fun logout() {
|
||||||
val session = store.load()
|
val session = sessions.currentSession()
|
||||||
if (session != null) runCatching { api.logout("Bearer ${session.accessToken}") }
|
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)
|
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)
|
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)
|
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)
|
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)
|
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)
|
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)
|
api.createFieldAsset("Bearer ${session.accessToken}", visitId, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,7 +520,7 @@ class DhRepository(context: Context) {
|
|||||||
assetId: String,
|
assetId: String,
|
||||||
canonicalAssetId: String,
|
canonicalAssetId: String,
|
||||||
reason: String,
|
reason: String,
|
||||||
): FieldInventoryMergeResult = authorized { session ->
|
): FieldInventoryMergeResult = sessions.authorized { session ->
|
||||||
api.mergeFieldAsset(
|
api.mergeFieldAsset(
|
||||||
"Bearer ${session.accessToken}",
|
"Bearer ${session.accessToken}",
|
||||||
visitId,
|
visitId,
|
||||||
@@ -520,46 +537,28 @@ class DhRepository(context: Context) {
|
|||||||
longitude: Double,
|
longitude: Double,
|
||||||
accuracyM: Double?,
|
accuracyM: Double?,
|
||||||
capturedAt: String = Instant.now().toString(),
|
capturedAt: String = Instant.now().toString(),
|
||||||
): FieldPhotoResponse = authorized { session ->
|
): FieldPhotoResponse {
|
||||||
val text = "text/plain".toMediaType()
|
val response = sessions.authorized { session ->
|
||||||
val body = file.asRequestBody("image/jpeg".toMediaType())
|
val text = "text/plain".toMediaType()
|
||||||
val part = MultipartBody.Part.createFormData("file", file.name, body)
|
val body = file.asRequestBody("image/jpeg".toMediaType())
|
||||||
api.uploadFieldPhoto(
|
val part = MultipartBody.Part.createFormData("file", file.name, body)
|
||||||
authorization = "Bearer ${session.accessToken}",
|
api.uploadFieldPhoto(
|
||||||
visitId = visitId,
|
authorization = "Bearer ${session.accessToken}",
|
||||||
assetId = assetId,
|
visitId = visitId,
|
||||||
file = part,
|
assetId = assetId,
|
||||||
latitude = latitude.toString().toRequestBody(text),
|
file = part,
|
||||||
longitude = longitude.toString().toRequestBody(text),
|
latitude = latitude.toString().toRequestBody(text),
|
||||||
accuracy = accuracyM?.toString()?.toRequestBody(text),
|
longitude = longitude.toString().toRequestBody(text),
|
||||||
capturedAt = capturedAt.toRequestBody(text),
|
accuracy = accuracyM?.toString()?.toRequestBody(text),
|
||||||
deviceLabel = "DH Android".toRequestBody(text),
|
capturedAt = capturedAt.toRequestBody(text),
|
||||||
exifLatitude = latitude.toString().toRequestBody(text),
|
deviceLabel = "DH Android".toRequestBody(text),
|
||||||
exifLongitude = longitude.toString().toRequestBody(text),
|
exifLatitude = latitude.toString().toRequestBody(text),
|
||||||
exifCapturedAt = capturedAt.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
|
|
||||||
}
|
}
|
||||||
|
file.delete()
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
Reference in New Issue
Block a user