Skip to main content

18 - Room 3 Database & Files

Recap​

17 - Sensors & Compass completed the live part of SportMap: map, track, checkpoints, statistics and compass, fed by TrackingRepository while TrackingService runs. Since 15 - Foreground Service, the service has also written every session start, accepted fix and checkpoint through SessionRepository, but so far that was only an interface. This lecture implements it with Room 3, so sessions survive a stop, a process kill and a reboot. It then builds what the final project wants on top: a history of old sessions that can be viewed, renamed, recoloured and deleted, and a GPX export that can be mailed to the teacher, using plain files, a FileProvider and the Storage Access Framework.

By the end of this lecture you should be able to:

  • Set up Room 3 with KSP, the Room Gradle plugin and an Android SQLite driver.
  • Model GpsSessionEntity and GpsLocationEntity with a foreign key, an index and type converters.
  • Write a DAO with suspend and Flow functions, and read a one-to-many relation.
  • Create the database once with Hilt, use transactions, and evolve the schema with migrations.
  • Hide Room behind SessionRepository and build the session history screens on top of it.
  • Recognise Room 2.x code (tutorials, AI output) and translate it to Room 3.
  • Write a GPX 1.1 file, share it through a FileProvider, or save it where the user chooses.
Demo code

Lecture demos: native-2026-fall

Where data lives​

DataStorageLecture
settings, statistics, the JWT tokenDataStore Preferences10, 19
sessions, locations, checkpoints (structured, queried, growing)Room (SQLite)this one
GPX exports, photos (blobs, files for other apps)files in app storagethis one
the running sessionmemory (TrackingRepository)10, 13

SQLite is a full relational database that ships with Android, one file per database inside the app's private storage. Writing raw SQLite (SQLiteOpenHelper, Cursor, ContentValues) is possible but verbose and unchecked. Room is the official layer on top: you describe tables as Kotlin classes, queries as annotated functions, and the compiler checks every SQL statement against the schema at build time.

Setup​

Room 3 is a new major version under new coordinates (androidx.room3). It is Kotlin-first, coroutine-first and generates Kotlin code with KSP only.

# gradle/libs.versions.toml
[libraries]
androidx-room3-runtime = { group = "androidx.room3", name = "room3-runtime", version.ref = "room3" }
androidx-room3-compiler = { group = "androidx.room3", name = "room3-compiler", version.ref = "room3" }

[plugins]
room3 = { id = "androidx.room3", version.ref = "room3" }
// build.gradle.kts (top level) - add
alias(libs.plugins.room3) apply false

// app/build.gradle.kts
plugins {
// ... android.application, kotlin.compose, ksp, hilt
alias(libs.plugins.room3)
}

room3 {
schemaDirectory("$projectDir/schemas") // exported schema JSON, commit it to git
}

dependencies {
implementation(libs.androidx.room3.runtime)
ksp(libs.androidx.room3.compiler)
}

room3-runtime brings the Android framework driver (AndroidSQLiteDriver) with it, so nothing else is needed.

Entities​

An entity is a table. Each property is a column, and one property is the primary key.

@Entity(tableName = "gps_session")
data class GpsSessionEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val startedAt: Instant,
val endedAt: Instant? = null,
val distanceM: Double = 0.0,
val durationMs: Long = 0,
val paceMinSecPerKm: Int? = null, // per-session colour range, null = use Settings
val paceMaxSecPerKm: Int? = null,
val backendId: String? = null, // id of the session on sportmap.akaver.com (lecture 19)
)

@Entity(
tableName = "gps_location",
foreignKeys = [
ForeignKey(
entity = GpsSessionEntity::class,
parentColumns = ["id"],
childColumns = ["sessionId"],
onDelete = ForeignKey.CASCADE, // deleting a session deletes its locations
)
],
indices = [Index("sessionId")], // every query filters by session
)
data class GpsLocationEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val sessionId: Long,
val type: LocationType, // LOC, WP, CP - stored as text "LOC"/"WP"/"CP"
val latitude: Double,
val longitude: Double,
val accuracy: Float,
val altitude: Double?,
val verticalAccuracy: Float?,
val recordedAt: Instant,
val synced: Boolean = false, // uploaded to the backend? (lecture 19)
)
  • autoGenerate = true with a default of 0 lets SQLite assign the id on insert. The @Insert function returns it.
  • Nullable Kotlin types become nullable columns, and non-null types become NOT NULL.
  • Enums are stored as their name (TEXT) without any extra code.
  • A foreign key makes SQLite enforce that every location belongs to an existing session. CASCADE removes the locations together with their session. Room switches SQLite's foreign key checks on for you.
  • Index foreign key columns. Without the index, loading one session's locations scans the whole table, and Room warns about it at build time.
  • Override names with @ColumnInfo(name = "first_name") and exclude a property with @Ignore.

Keep entities flat and dumb: they describe the table, nothing else. The UI and the domain logic use the domain classes GpsSession, GpsLocation and SessionDetail (below).

Type converters​

SQLite has five storage types: INTEGER, REAL, TEXT, BLOB and NULL. Room maps primitives, String, ByteArray and enums itself. For anything else, write a converter pair:

class Converters {
@ColumnTypeConverter
fun instantToMillis(value: Instant?): Long? = value?.toEpochMilli()

@ColumnTypeConverter
fun millisToInstant(value: Long?): Instant? = value?.let(Instant::ofEpochMilli)
}

Registered on the database with @ColumnTypeConverters(Converters::class) (below). Epoch milliseconds sort and compare correctly in SQL (ORDER BY startedAt DESC), which ISO strings in different time zones wouldn't.

DAO​

A DAO (data access object) is an interface whose functions are database operations. Room generates the implementation.

