Local Storage: DataStore, Files, Scoped Storage and MediaStore

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

This page is about where TrailMate’s data physically lives on the device and which API reaches it: small key-value settings, arbitrary files the app owns, and files the user or other apps own. It does not cover TrailMate’s structured trail and photo data, which is modelled as SQL rows through Room — see Room Database and Paging for that. It also does not cover asking for the runtime permissions some of the APIs below still require on older targetSdk values; that is on Permissions, Privacy and Security. The baseline is DataStore 1.2.1, compileSdk = 37, targetSdk = 36, minSdk = 26.

Choosing a Storage Option

Android offers several storage mechanisms and they are not interchangeable; picking the wrong one is a common source of ANRs (blocking the main thread on disk I/O), data loss (an app-specific file surviving an uninstall when it should not, or not surviving one when it should) and Play Store rejections (writing to shared storage without scoped-storage APIs). TrailMate uses four of the rows below and the table records why:

Option Best for TrailMate example

Preferences DataStore

Small amounts of key-value settings, replacing SharedPreferences.

UserPreferences(units, onlyFavorites) via UserPreferencesRepository

Typed DataStore

A single structured object (not a bag of keys) that benefits from a real schema and type safety.

A LastSyncState proto-like object serialized with kotlinx.serialization

Room (see Room Database and Paging)

Structured, relational, queryable data.

TrailEntity, PhotoEntity

App-specific files (internal or external)

Files only the app itself needs: caches, temporary exports, logs.

A cached GPX track file per hike, in filesDir

MediaStore

Media (photos, video, audio) the user considers theirs and expects to survive the app’s uninstall, findable by the Photos/Gallery app.

A trail photo saved to Pictures/TrailMate

Storage Access Framework / photo picker

Reading or writing an arbitrary document the user picks, or a photo the user selects, without a storage permission.

Importing a GPX file exported by another app; letting the user attach an existing photo to a trail

EncryptedSharedPreferences (deprecated) / Keystore-backed encryption

Small amounts of sensitive key-value data (tokens, not passwords) that must be encrypted at rest.

An OAuth refresh token, discussed alongside Permissions, Privacy and Security

SharedPreferences (Legacy)

SharedPreferences is the original key-value store: an XML file per named preferences group, backed by an in-memory cache the framework loads once and keeps synchronized across every SharedPreferences instance for that name within the process. It predates coroutines, is not type-safe beyond String/Int/Boolean/Float/ Long/Set<String>, offers no transactional guarantees across keys, and its listener API (OnSharedPreferenceChangeListener) is callback-based rather than a Flow. Android’s guidance now recommends Preferences DataStore for new code, covered next; SharedPreferences is documented here because a lot of existing code — and the older TrailMate views-and-layouts/navigation-with-fragments snippets elsewhere in this section — still uses it, and because migrating away from it is itself a supported, common task:

// feature/settings/src/main/kotlin/com/example/trailmate/feature/settings/LegacySettings.kt
class LegacySettingsRepository(context: Context) {
    private val prefs = context.getSharedPreferences("trailmate_prefs", Context.MODE_PRIVATE)

    var onlyFavorites: Boolean
        get() = prefs.getBoolean(KEY_ONLY_FAVORITES, false)
        set(value) = prefs.edit { putBoolean(KEY_ONLY_FAVORITES, value) }

    companion object {
        private const val KEY_ONLY_FAVORITES = "only_favorites"
    }
}

Context.MODE_PRIVATE is the only mode that still works as documented; MODE_WORLD_READABLE and MODE_WORLD_WRITEABLE were removed because they let any other app on the device read or corrupt the file — inter-process sharing of preferences must go through a ContentProvider or a bound service instead.

apply() vs. commit()

SharedPreferences.Editor exposes two ways to persist a batch of changes, and picking the wrong one either blocks the UI thread or silently loses data on a process death that happens too soon after the call:

Method Behavior When to use

apply()

