Guardar los tokens
Al terminar: Guardar la sesión en el sandbox de Android y en el Llavero de iOS, con Swift implementando una interfaz de Kotlin.
Cierra la app y vuelve a abrirla. Estás fuera otra vez: el TokenStore que tienes guarda en memoria, y la memoria se acaba con la app. Esta lección lo cambia por un almacén de verdad, uno por plataforma.
Tiene además el momento más raro de todo el curso: una clase de Swift implementando una interfaz escrita en Kotlin. Hasta ahora la dependencia iba siempre en el otro sentido.
No se instala ninguna librería. Android guarda en SharedPreferences, que viene con el sistema (es de la plataforma Android, no de Jetpack), y iOS en el Llavero del sistema. Sí se toca el manifiesto de Android (dos atributos y dos ficheros) y se crea un fichero de Swift.
Cada fichero de Kotlin nuevo se crea como en El módulo compartido y la prueba de humo: clic derecho sobre la carpeta shared → New → File y la ruta desde la carpeta del módulo; primero va la línea package y después los import.
Paso 1 · Una interfaz sin suspend, a propósito
El archivo. SecureStorage.kt describe «un sitio donde guardar cadenas pequeñas y privadas»: leer, guardar y borrar por clave. Es lo único que verá el núcleo.
Créalo con la ruta src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/SecureStorage.kt:
package com.sebastiannarvaez.pokedex.data
// Un sitio donde guardar cadenas pequeñas y privadas. Es pública (no
// `internal`) a propósito: en iOS la implementa Swift, que es otro módulo.
interface SecureStorage {
// Sin `suspend`: ver la caja de abajo.
fun leer(clave: String): String?
fun guardar(clave: String, valor: String)
fun borrar(clave: String)
}
// En memoria. Para los tests y para el destino («target») de JVM, que no tiene dónde guardar.
class InMemorySecureStorage : SecureStorage {
private val datos = mutableMapOf<String, String>() // un mapa: pares clave → valor, como un diccionario
override fun leer(clave: String): String? = datos[clave]
override fun guardar(clave: String, valor: String) { datos[clave] = valor }
override fun borrar(clave: String) { datos.remove(clave) }
}Paso 2 · El adaptador, que es el único que sabe de hilos y de formato
El archivo. TokenStore.kt ya existe (lo escribiste en La sesión en el núcleo: la interfaz TokenStore y la versión en memoria). Ahí mismo, debajo de InMemoryTokenStore, va SecureStorageTokenStore: el puente entre la sesión (TokenStore) y el sitio seguro de cada plataforma. Vive en el código común, así que el formato de lo guardado es el mismo en las dos plataformas.
Abre shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/TokenStore.kt. Añade estos imports a los que ya hay (los que te falten):
import com.sebastiannarvaez.pokedex.core.AppDispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonY debajo de InMemoryTokenStore, el adaptador:
internal class SecureStorageTokenStore(
private val storage: SecureStorage, // el sitio de cada plataforma
private val dispatchers: AppDispatchers,
) : TokenStore {
override suspend fun leer(): Session? = withContext(dispatchers.io) {
// `return@withContext null` sale solo de este bloque `withContext`, no de más arriba.
val guardado = storage.leer(CLAVE) ?: return@withContext null
// Si lo guardado es de una versión anterior y ya no encaja, no se
// revienta: se trata como «no hay sesión» y el usuario vuelve a entrar.
runCatching { json.decodeFromString<SessionGuardada>(guardado).aSession() }.getOrNull()
}
override suspend fun guardar(session: Session) = withContext(dispatchers.io) {
storage.guardar(CLAVE, json.encodeToString(SessionGuardada.de(session)))
}
override suspend fun borrar() = withContext(dispatchers.io) { storage.borrar(CLAVE) }
private companion object {
const val CLAVE = "sesion"
val json = Json { ignoreUnknownKeys = true } // si el texto trae campos de más, los ignora
}
}Lo que hace cada pieza: withContext(dispatchers.io) mueve el trabajo al hilo de entrada y salida (así leer y escribir nunca bloquean la pantalla); runCatching { … }.getOrNull() intenta leer y, si falla, devuelve null en vez de reventar; y json es la herramienta de kotlinx.serialization que convierte la sesión en texto y al revés.
Y una decisión que se agradece dentro de un año: la forma de la sesión en disco no es la del dominio. Va al final del mismo fichero:
// Los nombres de los campos en disco están fijados a propósito y no siguen a
// los de `Session`: renombrar una propiedad del dominio no debe cerrarle la
// sesión a quien ya tenía la app.
@Serializable
private data class SessionGuardada(
val acceso: String,
val refresco: String,
val caducaEn: Long,
val usuario: String,
val email: String? = null,
) {
fun aSession() = Session(acceso, refresco, caducaEn, usuario, email)
companion object {
fun de(s: Session) = SessionGuardada(
acceso = s.accessToken,
refresco = s.refreshToken,
caducaEn = s.expiresAtEpochSeconds,
usuario = s.userId,
email = s.email,
)
}
}Paso 3 · Android: un fichero privado de la app
SharedPreferences, el almacén que se usa aquí, guarda pares clave-valor en un fichero XML privado de la app. Así se usa, en pequeño (es un ejemplo para leer; el código real está más abajo):
val prefs = context.getSharedPreferences("sesion", Context.MODE_PRIVATE) // el fichero sesion.xml, privado
prefs.getString("token", null) // leer: el valor, o null si no hay nada
prefs.edit().putString("token", "abc").commit() // escribir: abrir el editor, cambiar y confirmar
prefs.edit().remove("token").commit() // borrar, con la misma formacommit() espera a que el cambio esté en disco; apply() vuelve al momento y escribe después. En DataStore viste que su sustituto moderno existe y por qué: el fallo de SharedPreferences es leer y escribir en el hilo principal, el que dibuja la pantalla. Aquí nunca pasa, porque el adaptador del paso 2 ya nos movió al hilo de entrada y salida. Se usa el antiguo porque es síncrono, como la interfaz del paso 1: la función espera a tener el resultado y lo devuelve, sin suspend.
El Keystore de Android es el sistema que guarda claves criptográficas en un almacén protegido, a veces en hardware aparte, y hace las operaciones de cifrado sin dejar salir la clave. Sería lo que usaría un cifrado serio, y por eso aparece en la documentación de abajo.
Por eso la app no usa EncryptedSharedPreferences (la librería de cifrado de AndroidX): añadir hoy una dependencia obsoleta sería empezar con trabajo pendiente que alguien tendrá que rehacer. Y cifrar con el Keystore a mano es posible, pero es código delicado para un token que caduca en una hora.
En su lugar guarda la sesión en un fichero propio de la app. Android da a cada app su propio usuario de Linux y un directorio privado que las demás apps no pueden leer: eso es el sandbox («caja de arena»), y es lo que protege el token.
Lo que no protege: un móvil con root (con permisos de administrador total sobre el sistema), ni a quien consiga copiar todo el almacenamiento del teléfono por otra vía. Dentro del fichero el contenido está en claro.
Para un token que caduca en una hora y se puede revocar desde el servidor, el sandbox más sacarlo de las copias de seguridad (paso 5) es una elección razonable. Lo que no vale es dejarlo en el almacenamiento compartido ni en los logs.
El archivo. SecureStorage.android.kt es la implementación de Android. Créalo en la carpeta shared, con la ruta src/androidMain/kotlin/com/sebastiannarvaez/pokedex/data/SecureStorage.android.kt:
package com.sebastiannarvaez.pokedex.data
import android.content.Context
internal class AndroidSecureStorage(context: Context) : SecureStorage {
// `applicationContext`: el de la aplicación, que vive lo que el proceso.
// `MODE_PRIVATE`: solo esta app puede leer el fichero.
private val prefs = context.applicationContext
.getSharedPreferences(FICHERO, Context.MODE_PRIVATE)
override fun leer(clave: String): String? = prefs.getString(clave, null)
// `commit()` y no `apply()`: `apply()` escribe en otro hilo y vuelve al
// momento. Si la app muere justo después de entrar, la sesión se pierde y
// el síntoma es «a veces no me guarda la sesión», de los peores. Aquí ya
// estamos fuera del hilo principal (el adaptador del paso 2 nos movió al
// dispatcher de entrada y salida).
override fun guardar(clave: String, valor: String) {
prefs.edit().putString(clave, valor).commit()
}
override fun borrar(clave: String) {
prefs.edit().remove(clave).commit()
}
private companion object {
// Un fichero propio, `sesion.xml`, y no el de los ajustes: así se puede
// excluir de la copia de seguridad (paso 5) sin excluir los demás datos.
const val FICHERO = "sesion"
}
}La recomendación general es la contraria (apply() mejor que commit(), porque commit() espera a que el disco termine y, llamado desde el hilo principal, congelaría la pantalla). Aquí no la congela, porque ya estamos en el hilo de entrada y salida, y lo que está en juego es perder la sesión de alguien que acaba de entrar.
Al grafo. Las tres plataformas aportan su almacén y el núcleo común lo pide. En Android, abre shared/src/androidMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.android.kt: añade los dos imports y una línea dentro de platformModule:
import com.sebastiannarvaez.pokedex.data.AndroidSecureStorage
import com.sebastiannarvaez.pokedex.data.SecureStorage // Una sola instancia. El Context lo da Koin (`androidContext`): es el que
// se le pasó en `PokedexApplication` al arrancar.
single<SecureStorage> { AndroidSecureStorage(androidContext()) }La JVM, que solo existe para los tests, usa el de memoria. Abre shared/src/jvmMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.jvm.kt y añade lo mismo con otros nombres:
import com.sebastiannarvaez.pokedex.data.InMemorySecureStorage
import com.sebastiannarvaez.pokedex.data.SecureStorage // El destino de JVM solo existe para tests: no hay llavero que valga.
single<SecureStorage> { InMemorySecureStorage() }Qué pasa si falta la línea de Android (la de Modules.android.kt): ningún test de los que tienes lo notaría; lo notaría la app al pedir la sesión por primera vez, con este error, porque nadie aporta el SecureStorage que necesita el TokenStore:
InstanceCreationException: Could not create instance for '[Singleton: 'com.sebastiannarvaez.pokedex.data.TokenStore']'
Caused by: NoDefinitionFoundException: No definition found for type 'com.sebastiannarvaez.pokedex.data.SecureStorage' on scope '['_root_']'.Lo que verify() no ve. verify() es la comprobación de ModulesTest (Koin: un solo grafo en commonMain) que revisa que cada pieza del módulo tenga todo lo que pide su constructor. Pasa igual con la línea de la JVM y sin ella, porque TokenStore es una interfaz, sin constructor que mirar. Provócalo: borra la línea de Modules.jvm.kt (la de la JVM; la de Android déjala), ejecuta ./gradlew :shared:jvmTest --tests '*ModulesTest*' y verás BUILD SUCCESSFUL; vuelve a ponerla.
El cambio de verdad, en el núcleo: shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt. Sustituye el import de InMemoryTokenStore por el de SecureStorageTokenStore (los demás se quedan):
import com.sebastiannarvaez.pokedex.data.SecureStorageTokenStore // antes: InMemoryTokenStoreY dentro de pokedexModule sustituye las tres líneas del TokenStore: las dos del comentario que escribiste en La sesión en el núcleo («De momento en memoria…») y la de InMemoryTokenStore. Si dejaras el comentario viejo, quedaría encima del nuevo diciendo lo contrario.
// El almacén lo pone cada plataforma (get()); lo que se guarda y en qué
// formato lo decide este adaptador, que es común. El segundo get() es AppDispatchers.
single<TokenStore> { SecureStorageTokenStore(get(), get()) }InMemoryTokenStore se queda en TokenStore.kt: lo usan los tests.
Paso 4 · iOS: Swift implementa una interfaz de Kotlin
Primero, Kotlin. iOS no tiene el almacén en Kotlin: se lo pasa la app al arrancar Koin, igual que ya pasa la configuración. Abre shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/di/KoinIos.kt y sustituye el fichero entero:
package com.sebastiannarvaez.pokedex.di
import com.sebastiannarvaez.pokedex.core.AppConfig
import com.sebastiannarvaez.pokedex.data.SecureStorage
import org.koin.dsl.module
// El arranque de Koin para iOS: la única llamada que hace Swift. Se llama
// `startKoinIos` y no `initKoin` porque `init` + mayúscula es un prefijo
// reservado de Objective-C (llegaría a Swift como `doInitKoin`).
fun startKoinIos(config: AppConfig, secureStorage: SecureStorage) {
initKoin {
// La configuración y ahora también el Llavero, que es de iOS y se
// escribe en Swift: el grafo recibe una implementación que no está en Kotlin.
modules(module {
single { config }
single { secureStorage }
})
}
}Ahora, Swift. AlmacenDelLlavero.swift es la clase que cumple SecureStorage con la API del Llavero. La carpeta Core ya existe (Configuración y secretos por entorno). En Xcode: clic derecho sobre la carpeta Core → New File from Template… → Swift File → Next, nombre AlmacenDelLlavero → Create (plan B: touch iosApp/iosApp/Core/AlmacenDelLlavero.swift en la Terminal).
La API del Llavero es de C. Es antigua y se nota en tres detalles del código de abajo:
[String: Any]es el diccionario de Swift, yas CFDictionarylo convierte en el tipo equivalente que espera el sistema.- Las constantes
kSec…(kSecClass,kSecValueData…) son cadenas de C:as Stringlas convierte en claves válidas del diccionario de Swift. CFTypeRefsignifica «cualquier objeto del sistema», yas? Datalo convierte en bytes si de verdad lo es (si no, devuelvenil).
Contenido:
import Foundation
import Security // la API del Llavero (SecItemAdd, SecItemCopyMatching…)
import Shared // el framework de Kotlin: de ahí sale `SecureStorage`
// El Llavero de iOS, implementando una interfaz escrita en Kotlin. Es de las
// pocas veces que la dependencia va al revés: normalmente Swift llama a Kotlin.
// `SecureStorage` llega a Swift como un protocolo, y esta clase lo cumple.
final class AlmacenDelLlavero: SecureStorage {
// Todo lo que guarde la app comparte este «servicio», que es lo que permite
// borrarlo junto y no pisar lo de otras apps. `bundleIdentifier` es el
// nombre único de tu app (el que ves en Xcode); si no lo hubiera, se usa el de después de `??`.
private let servicio = Bundle.main.bundleIdentifier ?? "com.sebastiannarvaez.pokedex"
func leer(clave: String) -> String? {
var consulta = base(clave)
consulta[kSecReturnData as String] = true // devuélveme los datos…
consulta[kSecMatchLimit as String] = kSecMatchLimitOne // …de un solo elemento
// `resultado` empieza vacío y `&` deja que el sistema lo rellene.
var resultado: CFTypeRef?
let estado = SecItemCopyMatching(consulta as CFDictionary, &resultado)
guard estado == errSecSuccess, let datos = resultado as? Data else { return nil }
return String(data: datos, encoding: .utf8) // de bytes (Data) a texto
}
func guardar(clave: String, valor: String) {
// El Llavero no tiene «guardar o actualizar»: hay que borrar y añadir, o
// `SecItemAdd` devuelve -25299 (errSecDuplicateItem) la segunda vez, que
// es lo que convierte «entré dos veces» en «no me guarda».
borrar(clave: clave)
var item = base(clave)
item[kSecValueData as String] = Data(valor.utf8) // de texto a bytes, que es lo que guarda el Llavero
// Sin esta línea, el elemento viajaría en las copias de seguridad de
// iCloud a otros dispositivos. Un token de sesión no debe viajar:
// `ThisDeviceOnly` lo ata a este teléfono.
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
SecItemAdd(item as CFDictionary, nil)
}
func borrar(clave: String) {
SecItemDelete(base(clave) as CFDictionary)
}
// La «dirección» de un elemento: es una contraseña genérica de este servicio
// y de esta cuenta (la clave).
private func base(_ clave: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: servicio,
kSecAttrAccount as String: clave,
]
}
}Y se entrega al arrancar. En iosApp/iosApp/iOSApp.swift, dentro del init(), la llamada a startKoinIos gana un argumento (el resto del fichero se queda):
KoinIosKt.startKoinIos(config: IosAppConfig()) // El Llavero se pasa aquí porque es de iOS: el núcleo declara la
// interfaz y la app la cumple.
KoinIosKt.startKoinIos(config: IosAppConfig(), secureStorage: AlmacenDelLlavero())Compila con Cmd+B. Si Xcode dice que el tipo no cumple SecureStorage, comprueba que los tres métodos se llaman exactamente leer(clave:), guardar(clave:valor:) y borrar(clave:).
Paso 5 · Fuera de las copias de seguridad (Android)
Una sesión guardada que se restaura en otro teléfono es un token en un dispositivo que su dueño no eligió. Android hace copias de seguridad automáticas de los ficheros de la app, así que hay que decirle que sesion.xml no entra, y en los dos formatos de reglas: el de Android 11 y anteriores (backup_rules.xml) y el de Android 12 y posteriores (data_extraction_rules.xml).
Tu proyecto parte de la plantilla de Crear el proyecto Pokédex: el manifiesto ya trae android:allowBackup="true" (las copias están activadas), pero no existe la carpeta res/xml ni ninguno de los dos ficheros, y <application no tiene todavía los atributos que los nombran. Lo vas a crear todo.
Crea los dos ficheros. Clic derecho sobre la carpeta androidApp → New → File y la ruta (crea también la carpeta xml; plan B: mkdir -p androidApp/src/main/res/xml y touch en la Terminal):
src/main/res/xml/backup_rules.xml<?xml version="1.0" encoding="utf-8"?>
<!-- Android 11 y anteriores: qué entra en la copia automática. -->
<full-backup-content>
<!-- `sharedpref` = la carpeta de SharedPreferences; `path` = el fichero. -->
<exclude domain="sharedpref" path="sesion.xml" />
</full-backup-content>Y el segundo, src/main/res/xml/data_extraction_rules.xml:
<?xml version="1.0" encoding="utf-8"?>
<!-- Android 12 y posteriores, que separa dos casos. -->
<data-extraction-rules>
<!-- La copia que sube a Google: la sesión no sube. -->
<cloud-backup>
<exclude domain="sharedpref" path="sesion.xml" />
</cloud-backup>
<!-- El paso directo de un móvil al siguiente: tampoco. El usuario vuelve a
entrar una vez y a cambio el token no se duplica. -->
<device-transfer>
<exclude domain="sharedpref" path="sesion.xml" />
</device-transfer>
</data-extraction-rules>Esta app hace lo contrario a propósito: no guarda la sesión en la copia, y el usuario vuelve a entrar en el móvil nuevo. Block Store es lo que usarías si quisieras que entrara solo.
Declara los ficheros en el manifiesto. Abre androidApp/src/main/AndroidManifest.xml. En la etiqueta <application, añade los dos atributos nuevos (los demás se quedan; aquí van junto a los que ya tiene):
<application
android:name=".PokedexApplication"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
…@xml/backup_rules significa «el fichero backup_rules.xml de la carpeta res/xml»; el nombre va sin la extensión. Los puntos suspensivos son el resto de atributos que ya tenías, intactos.
Compila Android: ./gradlew :androidApp:assembleDebug debe terminar en BUILD SUCCESSFUL.
Paso 6 · Lo que prueban los tests
Ninguno de los tests es sobre el Llavero ni sobre SharedPreferences (esos no se pueden probar sin un móvil): son sobre el adaptador, que es común. Son cinco y repiten el patrón de los tests de La sesión en el núcleo (guardar y leer, no hay nada, borrar), así que el fichero se copia con Copiar fichero: clic derecho sobre la carpeta shared → New → File → src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/SecureStorageTokenStoreTest.kt, pulsa el botón, selecciona todo (Cmd+A) y pega (Cmd+V).
Dos de ellos merecen leerse (store(almacen) es un ayudante del fichero que crea el adaptador sobre ese almacén, y sesion es una sesión de ejemplo), porque fijan reglas que no se ven en el código. Lo guardado tiene que sobrevivir a una actualización de la app, y si ya no encaja, la respuesta correcta es «no hay sesión», no reventar al arrancar:
@Test
fun `un contenido ilegible se trata como si no hubiera sesion`() = runTest {
val almacen = InMemorySecureStorage()
almacen.guardar("sesion", "{esto no es json}") // algo que ya no encaja
assertNull(store(almacen).leer()) // sin sesión, y sin excepción
}Y los nombres de los campos en disco están fijados y no siguen a los de Session:
@Test
fun `el formato en disco no arrastra los nombres del dominio`() = runTest {
val almacen = InMemorySecureStorage()
store(almacen).guardar(sesion)
val guardado = almacen.leer("sesion")!!
assertTrue(guardado.contains("\"acceso\""), guardado)
assertTrue(guardado.contains("\"caducaEn\""), guardado)
assertTrue(!guardado.contains("accessToken"), guardado) // este nombre NO debe aparecer
}Ese último test es raro y merece la pena: comprueba que un nombre no aparece, que es la forma de dejar escrito que el formato de disco es un contrato y no un reflejo del código.
Una idea para llevarte, sin test nuevo. Todavía no hay pantallas para comprobar que la sesión sobrevive al cierre de la app (en las lecciones siguientes sí las habrá), pero se puede simular con cuatro líneas, que no hace falta que escribas: un reinicio es otro SecureStorageTokenStore sobre el mismo almacén.
// `dispatchers` y `sesion` serían los del fichero de tests: los hilos de prueba y una sesión de ejemplo.
val almacen = InMemorySecureStorage() // el sitio donde de verdad se guarda
SecureStorageTokenStore(almacen, dispatchers).guardar(sesion) // «antes de cerrar la app»
val tras = SecureStorageTokenStore(almacen, dispatchers).leer() // «al volver a abrirla»: otro store, el mismo almacén
// tras == sesion: lo que sobrevive a un reinicio es lo que hay en el almacén, no el TokenStoreEl TokenStore se crea de nuevo cada vez que arranca la app; lo que perdura es lo que hay en el almacén de la plataforma. Por eso el formato en disco es un contrato.
Paso 7 · Comprueba los tests
Esta lección añade cinco tests (los de SecureStorageTokenStoreTest). Lanza las tres tareas sin filtrar y abre los informes:
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test
open shared/build/reports/tests/jvmTest/index.htmlTermina en BUILD SUCCESSFUL y la página enseña arriba el recuadro «tests» con 110. Repite el open con testAndroidHostTest (72) y iosSimulatorArm64Test (75). Si pasas --tests a Gradle, el informe cuenta solo esas clases y la cifra será menor.
Punto de control
- Los informes enseñan 110 (JVM), 72 (Android host) y 75 (iOS): cinco más que al terminar la lección anterior, los de
SecureStorageTokenStoreTest.ModulesTestsigue en verde. ./gradlew :androidApp:assembleDebugtermina enBUILD SUCCESSFUL: los dos ficheros de reglas existen, los dos excluyensesion.xmly el manifiesto los nombra.- Xcode compila la app con Cmd+B: Swift implementa la interfaz de Kotlin sin errores.
shared/build.gradle.ktsno ha ganado ninguna dependencia nueva (y en concreto no apareceandroidx.security.crypto).- En Koin,
TokenStorese resuelve aSecureStorageTokenStorey el almacén lo aporta cada plataforma; enModules.ktya no queda el comentario «De momento en memoria».
Comprueba lo que entendiste
7 preguntas · necesitas 5 para completar la lección
1.¿Por qué
SecureStorageno declara sus funciones comosuspend?2.¿Por qué la app no usa
EncryptedSharedPreferencesen Android?3.En el Llavero de iOS, ¿por qué se borra antes de añadir?
4.Los dos ficheros de reglas de copia de seguridad, ¿por qué hacen falta los dos?
5.¿Por qué el formato en disco usa nombres propios (
acceso,caducaEn) y no los deSession?6.
commit()espera a que el disco termine. ¿Por qué no congela la pantalla enAndroidSecureStorage?7.Android recomienda Block Store para respaldar credenciales. ¿Qué hace esta app?