@Dao
interface SessionDao {
@Query("SELECT * FROM gps_session ORDER BY startedAt DESC")
fun observeSessions(): Flow<List<GpsSessionEntity>>

@Transaction
@Query("SELECT * FROM gps_session WHERE id = :id")
fun observeSessionWithLocations(id: Long): Flow<SessionWithLocations?>

@Query("SELECT * FROM gps_session WHERE id = :id")
suspend fun getSession(id: Long): GpsSessionEntity?

@Transaction
@Query("SELECT * FROM gps_session WHERE endedAt IS NULL ORDER BY startedAt DESC LIMIT 1")
suspend fun getUnfinishedSession(): SessionWithLocations?

@Insert
suspend fun insertSession(session: GpsSessionEntity): Long

@Insert
suspend fun insertLocation(location: GpsLocationEntity): Long

@Query("UPDATE gps_session SET name = :name WHERE id = :id")
suspend fun rename(id: Long, name: String)

@Query("UPDATE gps_session SET paceMinSecPerKm = :paceMin, paceMaxSecPerKm = :paceMax WHERE id = :id")
suspend fun setPaceRange(id: Long, paceMin: Int?, paceMax: Int?)

@Query("UPDATE gps_session SET endedAt = :endedAt, distanceM = :distanceM, durationMs = :durationMs WHERE id = :id")
suspend fun finish(id: Long, endedAt: Instant, distanceM: Double, durationMs: Long)

@Query("DELETE FROM gps_session WHERE id = :id")
suspend fun delete(id: Long)

// used by the sync in lecture 19
@Query("SELECT DISTINCT sessionId FROM gps_location WHERE synced = 0")
suspend fun sessionIdsWithUnsyncedLocations(): List<Long>

@Query("SELECT * FROM gps_location WHERE sessionId = :sessionId AND synced = 0 ORDER BY recordedAt LIMIT :limit")
suspend fun unsyncedLocations(sessionId: Long, limit: Int): List<GpsLocationEntity>

@Query("UPDATE gps_location SET synced = 1 WHERE id IN (:ids)")
suspend fun markSynced(ids: List<Long>)

@Query("UPDATE gps_session SET backendId = :backendId WHERE id = :id")
suspend fun setBackendId(id: Long, backendId: String)
}
AnnotationDoes
@InsertINSERT, returns the new row id (Long) or ids (List<Long>)
@Update, @Deleteupdate/delete by primary key, returns the number of rows
@Upsertinsert, or update when the primary key already exists
@Query("...")any SQL. :name binds a parameter, and a List parameter expands for IN (:ids)

Every DAO function in Room 3 must be either suspend or return a reactive type (Flow). There are no blocking database calls, so you can't accidentally freeze the UI. allowMainThreadQueries() still exists on the builder for code that wraps DAO calls in runBlocking. Don't write such code.

  • suspend is for one-shot operations: insert, update, delete, or read once.
  • Flow is for observation. Room re-runs the query and emits a new list every time a table it reads changes. The sessions screen never refreshes manually: delete a session and the list updates itself.

A typo in a column name, a wrong return type or a parameter missing from the SQL fails the build, not the app at runtime. That's Room's biggest win over raw SQLite.

Relations​

A session has many locations. Room assembles that one-to-many relation from two queries if you describe it with @Embedded and @Relation:

data class SessionWithLocations(
@Embedded val session: GpsSessionEntity,
@Relation(parentColumns = ["id"], entityColumns = ["sessionId"])
val locations: List<GpsLocationEntity>,
)

The DAO function returning it is marked @Transaction (see observeSessionWithLocations above). Room runs the session query and the locations query in one transaction, so you never see a session with half of a concurrently written track. @Relation makes no promise about the order of the child list, so sort the locations by recordedAt when mapping.

For many-to-many there is @Relation(..., associateBy = Junction(...)). SportMap doesn't need it.

The database​

@Database(
entities = [GpsSessionEntity::class, GpsLocationEntity::class],
version = 1,
exportSchema = true,
)
@ColumnTypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun sessionDao(): SessionDao
}

Build it once per process. Two instances on the same file waste memory and break Flow invalidation between them. With Hilt:

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder<AppDatabase>(context, "sportmap.db")
.setDriver(AndroidSQLiteDriver())
.build()

@Provides
fun provideSessionDao(db: AppDatabase): SessionDao = db.sessionDao()
}

Room 3 talks to SQLite through a driver:

DriverArtifactSQLite used
AndroidSQLiteDrivercomes with room3-runtimethe device's built-in SQLite, the version depends on the Android version
BundledSQLiteDriverandroidx.sqlite:sqlite-bundleda SQLite compiled into your app: the same version everywhere, about 1 MB larger APK

For an Android-only app, AndroidSQLiteDriver is the sensible default. Android Studio's Database Inspector works with databases opened through the framework SQLite. The bundled driver matters for Kotlin Multiplatform (iOS, desktop, web), which is what Room 3 was rebuilt for.

Transactions​

Several writes that must succeed or fail together go into one transaction:

suspend fun importSession(db: AppDatabase, dao: SessionDao, locations: List<GpsLocation>) {
db.withWriteTransaction {
val id = dao.insertSession(GpsSessionEntity(name = "Imported", startedAt = Instant.now()))
locations.forEach { dao.insertLocation(it.toEntity(id)) }
}
}

Inserting thousands of rows in one transaction is also dramatically faster than thousands of separate auto-committed inserts. withReadTransaction gives a consistent snapshot for several reads.

Migrations​

Change an entity, for example by adding a description column, and the schema no longer matches the file on users' devices. Room refuses to open such a database. You bump the version and tell Room how to get from the old schema to the new one.

exportSchema = true plus the room3 { schemaDirectory(...) } block makes Room write schemas/.../1.json, 2.json, and so on at build time. With those files it can generate simple migrations itself:

@Entity(tableName = "gps_session")
data class GpsSessionEntity(
// ... as before
val description: String? = null, // new in version 2
)

@Database(
entities = [GpsSessionEntity::class, GpsLocationEntity::class],
version = 2,
exportSchema = true,
autoMigrations = [AutoMigration(from = 1, to = 2)],
)
@ColumnTypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { /* ... */ }

Auto-migrations handle added tables and columns. Renames and deletes need a hint (spec = ... with @RenameColumn, @DeleteColumn and friends). For anything else, write the SQL yourself:

val MIGRATION_2_3 = object : Migration(2, 3) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE gps_session ADD COLUMN sessionTypeId TEXT")
}
}

Room.databaseBuilder<AppDatabase>(context, "sportmap.db")
.setDriver(AndroidSQLiteDriver())
.addMigrations(MIGRATION_2_3)
.build()

(execSQL is androidx.sqlite.execSQL, and Migration is androidx.room3.migration.Migration.)

During development

While the schema changes daily and nobody has real data yet, .fallbackToDestructiveMigration(dropAllTables = true) simply deletes and recreates the database when the version changes. Remove it before the field test. A 2-hour forest session lost to a schema bump makes for a bad defence.

Repository​

The rest of the app sees only the SessionRepository interface and the domain classes from the course contract:

// domain
data class GpsSession(
val id: Long,
val name: String,
val startedAt: Instant,
val endedAt: Instant?,
val distanceM: Double,
val durationMs: Long,
val paceMinSecPerKm: Int?,
val paceMaxSecPerKm: Int?,
val backendId: String?,
)

data class SessionDetail(val session: GpsSession, val locations: List<GpsLocation>)

interface SessionRepository {
fun sessions(): Flow<List<GpsSession>>
fun sessionDetail(id: Long): Flow<SessionDetail?>
suspend fun startSession(name: String, startedAt: Instant): Long
suspend fun addLocation(sessionId: Long, location: GpsLocation)
suspend fun finishSession(sessionId: Long, endedAt: Instant, overall: SectionStats)
suspend fun rename(id: Long, name: String)
suspend fun setPaceRange(id: Long, paceMinSecPerKm: Int?, paceMaxSecPerKm: Int?) // null = use Settings
suspend fun delete(id: Long)
suspend fun unfinishedSession(): SessionDetail? // endedAt == null, see "Unfinished sessions"
}