Updates the in-memory cache synchronously (so a read right after apply() sees the new value) and schedules the disk write asynchronously. Returns Unit, so failures are invisible to the caller.

The default choice for UI-triggered writes: a settings toggle, a "remember this choice" checkbox.

commit()

Writes to disk synchronously on the calling thread and returns a Boolean success flag.

Only when the caller must know the write finished and is on a background thread — e.g. a WorkManager worker that must not return before a value it wrote is durable.

// feature/settings/src/main/kotlin/com/example/trailmate/feature/settings/LegacySettings.kt (continued)
fun setOnlyFavoritesAsync(value: Boolean) {
    prefs.edit { putBoolean(KEY_ONLY_FAVORITES, value) } // apply() under the hood via KTX edit {}
}

fun setOnlyFavoritesBlocking(value: Boolean): Boolean =
    prefs.edit().putBoolean(KEY_ONLY_FAVORITES, value).commit()

Calling commit() from the main thread is a common source of jank and a lint warning (CommitPrefEdits/ApplySharedPref), because it performs synchronous file I/O on the thread that also has to draw the next frame. The androidx.core.content KTX edit { } builder used above defaults to apply().

Migrating with SharedPreferencesMigration

DataStore ships a ready-made migration so existing SharedPreferences data is not lost the first time an app switches a given preference group over. SharedPreferencesMigration runs on DataStore’s first access, copies the requested keys into the new Preferences object, and then cleans up after itself: each migrated key is removed from the old SharedPreferences file, and if the file is empty once the migration completes, the migration deletes the file too. The old file is therefore not a fallback — rolling the app back to a version that still reads SharedPreferences would find the migrated keys gone, so a release that might need reverting has to ship a reverse migration (DataStore back to SharedPreferences) rather than rely on the old file:

// core/data/src/main/kotlin/com/example/trailmate/core/data/local/UserPreferencesModule.kt
val Context.userPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(
    name = "user_preferences",
    produceMigrations = { context ->
        listOf(
            SharedPreferencesMigration(
                context = context,
                sharedPreferencesName = "trailmate_prefs",
                keysToMigrate = setOf("only_favorites"),
            ),
        )
    },
)

The cleanup behavior is documented on the migration class itself, including that a migrated key already present in DataStore is not migrated again. See Android Developers — SharedPreferencesMigration.

keysToMigrate should be restricted to the keys the new schema actually knows about; migrating everything silently carries forward keys nobody reads any more, which is the same "unused, undocumented preference key" problem SharedPreferences already had, just moved into DataStore.

Preferences DataStore

Preferences DataStore replaces SharedPreferences for key-value data. It stores the same conceptual shape — named keys mapped to primitive values — but every read is a cold Flow<Preferences> instead of a callback-based listener, every write goes through a suspend function that is guaranteed to complete (or throw) before it returns, and concurrent writes are serialized so two coroutines updating different keys at the same time never corrupt each other’s write, unlike two threads racing SharedPreferences.Editor instances:

// core/data/src/main/kotlin/com/example/trailmate/core/data/local/UserPreferencesRepository.kt
package com.example.trailmate.core.data.local

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.example.trailmate.core.model.Units
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException
import javax.inject.Inject

private val KEY_UNITS = stringPreferencesKey("units")
private val KEY_ONLY_FAVORITES = booleanPreferencesKey("only_favorites")

data class UserPreferences(val units: Units, val onlyFavorites: Boolean)

class UserPreferencesRepository @Inject constructor(
    private val dataStore: DataStore<Preferences>,
) {
    val userPreferences: Flow<UserPreferences> = dataStore.data
        .catch { exception ->
            if (exception is IOException) emit(androidx.datastore.preferences.core.emptyPreferences())
            else throw exception
        }
        .map { preferences ->
            UserPreferences(
                units = Units.valueOf(preferences[KEY_UNITS] ?: Units.METRIC.name),
                onlyFavorites = preferences[KEY_ONLY_FAVORITES] ?: false,
            )
        }

    suspend fun setUnits(units: Units) {
        dataStore.edit { it[KEY_UNITS] = units.name }
    }

    suspend fun toggleFavoriteFilter() {
        dataStore.edit { it[KEY_ONLY_FAVORITES] = !(it[KEY_ONLY_FAVORITES] ?: false) }
    }
}

