Tema 9: Almacenamiento en Android
Objetivos de este tema
- Diferenciar los distintos tipos de almacenamiento en Android y sus casos de uso.
- Implementar almacenamiento ligero con
SharedPreferencesyDataStore. - Gestionar archivos en memoria interna y externa, aplicando las restricciones de seguridad actuales.
- Utilizar el Storage Access Framework (SAF) para seleccionar y manipular documentos de forma segura.
- Aplicar buenas prácticas de seguridad y eficiencia en el tratamiento de datos.
- Desarrollar una aplicación práctica que integre los distintos mecanismos de almacenamiento.
9.1. Introducción al almacenamiento en Android
El almacenamiento en Android permite conservar información más allá del ciclo de vida de la aplicación. Se distinguen distintos tipos:
- Memoria interna: espacio privado de la aplicación. Otros programas no pueden acceder.
- Memoria externa: almacenamiento compartido (galería, descargas). Puede ser accesible por otras apps, aunque desde Android 10 se restringe con Scoped Storage.
- Caché: almacenamiento temporal, puede ser eliminado por el sistema en cualquier momento.
Nota
Desde Android 10, el acceso a memoria externa está limitado por razones de seguridad. Android introduce el concepto de Scoped Storage, que restringe el acceso directo a directorios externos y fomenta el uso de MediaStore o Storage Access Framework (SAF).
9.2. Archivos internos
Los archivos internos son privados de la aplicación. Se eliminan cuando esta se desinstala.
Ejemplo: escribir un archivo en memoria interna
1// Guardar un archivo de texto en memoria interna
2fun saveToFile(context: Context, fileName: String, contenido: String) {
3 context.openFileOutput(fileName, Context.MODE_APPEND).use { output ->
4 output.write(contenido.toByteArray())
5 }
6}Nota
Si utilizas Context.MODE_PRIVATE, el archivo se sobrescribirá cada vez que se guarde.
Ejemplo: leer un archivo de memoria interna
1// Leer un archivo de texto desde memoria interna
2fun readFromFile(context: Context, fileName: String): String {
3 return try {
4 context.openFileInput(fileName).bufferedReader().use { it.readText() }
5 } catch (e: Exception) {
6 "Error al leer el archivo: ${e.message}"
7 }
8}Este código abre un archivo en modo lectura y devuelve su contenido como cadena, manejando posibles excepciones, como que el archivo no exista.
Información
Puedes consultar los archivos guardados en memoria interna desde Android Studio: View > Tool Windows > Device File Explorer. Navega a /data/data/<tu_paquete>/files/ para ver los archivos de tu aplicación.
9.3. SharedPreferences
SharedPreferences es un sistema clave-valor para almacenar configuraciones simples.
- Datos pequeños: ajustes, flags, preferencias del usuario.
- Limitaciones: acceso síncrono y sin soporte para estructuras complejas.
Ejemplo: guardar y recuperar un valor
1// Guardar preferencia
2fun savePreference(context: Context, key: String, value: String) {
3 val prefs = context.getSharedPreferences("MisPreferencias", Context.MODE_PRIVATE)
4 prefs.edit { putString(key, value) }
5}
6
7// Leer preferencia
8fun readPreference(context: Context, key: String): String? {
9 val prefs = context.getSharedPreferences("MisPreferencias", Context.MODE_PRIVATE)
10 // Leer preferencia
11 return prefs.getString(key, "Invitado")
12}En este ejemplo, se guarda y recupera una cadena asociada a una clave en las preferencias compartidas. Un posible resultado es el siguiente fichero XML:
1<map>
2 <string name="nombre">Javier</string>
3</map>Información
Puedes consultar las preferencias guardadas en Android Studio: View > Tool Windows > Device File Explorer. Navega a /data/data/<tu_paquete>/shared_prefs/ para ver el archivo XML con las preferencias de tu aplicación.
9.4. DataStore
DataStore es la alternativa moderna a SharedPreferences.
-
Basado en corrutinas y Flow → asincrónico y seguro.
-
Tipos:
- Preferences DataStore: clave-valor.
- Proto DataStore: objetos tipados mediante Protobuf (mecanismo de serialización).
Dependencia
1implementation("androidx.datastore:datastore-preferences:1.1.7")Ejemplo: guardar y leer un tema oscuro/claro
1val Context.dataStore by preferencesDataStore("ajustes")
2val TEMA_OSCURO = booleanPreferencesKey("tema_oscuro")
3
4suspend fun saveThemeMode(context: Context, oscuro: Boolean) {
5 context.dataStore.edit { prefs ->
6 prefs[TEMA_OSCURO] = oscuro
7 }
8}
9
10fun readThemeMode(context: Context): Flow<Boolean> {
11 val temaFlow: Flow<Boolean> = context.dataStore.data.map { prefs ->
12 prefs[TEMA_OSCURO] ?: false
13 }
14 return temaFlow
15}En este ejemplo, se guarda y recupera una preferencia booleana que indica si el tema oscuro está activado. El valor predeterminado es false (tema claro).
Uso desde una Activity:
1// Guardar el modo tema en DataStore
2CoroutineScope(Dispatchers.IO).launch {
3 saveThemeMode(contxt, it)
4}
5
6...
7// Leer el modo tema desde DataStore
8LaunchedEffect(checked) {
9 readThemeMode(contxt).collect { valor ->
10 checked = valor
11 }
12}9.5. Storage Access Framework (SAF)
Los archivos externos (fotos, música, documentos) son accesibles fuera de la aplicación. Desde Android 10, solo es posible acceder mediante rutas específicas o el uso de SAF.
SAF permite a la aplicación abrir, guardar y seleccionar documentos mediante un explorador de archivos seguro, sin acceso directo al almacenamiento.
Seleccionar y procesar un documento
1val contxt = LocalContext.current
2var uriString by remember { mutableStateOf("") }
3var content by remember { mutableStateOf("") }
4
5val openDocumentLauncher = rememberLauncherForActivityResult(
6 contract = ActivityResultContracts.OpenDocument(),
7 onResult = { uri ->
8 uri?.let {
9 uriString = it.toString()
10 content = contxt.contentResolver.openInputStream(it)?.bufferedReader().use { reader ->
11 reader?.readText() ?: "Error al leer el archivo"
12 }
13 }
14 }
15)Este código crea un lanzador para seleccionar un documento. Al seleccionar un archivo, se obtiene su URI y se lee su contenido, permitiendo mostrarlo en la interfaz.
9.6. Almacenamiento seguro de preferencias con Android Keystore + AES-GCM
El almacenamiento puede exponer datos sensibles si no se protege adecuadamente, pero Android ofrece mecanismos de cifrado. Desde 2025 la librería Jetpack Security Crypto (que contenía EncryptedSharedPreferences) está deprecada. La recomendación de Google es apoyarse en APIs de plataforma (Android Keystore + JCA) y en DataStore como reemplazo moderno de SharedPreferences para preferencias no cifradas, si se necesita cifrado, cifra el valor antes de persistirlo.
Para aplicar el siguiente ejemplo se añadirá la dependencia utilizada en el punto 9.4., DataStore. Además, se utilizará el patrón de diseño MVVM y Repository para abstraer la lógica de acceso a datos, manteniendo el código limpio y modular.
Arquitectura propuesta para almacenamiento seguro
+-------------------+
| UI (Compose) |
+-------------------+
|
v
+-------------------+
| ViewModel |
+-------------------+
|
v
+-------------------+
| Repository |
+-------------------+
|
v
+-------------------+
| CryptoManager |
+-------------------+data/security/CryptoManager: utilidades JCA (Keystore + AES-GCM).data/repository/SecurePrefsRepository: persiste en DataStore los valores cifrados.ui/SecurePrefsViewModel: orquesta lectura/escritura.ui/SecurePrefsScreen: ejemplo Compose para guardar/leer un token/API key.
Paso 1: Implementación de las utilidades criptográficas (Keystore + AES-GCM)
En primer lugar, se creará el objeto CryptoManager en el paquete data/security que gestionará la generación de claves y el cifrado/descifrado de datos.
1import android.security.keystore.KeyGenParameterSpec
2import android.security.keystore.KeyProperties
3import java.security.KeyStore
4import javax.crypto.Cipher
5import javax.crypto.KeyGenerator
6import javax.crypto.SecretKey
7import javax.crypto.spec.GCMParameterSpec
8
9object CryptoManager {
10
11 private const val ANDROID_KEYSTORE = "AndroidKeyStore"
12 private const val KEY_ALIAS = "prefs_aes_key"
13 private const val TRANSFORMATION = "AES/GCM/NoPadding"
14 private const val GCM_TAG_BITS = 128
15
16 // Obtiene o crea una clave simétrica AES en el Keystore (no exportable).
17 fun getOrCreateKey(): SecretKey {
18 val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
19 (ks.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey?.let { return it }
20
21 val spec = KeyGenParameterSpec.Builder(
22 KEY_ALIAS,
23 KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
24 )
25 .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
26 .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
27 .setUserAuthenticationRequired(false) // Poner a true si quieres exigir biometría/bloqueo.
28 .build()
29
30 return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
31 .apply { init(spec) }
32 .generateKey()
33 }
34
35 // Clase para mantener el IV junto a los datos cifrados.
36 data class Ciphertext(val iv: ByteArray, val bytes: ByteArray)
37
38 // Cifra datos con AES-GCM y IV aleatorio generado por el Cipher.
39 fun encrypt(plain: ByteArray, key: SecretKey = getOrCreateKey()): Ciphertext {
40 val cipher = Cipher.getInstance(TRANSFORMATION)
41 cipher.init(Cipher.ENCRYPT_MODE, key)
42 val iv = cipher.iv
43 val enc = cipher.doFinal(plain)
44 return Ciphertext(iv, enc)
45 }
46
47 // Descifra datos con AES-GCM usando el IV asociado.
48 fun decrypt(ct: Ciphertext, key: SecretKey = getOrCreateKey()): ByteArray {
49 val cipher = Cipher.getInstance(TRANSFORMATION)
50 val spec = GCMParameterSpec(GCM_TAG_BITS, ct.iv)
51 cipher.init(Cipher.DECRYPT_MODE, key, spec)
52 return cipher.doFinal(ct.bytes)
53 }
54}El objeto CryptoManager proporciona métodos para generar o recuperar una clave AES almacenada en el Keystore de Android, así como para cifrar y descifrar datos utilizando AES-GCM.
getOrCreateKey(): obtiene o crea una clave AES en el Keystore.- La clase
Ciphertextencapsula el IV y los datos cifrados, necesarios para el descifrado. encrypt(plain: ByteArray): cifra un array de bytes y devuelve un objetoCiphertextque contiene el IV y los datos cifrados.decrypt(ct: Ciphertext): descifra los datos utilizando el IV almacenado en el objetoCiphertext.
Paso 2: Implementación del repositorio para almacenamiento seguro
A continuación, se crea la clase SecurePrefsRepository en el paquete data/repository. Esta clase utilizará DataStore para almacenar los datos cifrados.
1import android.content.Context
2import androidx.datastore.preferences.core.Preferences
3import androidx.datastore.preferences.core.edit
4import androidx.datastore.preferences.core.stringPreferencesKey
5import androidx.datastore.preferences.preferencesDataStore
6import kotlinx.coroutines.flow.Flow
7import kotlinx.coroutines.flow.map
8import android.util.Base64
9import es.javiercarrasco.documentationt9_1.data.security.CryptoManager
10
11private val Context.dataStore by preferencesDataStore(name = "secure_prefs")
12
13class SecurePrefsRepository(private val context: Context) {
14
15 // Claves para el DataStore.
16 private val KEY_IV = stringPreferencesKey("token_iv_b64")
17 private val KEY_CT = stringPreferencesKey("token_ct_b64")
18
19 // Guarda un token cifrado en DataStore (iv + ciphertext, ambos en Base64).
20 suspend fun saveToken(token: String) {
21 val ct = CryptoManager.encrypt(token.encodeToByteArray())
22 val ivB64 = Base64.encodeToString(ct.iv, Base64.NO_WRAP)
23 val dataB64 = Base64.encodeToString(ct.bytes, Base64.NO_WRAP)
24
25 // Almacena ambos en DataStore.
26 context.dataStore.edit { prefs ->
27 prefs[KEY_IV] = ivB64
28 prefs[KEY_CT] = dataB64
29 }
30 }
31
32 // Flujo que expone el token descifrado o null si no existe.
33 val tokenFlow: Flow<String?> = context.dataStore.data.map { prefs: Preferences ->
34 val ivB64 = prefs[KEY_IV]
35 val ctB64 = prefs[KEY_CT]
36 if (ivB64 == null || ctB64 == null) return@map null
37
38 val iv = Base64.decode(ivB64, Base64.NO_WRAP)
39 val data = Base64.decode(ctB64, Base64.NO_WRAP)
40 CryptoManager.decrypt(CryptoManager.Ciphertext(iv, data)).decodeToString()
41 }
42
43 // Flujo que expone el token cifrado en formato "iv|ciphertext" o null si no existe.
44 val encryptedFlow: Flow<String?> = context.dataStore.data.map { prefs ->
45 val iv = prefs[KEY_IV]
46 val ct = prefs[KEY_CT]
47 if (iv != null && ct != null) "$iv|$ct" else null
48 }
49
50 // Elimina el token almacenado.
51 suspend fun clearToken() {
52 context.dataStore.edit { it.remove(KEY_IV); it.remove(KEY_CT) }
53 }
54}Esta clase SecurePrefsRepository proporciona métodos para guardar, leer y eliminar un token de autenticación de forma segura utilizando DataStore y el CryptoManager para el cifrado.
saveToken(token: String): cifra el token y lo guarda enDataStoreen formato Base64.tokenFlow: un flujo que emite el token descifrado onullsi no existe.encryptedFlow: un flujo que emite el token cifrado en formato"iv|ciphertext"onullsi no existe.clearToken(): elimina el token almacenado.
Información
DataStore sustituye a SharedPreferences, es asíncrono y consistente (Flow + corutinas). Aquí se utiliza como contenedor del Ciphertext, DataStore no cifra por sí mismo.
Paso 3: Implementación del ViewModel
Se crea la clase SecurePrefsViewModel en el paquete ui, que se encargará de la interacción entre la UI y el repositorio.
1import androidx.lifecycle.ViewModel
2import androidx.lifecycle.viewModelScope
3import es.javiercarrasco.documentationt9_1.data.repository.SecurePrefsRepository
4import kotlinx.coroutines.flow.SharingStarted
5import kotlinx.coroutines.flow.StateFlow
6import kotlinx.coroutines.flow.stateIn
7import kotlinx.coroutines.launch
8
9class SecurePrefsViewModel(private val repo: SecurePrefsRepository) : ViewModel() {
10
11 // StateFlow para observar el token almacenado de forma segura.
12 val token: StateFlow<String?> = repo.tokenFlow
13 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
14
15 // StateFlow para observar el token cifrado (iv + ciphertext).
16 val encrypted: StateFlow<String?> = repo.encryptedFlow
17 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
18
19 // Función para guardar el token de forma segura.
20 fun saveToken(value: String) = viewModelScope.launch {
21 repo.saveToken(value)
22 }
23
24 // Función para borrar el token almacenado de forma segura.
25 fun clearToken() = viewModelScope.launch {
26 repo.clearToken()
27 }
28}El SecurePrefsViewModel expone un StateFlow para observar el token almacenado y proporciona métodos para guardar y eliminar el token de forma segura.
token: unStateFlowque emite el token actual onull.encrypted: unStateFlowque emite el token cifrado en formato"iv|ciphertext"onull.saveToken(value: String): guarda el token de forma segura.clearToken(): elimina el token almacenado.
Paso 4: Implementación de la UI
Finalmente, se crea una pantalla Compose SecurePrefsScreen en el paquete ui para interactuar con el usuario.
1@Composable
2fun SecurePrefsScreen(vm: SecurePrefsViewModel) {
3 val token by vm.token.collectAsState()
4 val encrypted by vm.encrypted.collectAsState()
5
6 var input by remember { mutableStateOf("") }
7 val isValid = input.isNotBlank()
8
9 Card {
10 Column(
11 Modifier
12 .fillMaxWidth()
13 .padding(16.dp),
14 verticalArrangement = Arrangement.spacedBy(8.dp)
15 ) {
16 Text(
17 text = "Almacenamiento seguro (Keystore + AES-GCM + DataStore)",
18 style = MaterialTheme.typography.titleSmall
19 )
20 OutlinedTextField(
21 modifier = Modifier.fillMaxWidth(),
22 value = input,
23 onValueChange = { input = it },
24 label = { Text("Token / API key") },
25 singleLine = true
26 )
27 Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
28 Button(
29 modifier = Modifier.fillMaxWidth(.5f),
30 enabled = isValid,
31 onClick = { vm.saveToken(input.trim()) }) { Text("Guardar seguro") }
32 OutlinedButton(
33 modifier = Modifier.fillMaxWidth(),
34 onClick = { vm.clearToken() }) { Text("Eliminar") }
35 }
36 HorizontalDivider()
37 Text(
38 "Valor actual (descifrado): ${token ?: "—"}",
39 style = MaterialTheme.typography.bodyMedium
40 )
41 Text(
42 "Valor actual (cifrado, Base64): ${encrypted ?: "—"}",
43 style = MaterialTheme.typography.bodySmall,
44 color = Color.Gray
45 )
46 }
47 }
48}Para llamar a esta pantalla desde una Activity, será necesario crear el ViewModel y el Repository, pasando el contexto:
1val repo = SecurePrefsRepository(this)
2val vm = SecurePrefsViewModel(repo)
3
4SecurePrefsScreen(vm)9.7. Buenas prácticas y eficiencia
- Guarda solo lo necesario.
- Utiliza caché para datos temporales.
- Elimina datos antiguos para liberar espacio.
- Cifra información sensible.
- Utiliza corrutinas para evitar bloqueos de la UI en operaciones I/O.