Mapping functions keep Room types inside the data layer:

fun GpsSessionEntity.toDomain() = GpsSession(
id = id, name = name, startedAt = startedAt, endedAt = endedAt,
distanceM = distanceM, durationMs = durationMs,
paceMinSecPerKm = paceMinSecPerKm, paceMaxSecPerKm = paceMaxSecPerKm, backendId = backendId,
)

fun GpsLocationEntity.toDomain() = GpsLocation(
latitude = latitude, longitude = longitude, accuracy = accuracy, altitude = altitude,
verticalAccuracy = verticalAccuracy, recordedAt = recordedAt, type = type,
)

fun GpsLocation.toEntity(sessionId: Long) = GpsLocationEntity(
sessionId = sessionId, type = type, latitude = latitude, longitude = longitude,
accuracy = accuracy, altitude = altitude, verticalAccuracy = verticalAccuracy, recordedAt = recordedAt,
)
fun SessionWithLocations.toDomain() = SessionDetail(
session = session.toDomain(),
locations = locations.sortedBy { it.recordedAt }.map { it.toDomain() },
)

class RoomSessionRepository @Inject constructor(
private val dao: SessionDao,
) : SessionRepository {

override fun sessions(): Flow<List<GpsSession>> =
dao.observeSessions().map { list -> list.map { it.toDomain() } }

override fun sessionDetail(id: Long): Flow<SessionDetail?> =
dao.observeSessionWithLocations(id).map { it?.toDomain() }

override suspend fun unfinishedSession(): SessionDetail? =
dao.getUnfinishedSession()?.toDomain()

override suspend fun startSession(name: String, startedAt: Instant): Long =
dao.insertSession(GpsSessionEntity(name = name, startedAt = startedAt))

override suspend fun addLocation(sessionId: Long, location: GpsLocation) {
dao.insertLocation(location.toEntity(sessionId))
}

override suspend fun finishSession(sessionId: Long, endedAt: Instant, overall: SectionStats) {
dao.finish(sessionId, endedAt, overall.distanceM, overall.durationMs)
}

override suspend fun rename(id: Long, name: String) = dao.rename(id, name)

override suspend fun setPaceRange(id: Long, paceMinSecPerKm: Int?, paceMaxSecPerKm: Int?) =
dao.setPaceRange(id, paceMinSecPerKm, paceMaxSecPerKm)

override suspend fun delete(id: Long) = dao.delete(id)
}

@Module
@InstallIn(SingletonComponent::class)
abstract class SessionRepositoryModule {
@Binds
@Singleton
abstract fun bindSessionRepository(impl: RoomSessionRepository): SessionRepository
}

Who writes what:

  • TrackingService (lecture 15) calls startSession when tracking starts, addLocation for every accepted location, every CP and every WP (stored as a type = WP row, see Waypoints in the database), and finishSession(id, endedAt, overall) on stop.
  • The history screens read sessions() and sessionDetail(id) and call rename, setPaceRange and delete.
  • The start screen asks for unfinishedSession() once (see Unfinished sessions).
  • The sync (lecture 19) uses the DAO's unsynced queries directly. It's part of the data layer, too.
Saving every location immediately

A 3-hour session at one fix per second is about 11 000 rows. That's tiny for SQLite. Insert each location as it arrives instead of keeping the track in memory and writing it at the end: if the process dies at kilometre 12, the first 12 km are safe.

Session history UI​

The list:

@HiltViewModel
class SessionsViewModel @Inject constructor(
sessionRepository: SessionRepository,
) : ViewModel() {
val sessions: StateFlow<List<GpsSession>> = sessionRepository.sessions()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}

private val dateFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
.withZone(ZoneId.systemDefault())

@Composable
fun SessionsRoute(onSessionClick: (Long) -> Unit, viewModel: SessionsViewModel = hiltViewModel()) {
val sessions by viewModel.sessions.collectAsStateWithLifecycle()
SessionsScreen(sessions = sessions, onSessionClick = onSessionClick)
}