dataStore.data throws IOException if reading the underlying file fails; the catch operator maps that one specific exception into emptyPreferences() and rethrows anything else, so a genuine bug is not silently swallowed alongside a real disk error. dataStore.edit { } receives a MutablePreferences transform block that runs atomically — the whole block succeeds or the whole write is discarded, so toggleFavoriteFilter can read the current value and write the new one without a separate read-modify-write race. TrailListViewModel on Recommended App Architecture combines this userPreferences flow with the trail list precisely because it is a Flow, not a one-shot read.

Preferences DataStore trades away one thing SharedPreferences never promised either, but that a real schema would give: keys are still untyped strings underneath (stringPreferencesKey("units") does not stop a typo stringPreferencesKey("unit") from silently creating a second, unrelated key), and there is no way to express nested structures. That is what typed DataStore is for.

Typed DataStore with a kotlinx.serialization Serializer

Typed DataStore (DataStore<T>) stores a single serialized object of an application-defined type instead of a loose preferences bag. Google’s own samples build it on top of protocol buffers, but any format works as long as it round-trips through a Serializer<T>; kotlinx.serialization is a lighter-weight choice for a Kotlin-only app like TrailMate that already uses it for network DTOs (see Networking with Retrofit, OkHttp and Ktor) and avoids adding the protobuf Gradle plugin and codegen step for one small object:

// core/data/src/main/kotlin/com/example/trailmate/core/data/local/LastSyncState.kt
package com.example.trailmate.core.data.local

import kotlinx.serialization.Serializable

@Serializable
data class LastSyncState(
    val lastSyncEpochMillis: Long = 0L,
    val lastSyncedRegion: String? = null,
)
// core/data/src/main/kotlin/com/example/trailmate/core/data/local/LastSyncStateSerializer.kt
package com.example.trailmate.core.data.local

import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import java.io.InputStream
import java.io.OutputStream

object LastSyncStateSerializer : Serializer<LastSyncState> {
    override val defaultValue: LastSyncState = LastSyncState()

    override suspend fun readFrom(input: InputStream): LastSyncState =
        try {
            Json.decodeFromString(
                LastSyncState.serializer(),
                input.readBytes().decodeToString(),
            )
        } catch (exception: SerializationException) {
            throw CorruptionException("Cannot read LastSyncState", exception)
        }

    override suspend fun writeTo(value: LastSyncState, output: OutputStream) {
        output.write(
            Json.encodeToString(LastSyncState.serializer(), value).encodeToByteArray(),
        )
    }
}
// core/data/src/main/kotlin/com/example/trailmate/core/data/local/LastSyncStateModule.kt
val Context.lastSyncStateDataStore: DataStore<LastSyncState> by dataStore(
    fileName = "last_sync_state.json",
    serializer = LastSyncStateSerializer,
)

readFrom must throw CorruptionException, never let a raw parsing exception escape, because that specific exception type is what tells DataStore to fall back to defaultValue (optionally after running a configured corruptionHandler) instead of leaving the app permanently unable to open the file. defaultValue doubles as the value returned the very first time the app runs, before anything has ever been written.

DataStore Singleton Rules and Multi-Process DataStore

Both preferencesDataStore() and dataStore() are property delegates that create one DataStore instance per file path for the lifetime of the process, the first time the property is accessed, and hand back that same instance on every later access — this is enforced by the delegate itself, not a convention the caller has to remember. Creating a second DataStore pointed at the same file (for example by calling DataStoreFactory.create directly in two different places instead of going through the delegate) throws an IllegalStateException at runtime, because two independent in-memory caches backed by the same file can each believe they hold the current value while silently overwriting each other’s writes on disk. In practice this means userPreferencesDataStore and lastSyncStateDataStore above must be top-level val`s (or provided as `@Singleton bindings from Hilt’s NetworkModule-style module, never created fresh inside a ViewModel or a Fragment), and every reader and writer — UserPreferencesRepository, a settings screen, a WorkManager worker — must obtain the instance through that same property or binding.

