Skip to main content

19 - Networking & Background Sync

Recap​

18 - Room 3 Database & Files made SportMap durable. Every fix and checkpoint lands in Room as it arrives, the history screens read it back, and GPX leaves the device as a file. The synced flag and backendId columns were added there and haven't been used yet. This lecture puts them to work. SportMap talks to the course backend sportmap.akaver.com over HTTPS with Ktor: the user registers or logs in, the JWT is kept in DataStore, and the session is uploaded live while it runs and later, whatever was recorded offline, using Room as an outbox, WorkManager for guaranteed background work, and the foreground service from 15 - Foreground Service for the real-time part.

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

  • Configure a Ktor 3 client with JSON, timeouts, logging, default request settings and bearer authentication.
  • Model the backend's API with @Serializable DTOs that are separate from entities and domain classes.
  • Turn HTTP status codes and network exceptions into one result type the UI can show.
  • Implement login/register with the JWT stored in DataStore, and react to an expired token.
  • Build offline-first sync: Room as the outbox, a @HiltWorker for retries and catch-up, live upload from the service.
  • Use WorkManager constraints, unique work, backoff and periodic work correctly.
  • Map every final-project requirement to the lecture that covers it.
Demo code

Lecture demos: native-2026-fall

The backend​

The backend is an ASP.NET Core Web API, a sibling of what you built in the C# courses. The API is documented in Swagger, and the web UI shows sessions and tracks on a map, live. Base URL: https://sportmap.akaver.com/api/v1/ (v1.0 works too).

MethodPathBodyResponseAuth
POSTaccount/register{ email, password, firstName, lastName }{ token, status, firstName, lastName }-
POSTaccount/login{ email, password }{ token, status, firstName, lastName }-
GETGpsSessionTypes-[{ id, name, description, paceMin, paceMax }]-
GETGpsLocationTypes-[{ id, name, description }]-
POSTGpsSessions{ name, description, gpsSessionTypeId, recordedAt?, paceMin?, paceMax? }the created session with its idbearer
PUT, DELETEGpsSessions/{id}the session-bearer
POSTGpsLocations/{gpsSessionId}one locationthe created locationbearer
POSTGpsLocations/bulk/{gpsSessionId}an array of locations{ locationsAdded, locationsReceived, gpsSessionId }bearer
GETGpsLocations/Session/{gpsSessionId}-the session's locationsbearer

A location is { recordedAt, latitude, longitude, accuracy, altitude, verticalAccuracy, gpsLocationTypeId }. The session id goes in the path, not in the body. The type ids are fixed:

Location typeid
LOC - periodic update00000000-0000-0000-0000-000000000001
WP - waypoint00000000-0000-0000-0000-000000000002
CP - checkpoint00000000-0000-0000-0000-000000000003

The session types (running, orienteering easy/competition, bicycle...) have fixed ids ...0001 to ...0006 and default pace ranges in seconds per km. paceMin/paceMax on a session override them.

Behaviour worth knowing before you debug:

  • Wrong email or password returns 404 with { "messages": ["User not found!"] }, not 401.
  • name and description of a session are required, with at least 2 characters each, otherwise the response is 400.
  • A request without a valid token to a protected endpoint gets 401 with WWW-Authenticate: Bearer.
  • duration, distance, speed, climb and descent are computed by the backend from the locations you upload.
The final project text vs the API

The project description shows POST GpsLocations with gpsSessionId in the body. Swagger is the source of truth: post to GpsLocations/{gpsSessionId}, and for more than one location use GpsLocations/bulk/{gpsSessionId}. There is also a v2 account API with refresh tokens (api/v2/Account/Login, RenewRefreshToken). It's optional, see Token expiry.

Setup​

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />

INTERNET is an install-time permission (lecture 12), so there's no dialog. Without it, every request fails with a misleading UnknownHostException or SocketException: ... EPERM.

# gradle/libs.versions.toml
[libraries]
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" }
ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "androidxHilt" }
androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "androidxHilt" }

[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
// app/build.gradle.kts
plugins {
// ...
alias(libs.plugins.kotlin.serialization) // already there if you did lecture 11
}

dependencies {
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp) // engine: OkHttp, visible in Network Inspector
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.auth)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.serialization.json)

implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler) // generates the @HiltWorker factories
}

Ktor is JetBrains' asynchronous HTTP client, built on coroutines and multiplatform. The same code runs on iOS and desktop. The engine does the actual networking (OkHttp on Android), and plugins add features: JSON, auth, logging, timeouts.

DTOs​

The JSON on the wire is a third shape of data, next to entities (lecture 18) and domain classes (lecture 10). Give it its own classes, DTOs (data transfer objects), so a renamed backend field never ripples into your database or UI:

@Serializable
data class LoginRequest(val email: String, val password: String)

@Serializable
data class RegisterRequest(val email: String, val password: String, val firstName: String, val lastName: String)

@Serializable
data class JwtResponse(val token: String, val status: String, val firstName: String, val lastName: String)

@Serializable
data class GpsSessionTypeDto(val id: String, val name: String, val description: String, val paceMin: Int, val paceMax: Int)

@Serializable
data class GpsLocationTypeDto(val id: String, val name: String, val description: String)

@Serializable
data class GpsSessionCreateDto(
val name: String,
val description: String,
val gpsSessionTypeId: String,
val recordedAt: String? = null, // ISO 8601
val paceMin: Double? = null, // seconds per km; null = the session type's range
val paceMax: Double? = null,
)

@Serializable
data class GpsSessionDto(
val id: String,
val name: String,
val description: String,
val recordedAt: String,
val duration: Double = 0.0,
val speed: Double = 0.0,
val distance: Double = 0.0,
val climb: Double = 0.0,
val descent: Double = 0.0,
val paceMin: Double = 0.0,
val paceMax: Double = 0.0,
val gpsSessionTypeId: String? = null,
val appUserId: String? = null,
)

@Serializable
data class GpsLocationCreateDto(
val recordedAt: String,
val latitude: Double,
val longitude: Double,
val accuracy: Double,
val altitude: Double = 0.0,
val verticalAccuracy: Double = 0.0,
val gpsLocationTypeId: String,
)

@Serializable
data class BulkUploadResponse(val locationsAdded: Int, val locationsReceived: Int, val gpsSessionId: String)
  • The kotlin-serialization compiler plugin generates a serializer for every @Serializable class at build time. There's no reflection, and it's fast.
  • Property names must match the JSON keys. If they don't, use @SerialName("json_name").
  • Defaults make fields optional when reading. Response fields you don't care about can simply be left out of the class.
  • Ids are String (UUIDs), and times are ISO 8601 strings. Instant.toString() produces exactly that (2026-10-02T15:04:07.123Z).

Mapping from the domain:

object LocationTypeIds {
const val LOC = "00000000-0000-0000-0000-000000000001"
const val WP = "00000000-0000-0000-0000-000000000002"
const val CP = "00000000-0000-0000-0000-000000000003"

fun of(type: LocationType) = when (type) {
LocationType.LOC -> LOC
LocationType.WP -> WP
LocationType.CP -> CP
}
}

fun GpsLocation.toCreateDto() = GpsLocationCreateDto(
recordedAt = recordedAt.toString(),
latitude = latitude,
longitude = longitude,
accuracy = accuracy.toDouble(),
altitude = altitude ?: 0.0,
verticalAccuracy = verticalAccuracy?.toDouble() ?: 0.0,
gpsLocationTypeId = LocationTypeIds.of(type),
)

The HTTP client​

One HttpClient per app. It holds connection pools and threads, so creating one per request leaks resources. Provide it with Hilt:

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
private const val BASE_URL = "https://sportmap.akaver.com/api/v1/"

@Provides
@Singleton
fun provideJson(): Json = Json {
ignoreUnknownKeys = true // the backend may add fields - don't crash on them
explicitNulls = false // don't send "paceMin": null, leave it out
}

@Provides
@Singleton
fun provideHttpClient(json: Json, tokenStore: TokenStore): HttpClient = HttpClient(OkHttp) {
expectSuccess = true // non-2xx responses throw
install(ContentNegotiation) { json(json) }
install(HttpTimeout) {
connectTimeoutMillis = 10_000
requestTimeoutMillis = 20_000
}
install(Logging) {
logger = Logger.ANDROID // Logcat
level = LogLevel.INFO // HEADERS / BODY while debugging
sanitizeHeader { header -> header == HttpHeaders.Authorization }
}
install(Auth) {
bearer {
loadTokens { tokenStore.token.first()?.let { BearerTokens(it, null) } }
cacheTokens = false // re-read after login/logout
}
}
defaultRequest {
url(BASE_URL)
contentType(ContentType.Application.Json)
}
}
}
Plugin / settingWhy
ContentNegotiation + json(...)setBody(dto) serialises and .body<T>() deserialises with kotlinx.serialization
HttpTimeouta dead network in the forest must fail in seconds, not minutes
Loggingrequests in Logcat. sanitizeHeader keeps the JWT out of logs
Auth / beareradds Authorization: Bearer ...
defaultRequestbase URL and content type, so calls use relative paths: client.get("GpsSessionTypes")
expectSuccess = true4xx/5xx become exceptions you handle in one place (below), instead of a "successful" response you forget to check