@Composable
fun SessionsScreen(sessions: List<GpsSession>, onSessionClick: (Long) -> Unit, modifier: Modifier = Modifier) {
if (sessions.isEmpty()) {
Box(modifier.fillMaxSize().padding(16.dp)) { Text("No sessions yet") }
return
}
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(sessions, key = { it.id }) { session ->
Card(onClick = { onSessionClick(session.id) }, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(session.name, style = MaterialTheme.typography.titleMedium)
Text(
"${dateFormat.format(session.startedAt)} · ${formatDistance(session.distanceM)} · " +
formatDuration(session.durationMs),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}

The newest session is first because the query says ORDER BY startedAt DESC, so sort in SQL, not in Kotlin. key = { it.id } keeps rows stable when one is deleted. The clickable Card overload (lecture 07) handles ripple and accessibility.

SessionDetailViewModel gets the session id from SessionDetailKey through assisted injection, exactly as in 11 - Navigation 3. It also reads Settings for the default pace range, and it models the deletion as state, following the one-off-event rule from lecture 10:

data class SessionDetailUiState(
val detail: SessionDetail? = null,
val defaults: Settings = Settings(), // global pace range, used when the session has none
val deleted: Boolean = false,
)

@HiltViewModel(assistedFactory = SessionDetailViewModel.Factory::class)
class SessionDetailViewModel @AssistedInject constructor(
@Assisted private val sessionId: Long,
private val sessionRepository: SessionRepository,
settingsRepository: SettingsRepository,
) : ViewModel() {

@AssistedFactory
interface Factory {
fun create(sessionId: Long): SessionDetailViewModel
}

private val deleted = MutableStateFlow(false)

val uiState: StateFlow<SessionDetailUiState> = combine(
sessionRepository.sessionDetail(sessionId),
settingsRepository.settings,
deleted,
) { detail, settings, isDeleted -> SessionDetailUiState(detail, settings, isDeleted) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SessionDetailUiState())

fun onRename(name: String) {
viewModelScope.launch { sessionRepository.rename(sessionId, name.trim()) }
}

fun onPaceRangeChange(minSecPerKm: Int?, maxSecPerKm: Int?) {
viewModelScope.launch { sessionRepository.setPaceRange(sessionId, minSecPerKm, maxSecPerKm) }
}

fun onDelete() {
viewModelScope.launch {
sessionRepository.delete(sessionId)
deleted.value = true
}
}
}

@Composable
fun SessionDetailRoute(sessionId: Long, onDeleted: () -> Unit) {
val viewModel = hiltViewModel<SessionDetailViewModel, SessionDetailViewModel.Factory>(
creationCallback = { factory -> factory.create(sessionId) }
)
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(state.deleted) { if (state.deleted) onDeleted() } // navigate back once
state.detail?.let { detail ->
SessionDetailScreen(
detail = detail,
defaults = state.defaults,
onRename = viewModel::onRename,
onPaceRangeChange = viewModel::onPaceRangeChange,
onDelete = viewModel::onDelete,
)
}
}

Statistics​

"Display statistics" means more than distance and duration. Everything else can be derived from the saved rows with the lecture 13 helpers:

/** Statistics of a finished session, computed from its saved rows. */
fun SessionDetail.stats(): SectionStats {
val track = locations.filter { it.type == LocationType.LOC }
val direct = if (track.size < 2) 0.0 else TrackMath.distanceM(track.first(), track.last())
return SectionStats(distanceM = session.distanceM, directM = direct, durationMs = session.durationMs)
}

@Composable
fun StatRow(label: String, value: String) {
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(label, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
Text(value, style = MaterialTheme.typography.titleSmall)
}
}

Reusing SectionStats gives you paceSecPerKm for free, computed the same way as during the live session.

The detail screen​

Rename and delete go through dialogs. Delete needs a confirmation, because it can't be undone:

@Composable
fun SessionDetailScreen(
detail: SessionDetail,
defaults: Settings,
onRename: (String) -> Unit,
onPaceRangeChange: (Int?, Int?) -> Unit,
onDelete: () -> Unit,
) {
var showRename by rememberSaveable { mutableStateOf(false) }
var showDelete by rememberSaveable { mutableStateOf(false) }
val stats = remember(detail) { detail.stats() }
val checkpoints = remember(detail) { detail.locations.count { it.type == LocationType.CP } }

Column(Modifier.padding(16.dp).verticalScroll(rememberScrollState())) {
Text(detail.session.name, style = MaterialTheme.typography.headlineSmall)
// the track on a map: lecture 16 (zoom to bounds, pace colours)
StatRow("Distance", formatDistance(stats.distanceM))
StatRow("Duration", formatDuration(stats.durationMs))
StatRow("Average pace", "${formatPace(stats.paceSecPerKm)} min/km")
StatRow("Start to end, direct", formatDistance(stats.directM))
StatRow("Checkpoints", checkpoints.toString())
Spacer(Modifier.height(16.dp))
PaceRangeEditor(session = detail.session, defaults = defaults, onChange = onPaceRangeChange)
Row {
TextButton(onClick = { showRename = true }) { Text("Rename") }
TextButton(onClick = { showDelete = true }) { Text("Delete") }
}
}

if (showRename) {
RenameSessionDialog(
currentName = detail.session.name,
onConfirm = { onRename(it); showRename = false },
onDismiss = { showRename = false },
)
}
if (showDelete) {
AlertDialog(
onDismissRequest = { showDelete = false },
title = { Text("Delete session?") },
text = { Text("The track and all its checkpoints are removed. This cannot be undone.") },
confirmButton = { TextButton(onClick = { showDelete = false; onDelete() }) { Text("Delete") } },
dismissButton = { TextButton(onClick = { showDelete = false }) { Text("Cancel") } },
)
}
}

@Composable
fun RenameSessionDialog(currentName: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
var name by rememberSaveable { mutableStateOf(currentName) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Rename session") },
text = { OutlinedTextField(value = name, onValueChange = { name = it }, singleLine = true) },
confirmButton = {
TextButton(onClick = { onConfirm(name) }, enabled = name.isNotBlank()) { Text("Save") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}

After delete, the list Flow emits a shorter list and deleted flips to true. The route navigates back in its LaunchedEffect, and no navigation lambda ever enters viewModelScope. Both screens stay consistent without any manual refresh.

Pace colours per session​

The project wants the track colouring configurable "in every past session separately (sometimes optimal range is 4-7, sometimes 7-20 minutes per km)". The session row has paceMinSecPerKm/paceMaxSecPerKm, where null means "use the range from Settings". Lecture 16 colours with session.paceMinSecPerKm ?: settings.paceMinSecPerKm. The editor is a switch plus a RangeSlider:

@Composable
fun PaceRangeEditor(
session: GpsSession,
defaults: Settings,
onChange: (minSecPerKm: Int?, maxSecPerKm: Int?) -> Unit,
) {
val useDefaults = session.paceMinSecPerKm == null || session.paceMaxSecPerKm == null
val min = session.paceMinSecPerKm ?: defaults.paceMinSecPerKm
val max = session.paceMaxSecPerKm ?: defaults.paceMaxSecPerKm
var range by remember(min, max) { mutableStateOf(min.toFloat()..max.toFloat()) }

Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Pace colours: use app defaults", Modifier.weight(1f))
Switch(
checked = useDefaults,
onCheckedChange = { checked -> if (checked) onChange(null, null) else onChange(min, max) },
)
}
Text("${formatPace(range.start.toDouble())} - ${formatPace(range.endInclusive.toDouble())} min/km")
RangeSlider(
value = range,
onValueChange = { range = it }, // follow the finger...
onValueChangeFinished = { // ...save once, on release
onChange(range.start.roundToInt(), range.endInclusive.roundToInt())
},
valueRange = 120f..1200f, // 2:00 - 20:00 min/km
steps = (1200 - 120) / 15 - 1, // 15-second steps
enabled = !useDefaults,
)
}
}

The slider keeps its own range while dragging, and the database is written once, in onValueChangeFinished. Writing on every pixel would trigger a Room write, a Flow emission and a map recolouring per frame.

Whether a new session starts with null (it follows the global setting, even if that changes later) or with a frozen copy of the current range is your decision. To freeze it, TrackingService (lecture 15) adds one line right after startSession:

sessionRepository.setPaceRange(sessionId, settings.paceMinSecPerKm, settings.paceMaxSecPerKm)

Unfinished sessions​

Lecture 15 returns START_NOT_STICKY: if Android or the user kills the app mid-session, the service isn't restarted. Nothing recorded is lost, because every fix is already in the database, but the session row still has endedAt == null. On the next start, offer to finish it:

// in MapViewModel, the start screen
private val _unfinished = MutableStateFlow<SessionDetail?>(null)
val unfinished: StateFlow<SessionDetail?> = _unfinished.asStateFlow()

init {
viewModelScope.launch {
// a running service owns its session - only offer recovery when nothing is tracking
if (!trackingRepository.state.value.isTracking) {
_unfinished.value = sessionRepository.unfinishedSession()
}
}
}

fun onFinishUnfinished() {
val detail = _unfinished.value ?: return
_unfinished.value = null
viewModelScope.launch {
val track = detail.locations.filter { it.type == LocationType.LOC }
val endedAt = detail.locations.lastOrNull()?.recordedAt ?: detail.session.startedAt
val overall = SectionStats(
distanceM = TrackMath.trackDistanceM(track),
durationMs = Duration.between(detail.session.startedAt, endedAt).toMillis(),
)
sessionRepository.finishSession(detail.session.id, endedAt, overall)
}
}

fun onDiscardUnfinished() {
val detail = _unfinished.value ?: return
_unfinished.value = null
viewModelScope.launch { sessionRepository.delete(detail.session.id) }
}
// in MapScreen
unfinished?.let { detail ->
AlertDialog(
onDismissRequest = { }, // force a decision
title = { Text("Unfinished session") },
text = { Text("\"${detail.session.name}\" was not stopped properly. Keep it (${detail.locations.size} points) or delete it?") },
confirmButton = { TextButton(onClick = onFinishUnfinished) { Text("Keep") } },
dismissButton = { TextButton(onClick = onDiscardUnfinished) { Text("Delete") } },
)
}

The session ends at its last saved fix, not "now". Otherwise a session killed at 18:00 and recovered the next morning would claim a 15-hour duration.

The complete map screen​

Five lectures added parts to MapViewModel and the map screen: start/stop and the confirmation (15), the map state and the follow flag (16), the compass and the heading (17), and the unfinished session (this lecture). Here they are as one class. The order matters, because Kotlin initialises properties top to bottom: a flow must be declared before the flows built from it (currentFix before mapState and headingDeg, magneticAzimuth before compassDeg).

data class MapUiState(                                       // lecture 15
val tracking: TrackingState = TrackingState(),
val showStopConfirmation: Boolean = false,
)

@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class MapViewModel @Inject constructor(
private val trackingRepository: TrackingRepository, // 13
private val settingsRepository: SettingsRepository, // 10
private val sessionRepository: SessionRepository, // 18
private val trackingCommands: TrackingCommands, // 15
private val compassSensor: CompassSensor, // 17
locationClient: LocationClient, // 13
@DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, // 08
) : ViewModel() {

// ---- lecture 15: start, stop, confirmation, CP and WP ----------------------------
private val showStopConfirmation = MutableStateFlow(false)

val uiState: StateFlow<MapUiState> =
combine(trackingRepository.state, showStopConfirmation) { tracking, confirm ->
MapUiState(tracking = tracking, showStopConfirmation = confirm && tracking.isTracking)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MapUiState())

fun onStartClick() { trackingCommands.start() }
fun onStopRequested() { showStopConfirmation.value = true }
fun onStopConfirmed() {
showStopConfirmation.value = false
trackingCommands.stop()
}
fun onStopDismissed() { showStopConfirmation.value = false }
fun onAddCheckpoint() = trackingCommands.addCheckpoint()
fun onAddWaypoint() = trackingCommands.addWaypoint()

// ---- lecture 16: what the map draws ----------------------------------------------
private val locationPermission = MutableStateFlow(false)
private val following = MutableStateFlow(true)

init {
settingsRepository.settings
.map { it.keepCentered }
.distinctUntilChanged()
.onEach { following.value = it }
.launchIn(viewModelScope)
}

private val idleFix: Flow<GpsLocation?> = combine(
trackingRepository.state.map { it.isTracking }.distinctUntilChanged(),
locationPermission,
) { tracking, granted -> !tracking && granted }
.distinctUntilChanged()
.flatMapLatest { needed ->
if (!needed) flowOf(null)
else locationClient.locationUpdates(intervalMs = 5_000)
.map<Location, GpsLocation?> { it.toGpsLocation() }
.onStart { emit(null) }
.catch { emit(null) }
}

private val currentFix: StateFlow<GpsLocation?> =
combine(trackingRepository.state, idleFix) { tracking, idle -> tracking.track.lastOrNull() ?: idle }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)

val mapState: StateFlow<MapDrawState> = combine(
trackingRepository.state, settingsRepository.settings, currentFix, following, locationPermission,
) { tracking, settings, fix, isFollowing, granted ->
MapDrawState(
points = tracking.track.map { LatLng(it.latitude, it.longitude) },
spans = paceSpans(tracking.track, settings.paceMinSecPerKm, settings.paceMaxSecPerKm),
checkpoints = tracking.checkpoints,
waypoint = tracking.waypoint,
position = fix?.let { LatLng(it.latitude, it.longitude) },
isTracking = tracking.isTracking,
showMyLocation = granted && !tracking.isTracking,
following = isFollowing,
orientation = settings.mapOrientation,
)
}
.flowOn(defaultDispatcher)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MapDrawState())

fun onLocationPermissionChanged(granted: Boolean) { locationPermission.value = granted }
fun onUserMovedMap() { following.value = false }
fun onRecenter() { following.value = true }

// ---- lecture 17: compass and the direction-up heading ----------------------------
private val magneticAzimuth: StateFlow<Float?> = settingsRepository.settings
.map { it.compassVisible || it.mapOrientation == MapOrientation.DIRECTION_UP }
.distinctUntilChanged()
.flatMapLatest { needed ->
if (needed && compassSensor.isAvailable) compassSensor.azimuthDeg else flowOf(null)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)

val compassDeg: StateFlow<Float?> = combine(magneticAzimuth, settingsRepository.settings) { azimuth, settings ->
azimuth.takeIf { settings.compassVisible }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)

val headingDeg: StateFlow<Float?> = combine(trackingRepository.state, currentFix, magneticAzimuth) { tracking, fix, magnetic ->
movementHeading(tracking.track)
?: magnetic?.let { azimuth -> fix?.let { trueAzimuth(azimuth, it) } ?: azimuth }
}
.map { it?.roundTo5() }
.distinctUntilChanged()
.flowOn(defaultDispatcher)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)

// ---- lecture 18: a session that was not stopped properly -------------------------
private val _unfinished = MutableStateFlow<SessionDetail?>(null)
val unfinished: StateFlow<SessionDetail?> = _unfinished.asStateFlow()

init {
viewModelScope.launch {
if (!trackingRepository.state.value.isTracking) {
_unfinished.value = sessionRepository.unfinishedSession()
}
}
}

fun onFinishUnfinished() {
val detail = _unfinished.value ?: return
_unfinished.value = null
viewModelScope.launch {
val track = detail.locations.filter { it.type == LocationType.LOC }
val endedAt = detail.locations.lastOrNull()?.recordedAt ?: detail.session.startedAt
val overall = SectionStats(
distanceM = TrackMath.trackDistanceM(track),
durationMs = Duration.between(detail.session.startedAt, endedAt).toMillis(),
)
sessionRepository.finishSession(detail.session.id, endedAt, overall)
}
}

fun onDiscardUnfinished() {
val detail = _unfinished.value ?: return
_unfinished.value = null
viewModelScope.launch { sessionRepository.delete(detail.session.id) }
}

// ---- helpers (lecture 16) --------------------------------------------------------
private fun movementHeading(track: List<GpsLocation>): Float? {
val last = track.lastOrNull() ?: return null
val from = track.asReversed().drop(1)
.takeWhile { Duration.between(it.recordedAt, last.recordedAt) <= Duration.ofSeconds(15) }
.firstOrNull { TrackMath.distanceM(it, last) >= 5.0 }
?: return null
return TrackMath.bearingDeg(from, last)
}

private fun Float.roundTo5(): Float = ((this / 5f).roundToInt() * 5 % 360).toFloat()
}

The route puts the pieces of earlier lectures together: the permission helpers (12), AdaptiveMapLayout and TrackingPanel (07), MapArea (16), the Compass (17) and both dialogs.

@Composable
fun MapRoute(
onOpenSessions: () -> Unit,
onOpenSettings: () -> Unit,
launchAction: String? = null,
onLaunchActionHandled: () -> Unit = {},
viewModel: MapViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val unfinished by viewModel.unfinished.collectAsStateWithLifecycle()
val compassDeg = viewModel.compassDeg.collectAsStateWithLifecycle() // State, read lazily (17)
val compassVisible by remember { derivedStateOf { compassDeg.value != null } }

// lecture 12: location when the map opens, notifications at Start
val location = rememberPermissionsState(locationPermissions)
LocationPermissionOnOpen(location)
val notifications = rememberPermissionsState(notificationPermissions, onResult = { viewModel.onStartClick() })

// lectures 11 + 15: the notification's Stop button opens the app with CONFIRM_STOP
LaunchedEffect(launchAction) {
if (launchAction == TrackingActions.CONFIRM_STOP) {
viewModel.onStopRequested()
onLaunchActionHandled()
}
}

val tracking = state.tracking
val onStartStop = {
when {
tracking.isTracking -> viewModel.onStopRequested()
!location.isGranted -> location.request()
notifications.status == PermissionStatus.Denied ||
notifications.status == PermissionStatus.ShowRationale -> notifications.request()
else -> viewModel.onStartClick() // lecture 13: check location settings here too
}
}

AdaptiveMapLayout( // lecture 07
map = { m ->
Box(m) {
MapArea(viewModel, hasLocationPermission = location.isGranted, Modifier.fillMaxSize()) // 16
Row(Modifier.align(Alignment.TopEnd).safeDrawingPadding().padding(8.dp)) {
FilledTonalIconButton(onClick = viewModel::onRecenter) {
Icon(Icons.Default.Refresh, contentDescription = "Center on my position")
}
FilledTonalIconButton(onClick = onOpenSessions) {
Icon(Icons.AutoMirrored.Filled.List, contentDescription = "Sessions")
}
FilledTonalIconButton(onClick = onOpenSettings) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
}
if (compassVisible) {
Compass( // 17
azimuthDeg = { compassDeg.value ?: 0f },
modifier = Modifier.align(Alignment.TopStart).safeDrawingPadding().padding(8.dp).size(96.dp),
)
}
if (!location.isGranted) {
LocationMissingBanner(location, Modifier.align(Alignment.BottomCenter).padding(8.dp)) // 12
}
}
},
panel = { vertical, m ->
TrackingPanel(
vertical = vertical,
isTracking = tracking.isTracking,
overall = SectionTexts(
formatDistance(tracking.overall.distanceM),
formatDuration(tracking.overall.durationMs),
formatPace(tracking.overall.paceSecPerKm),
),
checkpoint = tracking.fromCheckpoint.toTexts(),
waypoint = tracking.fromWaypoint.toTexts(),
onStartStop = onStartStop,
onAddCheckpoint = viewModel::onAddCheckpoint,
onAddWaypoint = viewModel::onAddWaypoint,
modifier = m.safeDrawingPadding(),
)
},
)

if (state.showStopConfirmation) { // 15
AlertDialog(
onDismissRequest = viewModel::onStopDismissed,
title = { Text("Stop session?") },
text = { Text("The session will be saved and tracking stops.") },
confirmButton = { TextButton(onClick = viewModel::onStopConfirmed) { Text("Stop") } },
dismissButton = { TextButton(onClick = viewModel::onStopDismissed) { Text("Continue") } },
)
}

unfinished?.let { detail -> // 18
AlertDialog(
onDismissRequest = { },
title = { Text("Unfinished session") },
text = { Text("\"${detail.session.name}\" was not stopped properly. Keep it or delete it?") },
confirmButton = { TextButton(onClick = viewModel::onFinishUnfinished) { Text("Keep") } },
dismissButton = { TextButton(onClick = viewModel::onDiscardUnfinished) { Text("Delete") } },
)
}
}

/** CP and WP sections: travelled distance, straight-line distance, pace. Dashes until set. */
private fun SectionStats?.toTexts(): SectionTexts =
if (this == null) SectionTexts("-", "-", "--:--")
else SectionTexts(formatDistance(distanceM), formatDistance(directM), formatPace(paceSecPerKm))
  • One ViewModel, many flows. Each StateFlow has its own consumer: uiState drives the panel and the dialogs, mapState and headingDeg the map, and compassDeg the compass. A compass tick recomposes neither the panel nor the map.
  • Start checks location, then notifications (lecture 12), then calls onStartClick(). Before a real start, add lecture 13's location-settings check (are location services on?) at the marked line.
  • Stop, from the panel or from the notification, always goes through onStopRequested() and the same dialog.
  • The map draws behind the system bars. The overlay buttons and the panel use safeDrawingPadding() (lectures 05 and 16).
  • The toolbar here (re-center, sessions, settings) is minimal. Your design decides which controls go on the map. Map orientation and the compass toggle live in Settings (lecture 10), and you may also put them on the map.

Inspecting the database​

  • App Inspection → Database Inspector in Android Studio, with the app running in debug: browse tables, run SQL, and with Live updates on, watch gps_location grow while you walk.
  • The file itself is at /data/data/ee.taltech.sportmap/databases/sportmap.db (plus -wal and -shm files). Device Explorer can download it for any SQLite tool.
  • Every inspection is read-only unless you deliberately run an UPDATE. Great for testing what the history screen does with odd data.

Room 2.x vs Room 3​

Almost every tutorial, Stack Overflow answer and AI assistant still writes Room 2. Translate:

Room 2.xRoom 3
androidx.room:room-runtime, room-ktx, room-compiler with kapt or KSPandroidx.room3:room3-runtime, room3-compiler, KSP only
import androidx.room.*import androidx.room3.*
plugin androidx.room, room { schemaDirectory(...) }plugin androidx.room3, room3 { schemaDirectory(...) }
blocking DAO functions allowed (fun getAll(): List<T>)every DAO function is suspend or returns Flow
Room.databaseBuilder(context, AppDatabase::class.java, "db")Room.databaseBuilder<AppDatabase>(context, "db").setDriver(AndroidSQLiteDriver())
@TypeConverter, @TypeConverters@ColumnTypeConverter, @ColumnTypeConverters
@Relation(parentColumn = "id", entityColumn = "sessionId")@Relation(parentColumns = ["id"], entityColumns = ["sessionId"])
db.withTransaction { }, runInTransaction { }db.withWriteTransaction { }, withReadTransaction { }
Migration.migrate(db: SupportSQLiteDatabase)suspend fun migrate(connection: SQLiteConnection)
LiveData return types out of the boxFlow. LiveData is a separate artifact you don't need
InvalidationTracker.Observerdb.invalidationTracker.createFlow("table")

If AI-generated code imports androidx.room.Room or calls getAll() without suspend, it is Room 2.

Files​

Not everything belongs in a database. A GPX export is a document for another app (mail, Drive, an analysis tool), and a photo is a blob. Both are files.

LocationAPIVisible toRemoved
internal filescontext.filesDironly your appon uninstall
internal cachecontext.cacheDironly your appon uninstall, or by the system when storage runs low
app-specific externalcontext.getExternalFilesDir(null)your app (and a USB-connected computer)on uninstall
shared storage (Downloads, Pictures...)Storage Access Framework, MediaStoreeveryonenever by the system

None of the app-specific locations needs a permission. READ_EXTERNAL_STORAGE/WRITE_EXTERNAL_STORAGE from old tutorials do nothing for these folders on modern Android. Don't declare them. To put a file into shared storage, let the user pick the place (SAF, below) instead of asking for broad storage access.

val file = File(context.filesDir, "notes.txt")
file.writeText("Hello") // kotlin.io extensions: readText, appendText, outputStream...
val text = file.readText()
context.deleteFile("notes.txt")

File I/O blocks, so do it on Dispatchers.IO (lecture 08).

GPX export​

GPX 1.1 is the XML exchange format every sports and map tool understands. The final project needs every location update with its timestamp, and the checkpoints as waypoints:

<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1" creator="SportMap">
<metadata>
<name>Evening orienteering</name>
<time>2026-10-02T15:04:05Z</time>
</metadata>
<wpt lat="59.39512" lon="24.66408">
<ele>41.0</ele>
<time>2026-10-02T15:21:40Z</time>
<name>CP 1</name>
<sym>Flag, Red</sym>
</wpt>
<trk>
<name>Evening orienteering</name>
<trkseg>
<trkpt lat="59.39501" lon="24.66412">
<ele>40.5</ele>
<time>2026-10-02T15:04:07Z</time>
</trkpt>
<!-- one trkpt per location update -->
</trkseg>
</trk>
</gpx>

The order matters for validators: metadata, then all wpt, then trk. Inside a point the order is ele, time, name, sym. Times are ISO 8601 in UTC.

Android ships an XML pull serializer (android.util.Xml), which escapes text correctly. Don't build XML with string templates: a session named Tom & Jerry would produce a broken file.

class GpxExporter @Inject constructor(
@ApplicationContext private val context: Context,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, // lecture 08
) {
/** Writes the GPX into the app cache, ready to share. */
suspend fun export(detail: SessionDetail): File = withContext(ioDispatcher) {
val dir = File(context.cacheDir, "exports").apply { mkdirs() }
val file = File(dir, "session-${detail.session.id}.gpx")
file.outputStream().use { write(detail, it) }
file
}

/** Writes the GPX into a document the user picked (Storage Access Framework). */
suspend fun exportTo(detail: SessionDetail, uri: Uri) = withContext(ioDispatcher) {
context.contentResolver.openOutputStream(uri)?.use { write(detail, it) }
}

private fun write(detail: SessionDetail, out: OutputStream) {
val xml = Xml.newSerializer()
xml.setOutput(out, "UTF-8")
xml.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true)
xml.startDocument("UTF-8", true)
xml.setPrefix("", NS)
xml.startTag(NS, "gpx")
xml.attribute(null, "version", "1.1")
xml.attribute(null, "creator", "SportMap")

xml.startTag(NS, "metadata")
xml.textTag("name", detail.session.name)
xml.textTag("time", iso(detail.session.startedAt))
xml.endTag(NS, "metadata")

var cp = 0
var wp = 0
detail.locations.filter { it.type != LocationType.LOC }.forEach { p ->
xml.startTag(NS, "wpt")
xml.attribute(null, "lat", p.latitude.coordinate())
xml.attribute(null, "lon", p.longitude.coordinate())
p.altitude?.let { xml.textTag("ele", it.elevation()) }
xml.textTag("time", iso(p.recordedAt))
xml.textTag("name", if (p.type == LocationType.CP) "CP ${++cp}" else "WP ${++wp}")
xml.textTag("sym", if (p.type == LocationType.CP) "Flag, Red" else "Pin, Blue")
xml.endTag(NS, "wpt")
}

xml.startTag(NS, "trk")
xml.textTag("name", detail.session.name)
xml.startTag(NS, "trkseg")
detail.locations.filter { it.type == LocationType.LOC }.forEach { p ->
xml.startTag(NS, "trkpt")
xml.attribute(null, "lat", p.latitude.coordinate())
xml.attribute(null, "lon", p.longitude.coordinate())
p.altitude?.let { xml.textTag("ele", it.elevation()) }
xml.textTag("time", iso(p.recordedAt))
xml.endTag(NS, "trkpt")
}
xml.endTag(NS, "trkseg")
xml.endTag(NS, "trk")

xml.endTag(NS, "gpx")
xml.endDocument()
xml.flush()
}

private fun XmlSerializer.textTag(name: String, text: String) {
startTag(NS, name)
text(text)
endTag(NS, name)
}

private fun iso(instant: Instant): String = DateTimeFormatter.ISO_INSTANT.format(instant)

// fixed notation, always a dot: 7 decimals is about 1 cm
private fun Double.coordinate(): String = "%.7f".format(Locale.ROOT, this)
private fun Double.elevation(): String = "%.1f".format(Locale.ROOT, this)

private companion object {
const val NS = "http://www.topografix.com/GPX/1/1"
}
}