A DataStore instance is also not safe to open from more than one process at once by default: two processes each holding their own in-memory instance over the same file can still race each other the same way two ad-hoc instances in one process would. TrailMate itself is single-process, but an app that runs RecordHikeService (covered on Background Work: WorkManager, Services and Alarms) in a separate :tracking process to isolate a native crash from the main process would need MultiProcessDataStoreFactory.create instead of the single-process delegate, which coordinates access across processes with a file lock so only one process writes at a time:

// core/data/src/main/kotlin/com/example/trailmate/core/data/local/MultiProcessLastSyncState.kt
val lastSyncStateDataStore: DataStore<LastSyncState> = MultiProcessDataStoreFactory.create(
    serializer = LastSyncStateSerializer,
    produceFile = { File(context.filesDir, "datastore/last_sync_state.json") },
)

App-Specific Files: Internal and External Storage

Every app gets two directories it owns outright, with no storage permission required to read or write them, because the system enforces the boundary at the filesystem level rather than at a permission check:

Location Guarantees TrailMate example

Internal storage (context.filesDir, context.cacheDir)

Private to the app (not even readable by other apps on a non-rooted device); deleted when the app is uninstalled; filesDir content persists across runs, cacheDir content can be deleted by the system under storage pressure at any time.

A rendered offline map tile cache in cacheDir; an exported GPX file awaiting upload in filesDir

External, app-specific storage (context.getExternalFilesDir(null), context.externalCacheDir)

Lives on shared storage (falls back to internal storage on a device with no SD card) but inside a directory scoped to the app; still deleted on uninstall; readable via USB/MTP for debugging, unlike internal storage.

A large one-time downloaded offline region tileset too big to keep counted against the app’s internal quota

// core/data/src/main/kotlin/com/example/trailmate/core/data/local/GpxExporter.kt
fun exportGpxFile(context: Context, hikeId: String, gpxContent: String): File {
    val dir = File(context.filesDir, "gpx").apply { mkdirs() }
    val file = File(dir, "$hikeId.gpx")
    file.writeText(gpxContent)
    return file
}

Neither directory ever needs READ_EXTERNAL_STORAGE/WRITE_EXTERNAL_STORAGE at any API level; those permissions only ever governed access to other apps' or the user’s shared media, which is the scoped-storage boundary the next section covers. A file written under either directory is not visible to the Photos app or to another app’s file picker — for that, TrailMate has to use MediaStore or the Storage Access Framework.

Scoped Storage

Introduced in Android 10 (API 29) for apps targeting API 29, which could still opt out temporarily with android:requestLegacyExternalStorage="true", and enforced for every app that targets API 30 or higher when it runs on Android 11+ (the legacy flag is then ignored), scoped storage restricts direct filesystem access to shared storage: an app can no longer construct an arbitrary File("/storage/emulated/0/…​") path outside its own app-specific directories and expect to read or write it, even after being granted the legacy storage permissions. An app that still targets API 29 or lower keeps the legacy behavior; TrailMate, at targetSdk = 36, is always subject to it. Two APIs replace direct file paths for the cases that used to rely on them:

  • MediaStore for media the app itself creates and that belongs in the shared photo/video/audio collections — the API used to save a trail photo, below.

  • Storage Access Framework and the photo picker for a file or photo the user selects from anywhere on the device, including another app’s shared storage or cloud-backed storage — covered further down this page.

MANAGE_EXTERNAL_STORAGE, the "all files access" special permission that would bypass this, is reserved by Play policy for a narrow set of app categories (file managers, backup apps, antivirus) and requesting it without qualifying is a common cause of Play Console rejections; TrailMate never requests it.

MediaStore: Saving to Pictures/TrailMate