The bearer provider attaches the token to every request by default, and by default it also caches the tokens after the first loadTokens. It would keep sending no token after a login, or the old one after a logout. cacheTokens = false makes it call loadTokens for every request. That's cheap here, because it's a DataStore read, and login and logout take effect at once.

Why a separate TokenStore​

The client needs the token, and the login code needs the client. If AuthRepository provided the token to the client, Hilt would report a dependency cycle. The way out is a tiny class that only stores the token, which both depend on:

@Qualifier @Retention(AnnotationRetention.BINARY) annotation class AuthStore

@Provides @Singleton @AuthStore
fun provideAuthDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("auth") }

data class Account(val email: String, val firstName: String, val lastName: String)

@Singleton
class TokenStore @Inject constructor(
@AuthStore private val dataStore: DataStore<Preferences>,
) {
private object Keys {
val JWT = stringPreferencesKey("jwt")
val EMAIL = stringPreferencesKey("email")
val FIRST_NAME = stringPreferencesKey("first_name")
val LAST_NAME = stringPreferencesKey("last_name")
}

private val prefs = dataStore.data.catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e }

val token: Flow<String?> = prefs.map { it[Keys.JWT] }

val account: Flow<Account?> = prefs.map { p ->
val email = p[Keys.EMAIL] ?: return@map null
Account(email, p[Keys.FIRST_NAME].orEmpty(), p[Keys.LAST_NAME].orEmpty())
}

suspend fun save(email: String, jwt: JwtResponse) {
dataStore.edit {
it[Keys.JWT] = jwt.token
it[Keys.EMAIL] = email
it[Keys.FIRST_NAME] = jwt.firstName
it[Keys.LAST_NAME] = jwt.lastName
}
}

suspend fun clear() {
dataStore.edit { it.clear() }
}
}

This is the same DataStore pattern as SettingsRepository in lecture 10, with its own file and qualifier. "Account info is saved locally" from the project milestones is exactly this. Never store the password: the token is enough, and it expires.

The API​

One class, one function per endpoint. These are thin wrappers that only know HTTP:

class SportMapApi @Inject constructor(private val client: HttpClient) {

suspend fun login(request: LoginRequest): JwtResponse =
client.post("account/login") { setBody(request) }.body()

suspend fun register(request: RegisterRequest): JwtResponse =
client.post("account/register") { setBody(request) }.body()

suspend fun sessionTypes(): List<GpsSessionTypeDto> = client.get("GpsSessionTypes").body()

suspend fun locationTypes(): List<GpsLocationTypeDto> = client.get("GpsLocationTypes").body()

suspend fun createSession(request: GpsSessionCreateDto): GpsSessionDto =
client.post("GpsSessions") { setBody(request) }.body()

suspend fun addLocation(backendSessionId: String, location: GpsLocationCreateDto) {
client.post("GpsLocations/$backendSessionId") { setBody(location) }
}

suspend fun addLocations(backendSessionId: String, locations: List<GpsLocationCreateDto>): BulkUploadResponse =
client.post("GpsLocations/bulk/$backendSessionId") { setBody(locations) }.body()
}

Every function is suspend and main-safe: Ktor does the I/O on its own threads, so you call these from viewModelScope directly without withContext. Parameters work as you'd expect: client.get("GpsSessions") { parameter("minLocationsCount", 10) }, plus headers { append(...) } and timeout { requestTimeoutMillis = 60_000 } per request.

Errors​

A network call can fail in very different ways, and the UI needs to tell them apart:

What happenedKtor (with expectSuccess = true)Meaning for the app
2xxnormal returnsuccess
400ClientRequestExceptionour request is wrong, and retrying won't help
401ClientRequestExceptiontoken missing or expired, so log in again
404 on loginClientRequestExceptionwrong credentials
5xxServerResponseExceptionthe server's problem, so retry later
no network, DNS failure, connection resetIOException (UnknownHostException, ConnectException...)offline, so retry later
too slowHttpRequestTimeoutExceptionoffline-ish, so retry later
JSON doesn't match a DTOJsonConvertExceptiona bug in your DTO, so fix it

Wrap that in one sealed type, once:

sealed interface ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>
data class HttpError(val status: Int, val body: String) : ApiResult<Nothing>
data class NetworkError(val cause: Throwable) : ApiResult<Nothing>
}

suspend fun <T> apiCall(block: suspend () -> T): ApiResult<T> =
try {
ApiResult.Success(block())
} catch (e: CancellationException) {
throw e // never swallow cancellation (lecture 08)
} catch (e: ResponseException) { // any non-2xx response
ApiResult.HttpError(e.response.status.value, e.response.bodyAsText())
} catch (e: HttpRequestTimeoutException) {
ApiResult.NetworkError(e)
} catch (e: IOException) {
ApiResult.NetworkError(e)
}