write takes any OutputStream, so the same code serves a file in the cache (for sharing) and a document from the Storage Access Framework (for saving). Both public functions switch to the injected IO dispatcher, so callers can launch them from any coroutine. Numbers need care. String.format with the device locale writes 59,3951234 on an Estonian phone, and Double.toString() switches to scientific notation for small values (5.0E-4). Neither is valid GPX. Format with Locale.ROOT and a fixed number of decimals.

Check your output once: import it into a viewer, for example gpx.studio, and look at the track and the CP flags.

Sharing a file: FileProvider and ACTION_SEND​

Another app can't read cacheDir, and handing it a file:// path throws FileUriExposedException. A FileProvider gives out temporary content:// URIs for chosen folders instead.

<!-- AndroidManifest.xml, inside <application> -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- res/xml/file_paths.xml - only these folders can be shared -->
<paths>
<cache-path name="exports" path="exports/" />
<files-path name="photos" path="photos/" />
</paths>
fun shareGpx(context: Context, file: File, sessionName: String) {
val uri: Uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val send = Intent(Intent.ACTION_SEND).apply {
type = "application/gpx+xml"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "SportMap: $sessionName")
putExtra(Intent.EXTRA_TEXT, "Track exported from SportMap")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(send, "Share GPX"))
}

This is the implicit intent from lecture 02 with a stream attached. The chooser lists mail apps, Drive, messengers and so on, and picking Gmail creates the "email attachment" the final project asks for. FLAG_GRANT_READ_URI_PERMISSION gives the receiving app read access to that one URI only.