MediaStore is a system-managed ContentProvider that indexes every photo, video and audio file the device knows about, whether it came from the camera app, a download, or another app that inserted a row the way TrailMate does below. Inserting through MediaStore — rather than writing a file and asking the media scanner to notice it, the pre-scoped-storage pattern — is what makes a photo TrailMate saves show up immediately in the Photos app, survive TrailMate’s own uninstall, and remain accessible to other apps without any storage permission on API 29+:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/SaveTrailPhoto.kt
package com.example.trailmate.feature.trails

import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import java.io.OutputStream

fun saveTrailPhoto(context: Context, jpegBytes: ByteArray, displayName: String): Uri? {
    val values = ContentValues().apply {
        put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
        put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/TrailMate")
            put(MediaStore.Images.Media.IS_PENDING, 1)
        }
    }

    val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) // API 29+
    } else {
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI // API 26-28
    }
    val uri = context.contentResolver.insert(collection, values) ?: return null

    context.contentResolver.openOutputStream(uri)?.use { out: OutputStream -> out.write(jpegBytes) }

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        values.clear()
        values.put(MediaStore.Images.Media.IS_PENDING, 0)
        context.contentResolver.update(uri, values, null, null)
    }
    return uri
}

RELATIVE_PATH (API 29+) is what places the file under Pictures/TrailMate instead of the top-level Pictures directory; a minSdk 26 app that must also run on API 26-28 needs a MediaStore.Images.Media.DATA fallback path for those levels, since RELATIVE_PATH did not exist yet. The same applies to the collection URI: MediaStore.VOLUME_EXTERNAL_PRIMARY was also added in API 29, so the code above falls back to MediaStore.Images.Media.EXTERNAL_CONTENT_URI below it. On API 26-28 inserting into MediaStore also still needs the WRITE_EXTERNAL_STORAGE permission; declaring it with android:maxSdkVersion="28" keeps it from being requested on newer versions, where it is not needed. IS_PENDING marks the row as not-yet-visible to other apps (including the Photos app’s grid) while bytes are still being written, so nothing observes a truncated file; clearing it is the signal that the write is complete. contentResolver.openOutputStream(uri) — never a raw File — is the only way to write the bytes once insert() has handed back a content:// URI, because that URI does not correspond to a path the app is allowed to open directly.

Storage Access Framework

The Storage Access Framework (SAF) lets the app read or write an arbitrary document anywhere the system exposes a DocumentsProvider — shared storage, an SD card, or a cloud-backed provider such as Google Drive — without ever requesting a storage permission, because the user picks the exact file through a system UI the app does not control, and permission is scoped to only that file. TrailMate uses it to let a user import a GPX track recorded by another app:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/ImportGpx.kt
package com.example.trailmate.feature.trails

import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember

@Composable
fun rememberGpxImportLauncher(onGpxSelected: (android.net.Uri) -> Unit) =
    rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
        uri?.let(onGpxSelected)
    }

// Launched with: launcher.launch(arrayOf("application/gpx+xml"))

ActivityResultContracts.OpenDocument (read), CreateDocument (write a new file the user names and locates) and OpenDocumentTree (get access to a whole directory) wrap the underlying ACTION_OPEN_DOCUMENT, ACTION_CREATE_DOCUMENT and ACTION_OPEN_DOCUMENT_TREE intents. The Uri returned by OpenDocument grants access for the current app session only; if TrailMate needs to reopen the same GPX file on a later app launch — for a "recently imported" list — it must call context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) when it first receives the URI, otherwise the grant is revoked once the app process dies.

The Photo Picker

For the narrower, far more common case of letting the user attach an existing photo or video — as opposed to an arbitrary document of any type — the system photo picker (ActivityResultContracts.PickVisualMedia) is the recommended API: it shows the same system media-picker UI as the SAF file picker, but is backed directly by the media library, requires no storage permission at any API level (it ships as a Google Play system component, backported down to API 21 through Google Play services, so it works even on minSdk 26 without an SDK-version branch), and returns a URI scoped to only the selected item:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/PhotoCaptureScreen.kt
package com.example.trailmate.feature.trails