kotlin.Result with runCatching looks tempting, but it also catches CancellationException. A cancelled coroutine would then carry on as if the call had merely failed.

Logging in and registering​

@Singleton
class AuthRepository @Inject constructor(
private val api: SportMapApi,
private val tokenStore: TokenStore,
) {
val account: Flow<Account?> = tokenStore.account
val isLoggedIn: Flow<Boolean> = tokenStore.token.map { it != null }

suspend fun login(email: String, password: String): ApiResult<Unit> =
when (val result = apiCall { api.login(LoginRequest(email, password)) }) {
is ApiResult.Success -> { tokenStore.save(email, result.data); ApiResult.Success(Unit) }
is ApiResult.HttpError -> result
is ApiResult.NetworkError -> result
}

suspend fun register(email: String, password: String, firstName: String, lastName: String): ApiResult<Unit> =
when (val result = apiCall { api.register(RegisterRequest(email, password, firstName, lastName)) }) {
is ApiResult.Success -> { tokenStore.save(email, result.data); ApiResult.Success(Unit) }
is ApiResult.HttpError -> result
is ApiResult.NetworkError -> result
}

suspend fun logout() = tokenStore.clear()
}

The ViewModel turns results into UI state: loading, error message or done. One screen does both jobs: mode switches between logging in and registering.

enum class AuthMode { LOGIN, REGISTER }

data class LoginUiState(
val mode: AuthMode = AuthMode.LOGIN,
val email: String = "",
val password: String = "",
val firstName: String = "",
val lastName: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val loggedIn: Boolean = false,
) {
val canSubmit: Boolean
get() = !isLoading && email.isNotBlank() && password.length >= 6 &&
(mode == AuthMode.LOGIN || (firstName.isNotBlank() && lastName.isNotBlank()))
}

@Serializable
data class ServerMessages(val messages: List<String> = emptyList())

private val errorJson = Json { ignoreUnknownKeys = true }

/** The backend's error body `{ "messages": [...] }`, if there is one. */
fun ApiResult.HttpError.serverMessages(): List<String> =
runCatching { errorJson.decodeFromString<ServerMessages>(body).messages }.getOrDefault(emptyList())

@HiltViewModel
class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val syncScheduler: SyncScheduler,
) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()

fun onEmailChange(value: String) = _uiState.update { it.copy(email = value, error = null) }
fun onPasswordChange(value: String) = _uiState.update { it.copy(password = value, error = null) }
fun onFirstNameChange(value: String) = _uiState.update { it.copy(firstName = value, error = null) }
fun onLastNameChange(value: String) = _uiState.update { it.copy(lastName = value, error = null) }

fun onModeToggle() = _uiState.update {
it.copy(mode = if (it.mode == AuthMode.LOGIN) AuthMode.REGISTER else AuthMode.LOGIN, error = null)
}

fun onLogin() = submit { s -> authRepository.login(s.email.trim(), s.password) }

fun onRegister() = submit { s ->
authRepository.register(s.email.trim(), s.password, s.firstName.trim(), s.lastName.trim())
}

private fun submit(call: suspend (LoginUiState) -> ApiResult<Unit>) {
val state = _uiState.value
if (!state.canSubmit) return
_uiState.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch {
val result = call(state)
_uiState.update {
when (result) {
is ApiResult.Success -> it.copy(isLoading = false, loggedIn = true)
is ApiResult.HttpError -> it.copy(isLoading = false, error = errorText(state.mode, result))
is ApiResult.NetworkError -> it.copy(isLoading = false, error = "No connection to the server")
}
}
if (result is ApiResult.Success) syncScheduler.syncNow() // upload what was recorded before login
}
}

private fun errorText(mode: AuthMode, error: ApiResult.HttpError): String =
error.serverMessages().firstOrNull() // e.g. "User not found!"
?: if (mode == AuthMode.LOGIN) "Wrong email or password" else "Registration failed (${error.status})"
}

runCatching is fine in serverMessages(): it's a plain function, not a coroutine, so there is no cancellation to swallow.

@Composable
fun LoginRoute(onLoggedIn: () -> Unit, viewModel: LoginViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(state.loggedIn) { if (state.loggedIn) onLoggedIn() }
LoginScreen(
state = state,
onEmailChange = viewModel::onEmailChange,
onPasswordChange = viewModel::onPasswordChange,
onFirstNameChange = viewModel::onFirstNameChange,
onLastNameChange = viewModel::onLastNameChange,
onModeToggle = viewModel::onModeToggle,
onLogin = viewModel::onLogin,
onRegister = viewModel::onRegister,
)
}