From the detail screen:

val scope = rememberCoroutineScope()
val context = LocalContext.current
TextButton(onClick = {
scope.launch { shareGpx(context, gpxExporter.export(detail), detail.session.name) }
}) { Text("Share GPX") }

In a real app the button calls a ViewModel function that exports, and the UI only launches the chooser. GpxExporter is injected into SessionDetailViewModel.

Saving where the user chooses: CreateDocument​

The Storage Access Framework shows the system file picker. The user chooses Downloads, Drive or a folder on an SD card, and your app gets a URI to write to, with no permission at all:

@Composable
fun SaveGpxButton(detail: SessionDetail, exporter: GpxExporter) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/gpx+xml")
) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult // user cancelled
scope.launch { exporter.exportTo(detail, uri) }
}
Button(onClick = { launcher.launch("${detail.session.name}.gpx") }) { Text("Save GPX") }
}

rememberLauncherForActivityResult is the Compose form of the activity result API you'll also use for permissions (lecture 12). OpenDocument is the counterpart for importing a file.

Checkpoint photos: TakePicture​

At the field test you photograph every CP. The phone's camera app is fine for that. If you want the photos inside SportMap, ask the camera app to write into a file you own:

@Composable
fun CheckpointPhotoButton(onPhotoSaved: (File) -> Unit) {
val context = LocalContext.current
// the camera app often rotates the screen, or Android kills SportMap while it's open - survive both
var pendingPath by rememberSaveable { mutableStateOf<String?>(null) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val path = pendingPath
if (success && path != null) onPhotoSaved(File(path))
pendingPath = null
}
Button(onClick = {
val dir = File(context.filesDir, "photos").apply { mkdirs() }
val file = File(dir, "cp-${System.currentTimeMillis()}.jpg")
pendingPath = file.absolutePath
launcher.launch(FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file))
}) { Text("Photo") }
}