import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable

@Composable
fun rememberTrailPhotoPickerLauncher(onPhotoSelected: (android.net.Uri) -> Unit) =
    rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
        uri?.let(onPhotoSelected)
    }

// Launched with:
// launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))

Choosing between the photo picker and ACTION_OPEN_DOCUMENT with an image/* MIME filter comes down to intent: the photo picker is purpose-built for "pick a photo or video" and should be preferred whenever that is the whole requirement, while SAF’s OpenDocument is the right choice for non-media documents (a GPX file above) or when the app needs to browse an arbitrary directory tree via OpenDocumentTree.

Sharing Files with FileProvider

Handing a file TrailMate owns — a captured hike photo, before it has been inserted into MediaStore — to another app (the system share sheet, a mapping app) needs one more piece: a raw file:// URI to an app-specific file is not readable by another app since Android 7.0 (API 24), and androidx.core.content.FileProvider is the ContentProvider that turns such a file into a short-lived, permission-scoped content:// URI instead. FileProvider’s manifest declaration, `file_paths.xml resource and the getUriForFile/ACTION_SEND code TrailMate uses for PhotoCaptureScreen are covered in full, alongside the rest of the manifest’s <provider> element, on App Components, the Manifest and Intents — this page only places it in context: a photo TrailMate wants to keep and show in its own gallery goes through MediaStore above, while a photo TrailMate wants to hand to another app for a single action goes through FileProvider.

Auto Backup and dataExtractionRules

Android automatically backs up an app’s data — shared preferences, DataStore files, the Room database file under filesDir, and app-specific external files — to the user’s Google Drive account (device-to-device transfer uses the same mechanism locally, without Drive) so a freshly set-up device or a reinstall restores it without the user noticing anything was ever missing. This is opt-out, not opt-in: an app gets it for free unless it declares otherwise, which makes the exclusion rules the part that actually needs deliberate configuration. Since Android 12 (API 31), dataExtractionRules in an XML resource replaces the older fullBackupContent attribute and separately controls cloud backup and device-to-device transfer. fullBackupContent is still read on Android 11 (API 30) and lower, so a minSdk 26 app declares both — but each points at its own file, because the two use different XML schemas (<data-extraction-rules> versus <full-backup-content>):

<!-- app/src/main/res/xml/data_extraction_rules.xml: Android 12 (API 31) and higher -->
<data-extraction-rules>
    <cloud-backup>
        <exclude domain="sharedpref" path="trailmate_prefs.xml" />
        <exclude domain="file" path="gpx/" />
    </cloud-backup>
    <device-transfer>
        <include domain="file" path="gpx/" />
    </device-transfer>
</data-extraction-rules>
<!-- app/src/main/res/xml/backup_rules.xml: Android 11 (API 30) and lower -->
<full-backup-content>
    <exclude domain="sharedpref" path="trailmate_prefs.xml" />
    <exclude domain="file" path="gpx/" />
</full-backup-content>

The older schema has no separate device-transfer section, so its rules apply to both cloud backup and transfer. The manifest then points each attribute at its own file:

<!-- app/src/main/AndroidManifest.xml (excerpt) -->
<application
    android:dataExtractionRules="@xml/data_extraction_rules"
    android:fullBackupContent="@xml/backup_rules"
    ...>
</application>

TrailMate excludes the legacy trailmate_prefs.xml from cloud backup because it is superseded by the DataStore migration shown earlier and would otherwise restore stale duplicate data, and excludes cached GPX exports from cloud backup (they can be regenerated) while still allowing them in a same-owner device-transfer, which uses a faster, locally encrypted channel rather than Drive. Anything holding an access token or other credential — covered next — should be excluded from both, because a backup is one more place such a value can leak from.

Encrypting Local Data: EncryptedSharedPreferences (Deprecated) and the Keystore

androidx.security.crypto.EncryptedSharedPreferences used to be the recommended way to store a small amount of sensitive key-value data — an OAuth refresh token, not a user’s password, which should never be stored on-device at all — encrypted at rest with a key held in the Android Keystore rather than in application code. The androidx.security:security-crypto artifact that provided it is now deprecated with no drop-in AndroidX replacement; current guidance is to implement the same idea directly against javax.crypto.Cipher and a Keystore-backed SecretKey, wrapping ordinary SharedPreferences (or a DataStore Serializer) values:

// core/data/src/main/kotlin/com/example/trailmate/core/data/local/SecureTokenStore.kt
package com.example.trailmate.core.data.local

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.spec.GCMParameterSpec

private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val KEY_ALIAS = "trailmate_refresh_token_key"

class SecureTokenStore {
    private val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }

    private fun getOrCreateKey() =
        (keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey
            ?: KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE).apply {
                init(
                    KeyGenParameterSpec.Builder(
                        KEY_ALIAS,
                        KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
                    )
                        .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
                        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
                        .build(),
                )
            }.generateKey()

    fun encrypt(plaintext: ByteArray): Pair<ByteArray, ByteArray> {
        val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
            init(Cipher.ENCRYPT_MODE, getOrCreateKey())
        }
        return cipher.doFinal(plaintext) to cipher.iv
    }

    fun decrypt(ciphertext: ByteArray, iv: ByteArray): ByteArray {
        val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
            init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(128, iv))
        }
        return cipher.doFinal(ciphertext)
    }
}

See Android Developers — Android Keystore system and Android Developers — Cryptography. Google’s Tink library (https://github.com/google/tink) wraps the same Keystore-backed primitives behind a higher-level, harder-to-misuse API and is worth evaluating instead of hand-rolled Cipher code for anything beyond a single token.

The Keystore’s guarantee is that the raw key material in getOrCreateKey() never leaves secure hardware (a Trusted Execution Environment or a StrongBox-backed secure element, where the device has one) — application code only ever holds a handle to operate the key through Cipher, never the key’s bytes, which is what makes this meaningfully stronger than XOR-ing a value against a constant baked into the APK. The encrypted bytes and the iv (initialization vector, which must be stored alongside the ciphertext and does not itself need to be secret) are then persisted through ordinary SharedPreferences or DataStore; only the plaintext token in memory, and the key itself in hardware, need protecting. RECORD_HIKE-style location and other runtime permissions, and threat modelling for what deserves this treatment at all, are covered on Permissions, Privacy and Security.

Raw SQLite (Legacy)

Before Room existed, structured local data was managed directly against the platform’s SQLiteOpenHelper: subclassing it to define onCreate/onUpgrade, hand-writing CREATE TABLE statements, and querying through SQLiteDatabase.rawQuery/query with manually mapped Cursor columns. Older tutorials and pre-Room codebases still use this pattern directly:

// legacy/TrailDbHelper.kt (shown for historical context only -- not used in current TrailMate code)
class TrailDbHelper(context: Context) :
    SQLiteOpenHelper(context, "trailmate.db", null, 1) {

    override fun onCreate(db: SQLiteDatabase) {
        db.execSQL(
            "CREATE TABLE trail (id TEXT PRIMARY KEY, name TEXT NOT NULL, is_favorite INTEGER NOT NULL)",
        )
    }

    override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
        db.execSQL("DROP TABLE IF EXISTS trail")
        onCreate(db)
    }
}

Room is a compile-time-checked wrapper over exactly this same SQLite engine — it generates the SQLiteOpenHelper subclass, the CREATE TABLE statements and the Cursor-to-object mapping that used to be hand-written above, while validating @Query strings against the schema at compile time and integrating directly with Flow/Paging. Current TrailMate code, and any new Android code, should reach for Room rather than SQLiteOpenHelper directly; see Room Database and Paging for TrailEntity, TrailDao and the migration story Room provides in place of hand-written onUpgrade logic.