@Composable
fun LoginScreen(
state: LoginUiState,
onEmailChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onFirstNameChange: (String) -> Unit,
onLastNameChange: (String) -> Unit,
onModeToggle: () -> Unit,
onLogin: () -> Unit,
onRegister: () -> Unit,
modifier: Modifier = Modifier,
) {
val registering = state.mode == AuthMode.REGISTER
Column(
modifier.fillMaxSize().imePadding().verticalScroll(rememberScrollState()).padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(if (registering) "Create account" else "Log in", style = MaterialTheme.typography.headlineSmall)
if (registering) {
OutlinedTextField(
value = state.firstName, onValueChange = onFirstNameChange, label = { Text("First name") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.lastName, onValueChange = onLastNameChange, label = { Text("Last name") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
}
OutlinedTextField(
value = state.email, onValueChange = onEmailChange, label = { Text("Email") }, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.password, onValueChange = onPasswordChange,
label = { Text(if (registering) "Password (at least 6 characters)" else "Password") },
singleLine = true, visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), modifier = Modifier.fillMaxWidth(),
)
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(
onClick = if (registering) onRegister else onLogin,
enabled = state.canSubmit,
modifier = Modifier.fillMaxWidth(),
) {
if (state.isLoading) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
else Text(if (registering) "Register" else "Log in")
}
TextButton(onClick = onModeToggle, enabled = !state.isLoading, modifier = Modifier.align(Alignment.CenterHorizontally)) {
Text(if (registering) "Have an account? Log in" else "No account yet? Register")
}
}
}

A successful registration logs the user in straight away, because account/register returns a token just like account/login. canSubmit mirrors the backend's validation (a password of at least 6 characters, required names), so most 400s never happen. Navigation to LoginKey and back works as in lecture 11.

Token expiry​

A JWT has an expiry time inside it. When it passes, protected calls return 401. The v1 API has no refresh token, so:

  1. The sync code (below) sees the 401, calls tokenStore.clear() and stops.
  2. AuthRepository.isLoggedIn emits false. The UI shows "logged out, log in to sync" and offers LoginKey.
  3. Nothing recorded is lost. It's in the outbox and uploads after the next login.

If you want silent renewal, the backend's v2 account endpoints return { jwt, refreshToken } and offer RenewRefreshToken. Ktor's bearer { refreshTokens { ... } } callback is made for that. Store both tokens in TokenStore, and in refreshTokens call the renew endpoint with markAsRefreshTokenRequest(). It's optional for the project.

Local development: 10.0.2.2 and cleartext​

If you run your own backend on your laptop (e.g. the ASP.NET project from the web course), localhost inside the emulator is the emulator itself. The host machine is 10.0.2.2. A physical phone needs the laptop's LAN IP.

Android blocks plain http:// by default. Allow it only for the development host:

<!-- res/xml/network_security_config.xml -->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
</network-security-config>
<application
android:networkSecurityConfig="@xml/network_security_config"
... >

Never set android:usesCleartextTraffic="true" app-wide. The course backend is HTTPS and needs none of this.

Offline first: Room as the outbox​

The defence field test happens in a forest, and mobile data there comes and goes. If SportMap posted every location straight to the server and dropped it on failure, the backend track would have holes. The rule: write locally first, upload later.

The database row is the truth. "Synced" is just a flag on it. Whoever calls syncAll() (the running service or a WorkManager job) does the same thing: create the backend session if it has no backendId yet, then upload unsynced locations in batches and mark them. If it fails halfway, nothing is lost and the next call continues where this one stopped.

enum class SyncResult { DONE, RETRY, FAILED }

private const val ORIENTEERING_EASY = "00000000-0000-0000-0000-000000000003"

fun GpsSessionEntity.toCreateDto() = GpsSessionCreateDto(
name = name,
description = "Recorded with SportMap",
gpsSessionTypeId = ORIENTEERING_EASY,
recordedAt = startedAt.toString(),
paceMin = paceMinSecPerKm?.toDouble(),
paceMax = paceMaxSecPerKm?.toDouble(),
)

@Singleton
class SyncRepository @Inject constructor(
private val dao: SessionDao,
private val api: SportMapApi,
private val tokenStore: TokenStore,
) {
private val mutex = Mutex()

/** Uploads everything that is not on the server yet. Safe to call from anywhere, any time. */
suspend fun syncAll(): SyncResult = mutex.withLock {
if (tokenStore.token.first() == null) return@withLock SyncResult.DONE // not logged in: keep data for later
var result = SyncResult.DONE
for (sessionId in dao.sessionIdsWithUnsyncedLocations()) {
when (syncSession(sessionId)) {
SyncResult.DONE -> Unit
SyncResult.RETRY -> return@withLock SyncResult.RETRY // offline or server down: stop for now
SyncResult.FAILED -> result = SyncResult.FAILED // this session was rejected: try the others
}
if (tokenStore.token.first() == null) return@withLock SyncResult.FAILED // a 401 logged us out
}
result
}

private suspend fun syncSession(sessionId: Long): SyncResult {
val session = dao.getSession(sessionId) ?: return SyncResult.DONE
val backendId = session.backendId
?: when (val created = apiCall { api.createSession(session.toCreateDto()) }) {
is ApiResult.Success -> created.data.id.also { dao.setBackendId(sessionId, it) }
is ApiResult.HttpError -> return onHttpError(created.status)
is ApiResult.NetworkError -> return SyncResult.RETRY
}
while (true) {
val batch = dao.unsyncedLocations(sessionId, limit = 200)
if (batch.isEmpty()) return SyncResult.DONE
val dtos = batch.map { it.toDomain().toCreateDto() }
when (val sent = apiCall { api.addLocations(backendId, dtos) }) {
is ApiResult.Success -> dao.markSynced(batch.map { it.id })
is ApiResult.HttpError -> return onHttpError(sent.status)
is ApiResult.NetworkError -> return SyncResult.RETRY
}
}
}

private suspend fun onHttpError(status: Int): SyncResult = when {
status == 401 -> { tokenStore.clear(); SyncResult.FAILED } // token expired: log in again
status >= 500 -> SyncResult.RETRY // server trouble: later
else -> SyncResult.FAILED // 400: our data is wrong, retrying won't help
}
}
  • The Mutex guarantees one sync at a time inside the process. The service and a WorkManager job can both call syncAll(), and the second simply waits, then finds nothing left to do. Without it, both would upload the same rows.
  • The while loop drains: locations inserted while a batch is in flight are picked up by the next iteration.
  • Batches of 200 keep requests small enough for a bad connection. One bulk request is also far cheaper on battery than 200 single ones.
  • One edge case remains. If the process dies after the server accepted a batch but before markSynced, that batch is uploaded twice. For a tracker that's acceptable, and you can explain it at the defence.

The session types are fixed, so a constant is fine. A nicer app fetches GpsSessionTypes once and lets the user pick the type when starting a session.

WorkManager​

Some work must eventually happen, even if the user closes the app, the phone reboots or the network is down for an hour. That's WorkManager: you describe the work and its conditions, and the system runs it when they're met, retries it with backoff, and remembers it across reboots.

The worker​

@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val syncRepository: SyncRepository,
) : CoroutineWorker(context, params) {

override suspend fun doWork(): Result = when (syncRepository.syncAll()) {
SyncResult.DONE -> Result.success()
SyncResult.RETRY -> Result.retry() // run again later, with backoff
SyncResult.FAILED -> Result.failure() // give up on this request
}
}

CoroutineWorker.doWork is a suspend function on a background dispatcher. WorkManager creates workers itself, so constructor injection needs Hilt's assisted factory: @HiltWorker plus @AssistedInject, where the two @Assisted parameters come from WorkManager and everything else from Hilt.

Hilt + WorkManager wiring​

WorkManager must use Hilt's factory to create workers. Give it a custom configuration in the Application:

@HiltAndroidApp
class SportMapApp : Application(), Configuration.Provider {

@Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var syncScheduler: SyncScheduler

override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()

override fun onCreate() {
super.onCreate()
syncScheduler.schedulePeriodic() // safety net
syncScheduler.syncNow() // anything left over from last time
}
}

and switch off the default initialisation, which would otherwise start WorkManager with the default factory before your configuration exists:

<!-- AndroidManifest.xml, inside <application>; the root element needs xmlns:tools="http://schemas.android.com/tools" -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>

If you forget either part, WorkManager can't construct SyncWorker (it has no no-argument constructor). The worker fails with "Could not create Worker" in Logcat, and nothing is ever uploaded.

Scheduling​

@Singleton
class SyncScheduler @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val workManager get() = WorkManager.getInstance(context)
private val constraints = Constraints(requiredNetworkType = NetworkType.CONNECTED)

/** Upload whatever is pending, as soon as there is a network. */
fun syncNow() {
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
workManager.enqueueUniqueWork("sync-now", ExistingWorkPolicy.KEEP, request)
}

/** Catch up every 15 minutes, the minimum period. */
fun schedulePeriodic() {
val request = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
.setConstraints(constraints)
.build()
workManager.enqueueUniquePeriodicWork("sync-periodic", ExistingPeriodicWorkPolicy.KEEP, request)
}
}
ConceptHere
ConstraintsNetworkType.CONNECTED: the job waits, without polling and without battery cost, until there is a network. Others: UNMETERED, setRequiresCharging, setRequiresBatteryNotLow.
Unique workOne name means at most one request. KEEP ignores a new request while one is pending or running, REPLACE cancels the old one, APPEND/APPEND_OR_REPLACE chain after it. KEEP plus the draining loop is right for "sync whatever is pending".
BackoffAfter Result.retry(): 30 s, 60 s, 120 s... (exponential, minimum 10 s).
Periodic workMinimum interval 15 minutes, and the system may shift it to save battery. It's a safety net, not a timer.
PersistenceEnqueued work survives app restarts and reboots.

