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 |
|
Typed DataStore |
A single structured object (not a bag of keys) that benefits from a real schema and type safety. |
A |
Room (see Room Database and Paging) |
Structured, relational, queryable data. |
|
App-specific files (internal or external) |
Files only the app itself needs: caches, temporary exports, logs. |
A cached GPX track file per hike, in |
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 |
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 |
|---|---|---|
|
Updates the in-memory cache synchronously (so a read right after |
The default choice for UI-triggered writes: a settings toggle, a "remember this choice" checkbox. |
|
Writes to disk synchronously on the calling thread and returns a |
Only when the caller must know the write finished and is on a background thread — e.g. a |
// 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,
)
See Android Developers — Create a DataStore with kotlinx.serialization and
Android Developers — Serializer.
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 ( |
Private to the app (not even readable by other apps on a non-rooted device); deleted when the app is
uninstalled; |
A rendered offline map tile cache in |
External, app-specific storage ( |
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
}
See Android Developers — Save and read files in internal storage (app-specific) and Android Developers — Access app-specific files on external storage.
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:
-
MediaStorefor 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"))
See Android Developers — Access
documents and other files from shared storage and
Android Developers — ActivityResultContracts.OpenDocument.
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))
See Android Developers — Photo picker
and
Android Developers — ActivityResultContracts.PickVisualMedia.
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>
See Android Developers — Back up user data with Auto
Backup and
Android Developers — The
dataExtractionRules XML.
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.
References
-
Android Developers — Migrate from SharedPreferences to Preferences DataStore
-
Android Developers — Include and exclude files (Android 11 and lower)
-
Android Developers — Create a DataStore with kotlinx.serialization
-
Android Developers — Save and read files in internal storage (app-specific)
-
Android Developers — Access documents and other files from shared storage