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 SharedPreferences y DataStore.
  • 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 Ciphertext encapsula el IV y los datos cifrados, necesarios para el descifrado.
  • encrypt(plain: ByteArray): cifra un array de bytes y devuelve un objeto Ciphertext que contiene el IV y los datos cifrados.
  • decrypt(ct: Ciphertext): descifra los datos utilizando el IV almacenado en el objeto Ciphertext.

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 en DataStore en formato Base64.
  • tokenFlow: un flujo que emite el token descifrado o null si no existe.
  • encryptedFlow: un flujo que emite el token cifrado en formato "iv|ciphertext" o null si 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: un StateFlow que emite el token actual o null.
  • encrypted: un StateFlow que emite el token cifrado en formato "iv|ciphertext" o null.
  • 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.

Código completo

Fuentes