syncNow() is called after login, when a session stops (the service does it in its stop path), and on app start.

Live sync during a session​

The milestones ask that "session start and location updates are synced realtime to backend service during active session", with a configurable interval (Settings.syncIntervalSec, where 0 means every update). The running foreground service does this itself, in its lifecycleScope. Lecture 15 explains why: from Android 16, jobs started while a foreground service runs count against the normal job quotas, so a WorkManager job per location would soon be throttled. Inside the service, the live upload is just a coroutine:

// TrackingService (lecture 15) - add
@Inject lateinit var syncRepository: SyncRepository
@Inject lateinit var syncScheduler: SyncScheduler

private fun startLiveSync() {
lifecycleScope.launch {
settingsRepository.settings
.map { it.syncIntervalSec }
.distinctUntilChanged()
.collectLatest { intervalSec ->
if (intervalSec == 0) {
// after every accepted fix
trackingRepository.state
.map { it.track.size }
.distinctUntilChanged()
.collect { syncRepository.syncAll() }
} else {
while (true) {
delay(intervalSec * 1_000L)
syncRepository.syncAll()
}
}
}
}
}

// in the stop path, after finishSession(...):
syncScheduler.syncNow() // WorkManager uploads the rest whenever the network allows
  • collectLatest restarts the inner loop when the user changes the interval in the middle of a session.
  • With interval 0, collect waits for each syncAll(). Fixes arriving meanwhile collapse into one trigger (StateFlow conflates), so a slow network doesn't build a queue.
  • Offline, syncAll() returns RETRY after the timeout, the rows stay unsynced, and the next trigger tries again. When the connection returns, the next call uploads everything in batches. That's the "sync all the locations later" requirement.
  • The service starts startLiveSync() together with location updates. The coroutine dies with the service.
  • Posting directly (api.addLocation(...) per fix) without the outbox looks simpler, but every dead zone becomes a permanent hole in the track.