The pending file path is kept with rememberSaveable, as a String, because a File isn't saveable. With plain remember, a rotation or process death while the camera app is open would lose the path, and the photo would be taken but never attached. No CAMERA permission is needed, because the camera app takes the picture. Store the file path next to the checkpoint (an extra nullable column in gps_location, which is a migration). CameraX would give you an in-app viewfinder, but that's well beyond what the project needs.

Waypoints in the database​

The specification says "WPs do not get saved to DB". It also says the GPX must contain "user set WP coordinates and timestamp", and the backend has a WP location type. The two pull in different directions, and SportMap resolves it like this:

  • Each placed waypoint is stored as a gps_location row with type = WP, through the same addLocation call as everything else (lecture 15). Without a stored row the GPX export and the sync couldn't include it.
  • The spirit of "not saved" is kept in the UI: a WP is a temporary navigation aid. Only the newest one is shown during the session. It is never drawn as a permanent marker when you reopen an old session, and it doesn't count as a checkpoint in any statistic (count { it.type == LocationType.CP }).
  • GPX exports it as a wpt named WP n, and the sync uploads it with the WP type id.

Be ready to explain this reading of the specification at the defence.

In the final project​

  • "Save all the sessions and all the checkpoints to database": TrackingService writes through SessionRepository as data arrives, so a killed process loses at most the current fix.
  • "View/delete old sessions, display statistics and track on map": SessionsScreen and SessionDetailScreen with distance, duration, average pace, the direct start-to-end distance and the CP count, plus the map from lecture 16 zooming to the track bounds.
  • "Rename": RenameSessionDialog plus dao.rename. Deleting asks for confirmation.
  • "It will not crash even while doing some 2-3h long session": even if the process is killed anyway, the unfinished session is offered for recovery on the next start.
  • "Track segment coloring configurable in every past session separately": PaceRangeEditor on the detail screen calls setPaceRange. The session's paceMinSecPerKm/paceMaxSecPerKm override the global Settings range, and null means "use the defaults".
  • "Session export, as email attachment, GPX": GpxExporter, FileProvider and ACTION_SEND. Checkpoints and waypoints become wpt, every location update a trkpt with its time.
  • "Sync all the locations later": the synced flag and backendId turn the database into the outbox for lecture 19.
  • At the defence, open the Database Inspector and show your tables, the foreign key and the index.