Inspecting network and background work​

  • App Inspection → Network Inspector: every OkHttp request (so every Ktor request) with headers, body, timing and response. The fastest way to see why the backend says 400.
  • App Inspection → Background Task Inspector: every WorkManager job with state (enqueued, running, retrying, succeeded, failed), constraints and its chain. Turn the emulator's network off and watch sync-now wait.
  • The emulator's extended controls (Cellular) simulate slow or absent networks. Flight mode on a real phone works too.
  • adb shell dumpsys jobscheduler | grep ee.taltech.sportmap shows the scheduled jobs underneath WorkManager.
  • Logcat with the Ktor Logging plugin at LogLevel.BODY shows the JSON. Switch back to INFO before the field test.
Retrofit

Many tutorials and most AI-generated Android networking code use Retrofit: an annotated interface (@GET("GpsSessionTypes") suspend fun sessionTypes(): List<GpsSessionTypeDto>), OkHttp interceptors for the bearer header, and a kotlinx.serialization or Moshi converter. It's a perfectly good library. The concepts map one to one: DTOs, one client, suspend calls, the same error handling. This course uses Ktor because the same client code also runs on iOS (Kotlin Multiplatform). Don't mix both in one project.

Final project checklist​

Every requirement and milestone of the final project, and where it is taught. "You" means it's your own design and work, built on those lectures.

RequirementLectures
Map displayed, own location updated on it13, 16
Own position shown before tracking starts16, 12 (location permission first)
Toggle "keep map centered"16 (camera), 10 (Settings.keepCentered)
North-up / direction-up / user-chosen-up16, 17 (compass bearing), 10 (MapOrientation)
Trail drawn from start to the current location while tracking13, 15, 16
Checkpoints (permanent) and a single waypoint (a new one replaces the old)13 (TrackingRepository), 16 (markers), 14 (from the notification), 15/18 (WP stored as a type = WP row, never a permanent marker)
Direct and travelled distance from start, last CP and WP13 (TrackMath, SectionStats)
Elapsed time and pace (min/km) in all three sections13 (formatters), 05 (stats layout)
Sessions and checkpoints saved to the database18, 15
View/delete old sessions, statistics and track on the map18, 16, 11
Rename old sessions18
Zoom into the track of an old session16
Sticky custom-layout notification, UI like the stats under the map14
Notification updated after every position update14, 15
Stop asks for confirmation; no start/stop from the lock screen14 (CONFIRM_STOP, actions), 15, 07 (dialog), 11 (intent → state)
GPS listener and notification broadcast receiver in a service15, 14, 13
App works in the background, UI restored after unlock15, 06, 10
Rotation supported, state restored, buttons move to the side in landscape06, 07, 10
All screen sizes, including tablets07, 11 (list-detail)
Real on-screen compass, can be switched on and off17, 10 (Settings.compassVisible)
Track segments coloured by pace, current and old sessions16, 13 (segment pace)
Pace colour range configurable globally and per session10 (Settings), 18 (setPaceRange, PaceRangeEditor on the detail screen), 16
GPX export with every location, WP/CP coordinates and timestamps, as an email attachment18 (WPs come from the stored type = WP rows, 15)
Location, notification and foreground-service permissions12, 15
User account created in the app, account info saved locally19 (onRegister, TokenStore)
Session start and locations synced in real time19, 15
Configurable sync interval19, 10
Configurable GPS update frequency13, 10
Obviously wrong coordinates filtered out13 (isPlausible)
No crash during a 2-3 h session15, 18 (unfinished sessions), 08 (structured concurrency)
Sync later when there was no connection19, 18 (synced flag)
Meaningful app icon07
Photos of every CP, track sent to the teacher18 (TakePicture, share GPX) - or the phone's camera app
Screen designs, layout decisions, colours, wordingyou (05, 07 give the tools)
Testing on a real device, the field test, defending your codeyou

In the final project​

  • SportMapApi, DTOs and NetworkModule are the whole network layer. Nothing outside data/network sees Ktor.
  • Login/register screens store the account in DataStore. Recording works without an account, and everything uploads after login.
  • During a session the service uploads live at the chosen interval. Afterwards, and whenever the network returns, SyncWorker uploads the rest.
  • Watch your session appear live on sportmap.akaver.com while you walk around the building. That's the best end-to-end test before the forest.
  • At the defence, be ready to show the flow of one location from GPS to the backend: service → filter → TrackingRepository + Room → SyncRepository → bulk POST → synced = 1.

Self preparation QA​

  1. Why use separate DTO classes instead of sending Room entities or domain objects as JSON? — The wire format belongs to the backend and changes independently. DTOs isolate those changes, and mapping functions keep entities and the UI untouched.
  2. What do ignoreUnknownKeys = true and expectSuccess = true do? — The first lets deserialisation skip JSON fields the DTO doesn't have, so new backend fields don't crash the app. The second turns non-2xx responses into exceptions, so errors can't be mistaken for successful responses.
  3. Why does TokenStore exist next to AuthRepository? — The HTTP client needs the token and AuthRepository needs the client. Putting token storage in its own class both depend on breaks the dependency cycle.
  4. Why must apiCall rethrow CancellationException, and why not just use runCatching? — Swallowing it would make a cancelled coroutine continue as if the call had failed, which breaks structured concurrency. runCatching catches it too.
  5. What should the app do on a 401 from the sportmap backend, and what on a 404 from login? — 401: the token is invalid or expired, so clear it, show "log in again" and keep the unsynced data. 404 from login means wrong credentials, so show an error message.
  6. What is the "outbox" in SportMap, and why is it better than posting each location directly? — The gps_location rows with synced = 0. Data is written locally first and uploaded when possible, so dead zones never lose data and failed uploads are simply retried.
  7. Why does SyncRepository use a Mutex? — The service and WorkManager may call syncAll() at the same time. The mutex serialises them, so the same rows aren't uploaded twice.
  8. What does NetworkType.CONNECTED change for a SyncWorker, and what does Result.retry() do? — The job only starts when a network is available, and it waits without polling. retry() reschedules it with the configured backoff.
  9. Why is the live upload done in the foreground service and not as a WorkManager job per location? — On Android 16, jobs started while a foreground service runs count against the normal job quotas, so a stream of jobs gets throttled. The service is already running, so a coroutine there is free. WorkManager remains the guaranteed catch-up.
  10. What two things must you do so WorkManager can create a @HiltWorker? — Implement Configuration.Provider in the Application with HiltWorkerFactory, and remove the default WorkManagerInitializer from the manifest.