Self preparation QA​

  1. What does Room check at compile time that raw SQLite can't? — Every SQL statement against the schema: table and column names, parameter bindings, and return types matching the query's columns. Mistakes fail the build instead of crashing at runtime.
  2. Why must Room 3 DAO functions be suspend or return Flow? — Database I/O must never run blocking on the main thread. Room 3 is coroutine-first, so one-shot work is suspend and observation is a Flow.
  3. What happens to a Flow returned by a DAO query when another screen deletes a session? — Room notices the write to a table the query reads, re-runs the query and emits the new result, so every collector updates automatically.
  4. Why put an index on gps_location.sessionId, and what does onDelete = CASCADE do? — Every query filters locations by session, and the index avoids scanning the whole table. CASCADE deletes a session's locations when the session row is deleted.
  5. How do you store a java.time.Instant in Room? — With a @ColumnTypeConverter pair converting it to and from epoch milliseconds (Long), registered on the database with @ColumnTypeConverters.
  6. Why does SessionWithLocations need @Transaction on its DAO function, and why sort the locations afterwards? — Room loads the parent and the children in two queries, and the transaction makes them a consistent snapshot. @Relation doesn't guarantee child order, so sort by recordedAt when mapping.
  7. You add a column to GpsSessionEntity. What must you do so existing users keep their data? — Increase the database version and provide a migration: an AutoMigration(from, to) using the exported schema files, or a manual Migration with ALTER TABLE. fallbackToDestructiveMigration would delete their data.
  8. How do you recognise Room 2 code from a tutorial or AI answer? — Imports from androidx.room, @TypeConverter, Room.databaseBuilder(context, X::class.java, name) without a driver, blocking DAO functions, @Relation(parentColumn = ...).
  9. Why can't you share File(cacheDir, "x.gpx") directly with a mail app, and what do you do instead? — Other apps can't read your private storage, and file:// URIs are forbidden between apps. A FileProvider creates a content:// URI, and FLAG_GRANT_READ_URI_PERMISSION grants temporary read access.
  10. Why write GPX with XmlSerializer and "%.7f".format(Locale.ROOT, value) instead of string templates and toString()? — The serializer escapes special characters such as &. Locale.ROOT always uses a dot (the device locale would write commas on an Estonian phone), and fixed decimals avoid scientific notation such as 5.0E-4. Either mistake breaks the file.