Skip to main content

3 posts tagged with "Kotlin Coroutines"

View All Tags

On-Device LLMs: Building Responsive Android Features with Gemini Nano and Google AI Edge SDK

Published: · 13 min read
Sandra Rosa Antony
Software Engineer, Appxiom

Modern Android apps increasingly need AI features that feel instant, private, and resilient offline. Cloud LLMs are great for heavy lifting, but even with streaming, latency and connectivity are real-world constraints. On-device LLMs like Gemini Nano close that gap by keeping inference local: lower latency, no data egress, and graceful offline behavior.

In this post, we’ll build a production-ready, end-to-end “Summarize Note” feature that runs on-device with Gemini Nano via Google’s AI Edge SDK, and automatically falls back to the cloud when on-device isn’t available. We’ll focus on a cohesive feature with modern Android patterns, streaming UI, cancellation, model readiness, and failure handling.

Prerequisites

  • Android Studio: Koala (2024.1.1) or newer
  • Kotlin: 2.0.x
  • Coroutines: 1.8.x
  • Min SDK: 26
  • Target/Compile SDK: 34 or newer
  • Device: Pixel with AICore and Gemini Nano support (e.g., compatible Pixel devices); or Emulator for cloud fallback
  • Network: Only needed for the fallback path and for initial model download by the system

Dependencies (Gradle)

  • Kotlin coroutines
    • implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
  • Lifecycle
    • implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.3"
  • Compose (optional for the UI)
    • implementation platform("androidx.compose:compose-bom:2024.06.00")
    • implementation "androidx.compose.ui:ui"
    • implementation "androidx.compose.material3:material3"
  • Hilt (optional, used below)
    • implementation "com.google.dagger:hilt-android:2.51.1"
    • kapt "com.google.dagger:hilt-android-compiler:2.51.1"
  • Google AI Client (cloud fallback)
    • implementation "com.google.ai.client.generativeai:generativeai:0.9.0"
  • Google AI Edge SDK (on-device)
    • Add the latest AI Edge SDK dependency as specified in the official Google AI Edge SDK documentation for Gemini Nano on Android (AICore). Use the latest stable version available at the time you integrate. This provides the on-device text generation client you’ll wire into the Edge provider shown below.

Important: Version names for AI Edge SDK evolve. Keep them updated from the official docs to ensure compatibility with your Android version and AICore.

What we’re building

We’ll implement an MVVM-based feature that:

  • Accepts a long note
  • Streams a concise summary into the UI as it’s generated
  • Uses Gemini Nano on-device when available
  • Falls back to Gemini API in the cloud when Nano is not ready/available
  • Supports cancellation and timeouts
  • Handles edge cases like huge inputs, missing models, and low-memory

We’ll keep the LLM integration behind a small abstraction so you can test, swap providers, and evolve capabilities without refactoring the app.

Architecture overview

  • UI (Compose): Displays text, progress, and tokens as they arrive; provides Cancel/Retry
  • ViewModel: Owns UI state, starts/cancels work, debounces input
  • Repository: Chooses the best LLM provider (Edge vs Cloud), streams results
  • Providers:
    • EdgeTextGenerator: On-device via AI Edge SDK (Gemini Nano)
    • CloudTextGenerator: Cloud via Gemini API SDK
  • Model readiness: EdgeCapabilityChecker verifies on-device availability before use

The domain API

We define a small, streaming-first API that both on-device and cloud providers implement.

data class GenerationParams(
val maxOutputTokens: Int = 256,
val temperature: Float = 0.2f, // deterministic summaries
val systemInstruction: String? = null
)

sealed interface StreamEvent {
data class Token(val text: String) : StreamEvent
data class Complete(val fullText: String) : StreamEvent
data class Error(val throwable: Throwable) : StreamEvent
}

interface TextGenerator {
fun streamSummary(
input: String,
params: GenerationParams = GenerationParams()
): kotlinx.coroutines.flow.Flow<StreamEvent>
}

This API allows us to:

  • Stream partial tokens
  • Signal completion with the cumulative text
  • Handle errors distinctly
  • Keep configuration centralized

Provider selection and capability checks

We gate the on-device path safely and transparently.

interface EdgeCapabilityChecker {
suspend fun isOnDeviceReady(): Boolean
suspend fun reasonIfUnavailable(): String? // optional details for analytics/UX
}

Implementation notes:

  • Prefer the AI Edge SDK/AICore-provided capabilities check (e.g., model availability, downloaded/versioned) rather than ad-hoc package checks.
  • If the model is not ready, either prompt the user to enable the feature or silently use cloud fallback depending on your UX policy.
  • If you want proactive model availability, use a background task (WorkManager) to trigger/verify model readiness while on unmetered Wi‑Fi and charging, respecting user settings.

On-device provider (Gemini Nano via AI Edge SDK)

Below is a skeleton that demonstrates how you wire the AI Edge SDK into our TextGenerator interface. Replace the “TODO” calls with the actual AI Edge SDK client construction and streaming calls exactly as described in the SDK docs you’re using.

class EdgeTextGenerator(
private val appContext: android.content.Context
) : TextGenerator {

// Consider reusing a single client/session instance (lifecycle-aware) to amortize model load
// cost and reduce memory thrash. Build it lazily and close when not needed.
private val lock = Any()
@Volatile private var client: /* Edge SDK text generation client type */ Any? = null

private fun ensureClient(): /* client type */ Any {
synchronized(lock) {
val cached = client
if (cached != null) return cached as /* client type */

// TODO: Construct the Edge/Nano text generation client.
// This typically requires:
// - Obtaining an AICore/AI Edge SDK session bound to Gemini Nano
// - Supplying generation configuration
// - Handling model download/readiness if the SDK exposes it here

val created = /* Edge client construction */
client = created
return created
}
}

override fun streamSummary(
input: String,
params: GenerationParams
) = kotlinx.coroutines.flow.callbackFlow<StreamEvent> {
try {
val prompt = buildPromptForSummary(input)

// TODO: Start streaming generation using the Edge SDK.
// Pseudocode:
// val session = ensureClient().startSession(params.toEdgeConfig())
// session.generateStream(prompt).collect { token ->
// trySend(StreamEvent.Token(token))
// }
// trySend(StreamEvent.Complete(collectedText))

// Make sure to respect coroutine cancellation (use isActive checks or the SDK’s cancel APIs)
} catch (t: Throwable) {
trySend(StreamEvent.Error(t))
}
awaitClose {
// TODO: cancel active streaming request if the SDK supports it
}
}

private fun buildPromptForSummary(input: String): String {
// Keep prompts short and deterministic for on-device models
return """
Summarize the following note into 3 concise bullet points.
Focus on key facts and decisions. Avoid filler.

Note:
$input
""".trimIndent()
}
}

Key production considerations for the on-device path:

  • Session reuse: Create and reuse a session to avoid repeated warm-up time. Close it when the feature is idle.
  • Cancellation: Expose cancellation so swipes/back/rotations don’t keep the model busy.
  • Memory: Gemini Nano is lightweight relative to cloud LLMs, but still watch memory. Consider an upper bound on input size; pre-truncate to a token budget and inform the user if needed.
  • Threading: The SDK typically is non-blocking, but keep heavy work off the main thread.
  • Version pin: If the SDK allows specifying a runtime model version, pin to a tested version and roll forward with QA.

Cloud fallback provider (Gemini API via Google AI Client)

The cloud path uses the official Gemini client and supports streaming. This code is production-grade and known-good.

import com.google.ai.client.generativeai.GenerativeModel
import com.google.ai.client.generativeai.type.generationConfig
import com.google.ai.client.generativeai.type.content
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.isActive

class CloudTextGenerator(
apiKey: String,
modelName: String = "gemini-1.5-flash"
) : TextGenerator {

private val model = GenerativeModel(
modelName = modelName,
apiKey = apiKey
)

override fun streamSummary(
input: String,
params: GenerationParams
): Flow<StreamEvent> = callbackFlow {
val prompt = buildPromptForSummary(input)
val config = generationConfig {
temperature = params.temperature.toDouble()
maxOutputTokens = params.maxOutputTokens
}
val request = content { text(prompt) }

// Streaming
try {
val sb = StringBuilder()
val stream = model.generateContentStream(
request,
generationConfig = config
)

// Collect chunks; the SDK provides a Flow-like stream
val job = kotlinx.coroutines.GlobalScope.launch {
try {
stream.collect { chunk ->
if (!isActive) return@collect
val delta = chunk.text.orEmpty()
if (delta.isNotEmpty()) {
sb.append(delta)
trySend(StreamEvent.Token(delta))
}
}
trySend(StreamEvent.Complete(sb.toString()))
} catch (t: Throwable) {
trySend(StreamEvent.Error(t))
}
}

awaitClose { job.cancel() }
} catch (t: Throwable) {
trySend(StreamEvent.Error(t))
close(t)
}
}

private fun buildPromptForSummary(input: String): String {
return """
Summarize this note into 3 concise bullet points.
Use neutral tone and keep each bullet under 20 words.

Note:
$input
""".trimIndent()
}
}

Notes:

  • Use 1.5-Flash for UI-latency-sensitive features; bump to Pro only when necessary and you can tolerate higher latency/cost.
  • Set a max token budget and temperature appropriate for deterministic summaries.

Repository: choose Edge vs Cloud at runtime

class SummarizerRepository(
private val edgeChecker: EdgeCapabilityChecker,
private val edge: TextGenerator,
private val cloud: TextGenerator
) {

fun streamSummary(input: String, params: GenerationParams): kotlinx.coroutines.flow.Flow<StreamEvent> =
kotlinx.coroutines.flow.flow {
// Guardrails
val sanitized = sanitizeInput(input)
require(sanitized.isNotBlank()) { "Input is empty after sanitization." }

val useOnDevice = runCatching { edgeChecker.isOnDeviceReady() }.getOrElse { false }
val source = if (useOnDevice) edge else cloud

emitAll(source.streamSummary(sanitized, params))
}

private fun sanitizeInput(input: String): String {
// Boundary control: prevent absurdly large prompts, avoid OOM
val maxChars = 6_000 // tune for your app; align with token limits
return input.trim().take(maxChars)
}
}

ViewModel with cancellation, timeouts, and UI state

data class SummaryUiState(
val inProgress: Boolean = false,
val streamedText: String = "",
val error: String? = null
)

@dagger.hilt.android.lifecycle.HiltViewModel
class SummaryViewModel @javax.inject.Inject constructor(
private val repository: SummarizerRepository,
@javax.inject.Named("defaultParams") private val defaultParams: GenerationParams
) : androidx.lifecycle.ViewModel() {

private val _state = kotlinx.coroutines.flow.MutableStateFlow(SummaryUiState())
val state: kotlinx.coroutines.flow.StateFlow<SummaryUiState> = _state

private var currentJob: kotlinx.coroutines.Job? = null

fun summarize(note: String) {
currentJob?.cancel()
_state.value = SummaryUiState(inProgress = true)

currentJob = viewModelScope.launch {
// Optional timeout for UX responsiveness (tune as needed)
withTimeoutOrNull(15_000L) {
repository.streamSummary(note, defaultParams)
.collect { event ->
when (event) {
is StreamEvent.Token -> {
_state.update { it.copy(streamedText = it.streamedText + event.text) }
}
is StreamEvent.Complete -> {
_state.update { it.copy(inProgress = false, streamedText = event.fullText) }
}
is StreamEvent.Error -> {
_state.update { it.copy(inProgress = false, error = event.throwable.message) }
}
}
}
} ?: run {
_state.update { it.copy(inProgress = false, error = "Timed out. Try again.") }
}
}
}

fun cancel() {
currentJob?.cancel()
_state.update { it.copy(inProgress = false) }
}

override fun onCleared() {
currentJob?.cancel()
}
}

Compose UI that streams cleanly

@Composable
fun SummarizeNoteScreen(
viewModel: SummaryViewModel = androidx.hilt.navigation.compose.hiltViewModel()
) {
val state by viewModel.state.collectAsState()
var noteText by remember { mutableStateOf("") }

Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = noteText,
onValueChange = { noteText = it },
label = { Text("Paste note") },
modifier = Modifier.fillMaxWidth().weight(1f, fill = false),
minLines = 6
)
Spacer(Modifier.height(12.dp))
Row {
Button(
onClick = { viewModel.summarize(noteText) },
enabled = noteText.isNotBlank() && !state.inProgress
) { Text("Summarize") }

Spacer(Modifier.width(8.dp))

OutlinedButton(
onClick = { viewModel.cancel() },
enabled = state.inProgress
) { Text("Cancel") }
}
Spacer(Modifier.height(16.dp))

when {
state.inProgress -> {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(8.dp))
Text(
text = state.streamedText.ifBlank { "Generating..." },
style = MaterialTheme.typography.bodyLarge
)
}
state.error != null -> {
Text(
text = state.error ?: "Something went wrong",
color = MaterialTheme.colorScheme.error
)
}
state.streamedText.isNotBlank() -> {
Text(
text = state.streamedText,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}

Wiring it all up (DI)

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

@Provides
@Singleton
fun provideEdgeCapabilityChecker(
app: Application
): EdgeCapabilityChecker = object : EdgeCapabilityChecker {
override suspend fun isOnDeviceReady(): Boolean {
// Prefer AI Edge SDK’s official readiness check, which may expose:
// - model availability
// - version compatibility
// - minimum device capabilities
// Avoid guessing via package manager.
// return edgeSdk.checkModelReady(model = GeminiNano)

// Placeholder until you wire the real call:
return false
}

override suspend fun reasonIfUnavailable(): String? = null
}

@Provides
@Singleton
@Named("defaultParams")
fun provideDefaultParams(): GenerationParams = GenerationParams(
maxOutputTokens = 256,
temperature = 0.2f
)

@Provides
@Singleton
fun provideEdgeTextGenerator(app: Application): TextGenerator =
EdgeTextGenerator(app)

@Provides
@Singleton
fun provideCloudTextGenerator(): TextGenerator =
CloudTextGenerator(
apiKey = BuildConfig.GEMINI_API_KEY,
modelName = "gemini-1.5-flash"
)

@Provides
@Singleton
fun provideSummarizerRepository(
checker: EdgeCapabilityChecker,
edge: TextGenerator,
cloud: TextGenerator
): SummarizerRepository = SummarizerRepository(checker, edge, cloud)
}

Security note: Store API keys securely (do not hardcode). Use remote config or secure storage, and apply network privacy best practices. For on-device/Nano-only experiences, you do not need an API key.

Prompt engineering for on-device

  • Keep prompts short, explicit, and deterministic. Lower temperature.
  • Constrain output with explicit format (e.g., “3 bullet points”, length limits).
  • Pre-truncate inputs to a safe size.
  • Avoid complex multi-step instructions that explode token usage and latency.

Example prompt snippet (used above):

  • “Summarize into 3 concise bullet points”
  • “Each bullet under 20 words”
  • “Neutral tone; focus on key facts and decisions”

Performance, UX, and reliability

  • Warm-up: First request may be slower as the model initializes. Consider lazy pre-warm when user navigates to the screen.
  • Streaming: Always stream to keep the UI alive; avoid waiting for the final token for first paint.
  • Cancellation: Support cancellation when navigating away or if the user edits text mid-generation.
  • Timeouts: Use a user-facing timeout; surface actionable failures (“Model not available offline”, “Timed out”, “No network”).
  • Battery and thermal: Don’t loop long generations in the background; use the SDK’s guidance for batching and scheduling. Prefer generating during user interaction, not in the background.

Privacy and data handling

  • On-device path: Data stays on-device; no internet needed. Communicate this clearly in your UX for sensitive notes.
  • Cloud fallback: Clearly disclose network usage and handle PII responsibly (consent, redaction).
  • Logging: Never log raw user content in production logs. Gate debug logs behind build flags.

Testing strategy

  • Unit test repository selection logic (Edge available vs not).
  • Contract test the provider interface with fake generators that emit controlled streams.
  • Integration test on devices with and without Nano support.
  • Load test with large notes to uncover memory/latency issues.
  • UI tests: Assert that streaming renders incrementally and that Cancel works.

Common mistakes and how to fix them

  • Model not ready on-device:
    • Symptom: Immediate error from Edge SDK or null client/session.
    • Fix: Use the SDK’s readiness check and expose a “Download/Enable” affordance or auto-fallback to cloud. Avoid relying on package presence checks.
  • Jank during first token:
    • Symptom: 200–600 ms UI hitch when session warms up.
    • Fix: Pre-warm lazily (e.g., when screen becomes visible) on a background dispatcher. Reuse sessions.
  • OOM or ANR with huge inputs:
    • Symptom: Memory spikes or freezes processing long notes.
    • Fix: Enforce strict input size limits. Chunk and summarize incrementally if needed (map-reduce pattern).
  • No token streaming in UI:
    • Symptom: Empty UI until completion.
    • Fix: Ensure you’re using the streaming API; append partial tokens; flush to Compose state.
  • Timeout too aggressive:
    • Symptom: Frequent timeouts on low-end devices.
    • Fix: Increase timeout and/or reduce max output tokens. Consider device-class heuristics.
  • Cloud API key exposed:
    • Symptom: Static key in client or VCS.
    • Fix: Use server-side proxy or secured remote config, obfuscation, and app attestation.

Rolling out safely

  • Feature flags: Gate the entire feature behind a remote flag for staged rollout.
  • Device targeting: Gradually enable on supported devices first.
  • Metrics: Track latency, token count, session warm-up time, error rates, cloud fallback rate, and cancellations.
  • Crash/ANR monitoring: Pay attention to OOM signatures and binder-related issues if the Edge SDK uses a system service.

When to choose on-device vs cloud

  • On-device (Gemini Nano) is ideal for:

    • Short-form generation and rewriting (summaries, smart replies, autofill hints)
    • Privacy-sensitive content
    • Latency-critical UX
    • Offline and spotty connectivity
  • Cloud (Gemini API) is better for:

    • Larger context windows
    • Heavier reasoning or multimodal tasks
    • Team workflows where server-side policy and auditing are required

A hybrid approach (as we implemented) is the most resilient.

Final notes on the AI Edge SDK integration

Because the AI Edge SDK and AICore are evolving, refer to the official SDK documentation for:

  • Exact Gradle artifacts and versions
  • Model/download readiness APIs
  • Client/session construction code and lifecycle
  • Streaming function names and cancellation hooks
  • Supported Android/Device matrix and required permissions (generally none additional for on-device)

Keep all these calls isolated in your EdgeTextGenerator and EdgeCapabilityChecker. This limits churn when you upgrade the SDK.

Key takeaways

  • On-device LLMs like Gemini Nano unlock responsive, private, and offline-friendly features.
  • Architect behind a small TextGenerator interface to swap Edge and Cloud without refactoring.
  • Stream results to the UI for fast first paint; handle cancellation, timeouts, and input limits.
  • Proactively check model readiness and implement a clean cloud fallback path.
  • Treat performance, privacy, and reliability as first-class concerns - just like any other production feature.

Next steps

  • Wire the AI Edge SDK client code into EdgeTextGenerator using the latest docs and test on a Nano-capable device.
  • Add a WorkManager task to prime model readiness on unmetered Wi‑Fi and charging.
  • Extend the feature with “Actionable Summary” output format (e.g., JSON with decisions, dates, and owners) and render structured chips in the UI.
  • Measure and optimize: instrument warm-up time, token throughput, and fallback frequency; iterate on prompts and parameters for quality and speed.

Fixing Android 17 Foreground Service Type Errors: A Practical WorkManager Migration Guide

Published: · 9 min read
Don Peter
Cofounder and CTO, Appxiom

Foreground services used to be the Swiss army knife for “do something now and don’t get killed.” Starting in Android 13 and tightened in 14–15 (and again in recent Android 17 previews), foreground services must declare and use the correct types, hold the right permissions, and start within strict timing windows. Many apps now crash with type mismatches, missing permissions, or background-start exceptions.

If your app still relies on foreground services for deferrable work (uploads, syncs, backups), the correct fix isn’t “pick the right type and pray.” It’s to migrate to WorkManager and only use foreground mode when it’s truly warranted. This post shows a practical, production-grade migration path you can drop into a real app.

Prerequisites

  • Android Studio Koala or newer
  • Kotlin 1.9.20+ (Kotlin 2.x is fine)
  • AGP 8.3+ (8.5+ recommended)
  • compileSdk = 35 (Android 15), targetSdk = 35
  • minSdk = 23+
  • WorkManager 2.9.1

Gradle dependencies:

dependencies {
implementation("androidx.work:work-runtime-ktx:2.9.1")
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")
// Optional for Compose sample UI
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.4")
}

The real-world problem

You might see one or more of these on Android 14+ or recent Android 17 builds:

  • SecurityException: Missing required permission for foreground service type: FOREGROUND_SERVICE_DATA_SYNC
  • java.lang.IllegalArgumentException: Service did not specify foregroundServiceType
  • ForegroundServiceStartNotAllowedException: startForegroundService() not allowed while background
  • AppNotResponding or kill due to late startForeground() call

These aren’t intermittent “OEM quirks.” They’re the platform telling you to stop using foreground services for deferrable, batchable work.

When to keep a Foreground Service (FGS) vs. migrate

Keep a dedicated FGS only when the system says it’s the right tool:

  • Media playback (mediaPlayback)
  • Active navigation and continuous location/fitness tracking (location)
  • Phone calls/VoIP (phoneCall)
  • Screen capture/media projection (mediaProjection)
  • Remote messaging and certain accessibility/system-exempted flows

Everything else (uploads, data sync, backups, log shipping, prefetch, long computations) should be WorkManager.

Target feature for migration: reliable photo upload with progress

Scenario:

  • User selects a batch of photos to upload.
  • Work should respect constraints (unmetered network, device charging if desired).
  • Show progress when user stays in-app; continue reliably across process death and device reboots.
  • Comply with Android 14–17 foreground service restrictions.

We’ll migrate from a legacy FGS to WorkManager.

What the old code probably looks like (anti-pattern)

Manifest:

<service
android:name=".upload.UploadService"
android:exported="false"
android:foregroundServiceType="dataSync" />

Service:

class UploadService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(NOTIFICATION_ID, buildNotification())
// Long-running upload on a thread...
// Crashes on 14+ if types/permissions mismatch or background-start happens.
return START_NOT_STICKY
}
}

Common failures:

  • Missing uses-permission for the declared type.
  • Background-start blocked on newer Android.
  • Timing windows missed if uploads initialize slowly.
  • The service outlives the app process and gets killed mid-upload.

The modern approach with WorkManager

  • Use Worker for deferrable uploads.
  • Enter foreground mode only while needed using ForegroundInfo.
  • Correctly declare the foreground service type and permission only if you actually run in foreground.
  • Let WorkManager handle process death, retries, constraints, and OS quotas.

Step 1: Manifest cleanup

Remove the legacy service declaration. Then add only the permissions you truly need.

If you plan to ever run the upload in foreground (e.g., immediate user-triggered large batch with visible progress), you must add the corresponding FGS permission for your chosen type on Android 14+.

For data sync uploads:

<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

If you don’t plan to run in foreground mode at all, do not add FGS permissions or types.

For notifications on Android 13+:

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

Create a notification channel at app startup:

fun ensureUploadChannel(context: Context) {
if (Build.VERSION.SDK_INT >= 26) {
val channel = NotificationChannel(
"upload",
"Uploads",
NotificationManager.IMPORTANCE_LOW
).apply { description = "Background uploads" }
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}

Step 2: Define the Worker

We’ll implement a robust UploadWorker with:

  • Constraints (e.g., unmetered network)
  • Exponential backoff
  • Foreground mode with correct type when needed
  • Progress updates
class UploadWorker(
appContext: Context,
params: WorkerParameters,
private val uploader: PhotoUploader, // Inject via DI; shown simplified
) : CoroutineWorker(appContext, params) {

override suspend fun getForegroundInfo(): ForegroundInfo {
// Only called if we entered foreground via setForeground()
return buildForegroundInfo(progress = 0)
}

override suspend fun doWork(): Result {
// Input: list of photo URIs
val uris = inputData.getStringArray(KEY_URIS)?.toList().orEmpty()
if (uris.isEmpty()) return Result.success()

// Decide whether to use foreground mode for this run (e.g., user-initiated large batch)
val needsImmediateUserVisible = inputData.getBoolean(KEY_USER_INITIATED, false)
if (needsImmediateUserVisible) {
setForeground(buildForegroundInfo(progress = 0))
}

try {
var completed = 0
for (uri in uris) {
setProgress(workDataOf(KEY_PROGRESS to ((completed * 100) / uris.size)))
uploader.upload(uri) // make it suspend, chunked, cancellable
completed++
if (needsImmediateUserVisible) {
setForeground(buildForegroundInfo(progress = ((completed * 100) / uris.size)))
}
}
setProgress(workDataOf(KEY_PROGRESS to 100))
return Result.success()
} catch (e: IOException) {
// Network/server issue: retry with backoff
return Result.retry()
} catch (e: CancellationException) {
throw e // cooperatively cancel
} catch (t: Throwable) {
// Unexpected, don't loop forever
return Result.failure()
}
}

private fun buildForegroundInfo(progress: Int): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, "upload")
.setSmallIcon(R.drawable.ic_upload)
.setContentTitle("Uploading photos")
.setContentText("$progress%")
.setOnlyAlertOnce(true)
.setOngoing(true)
.setProgress(100, progress, false)
.build()

// You must set the correct foreground service type on Android 14+ if you use foreground mode
return ForegroundInfo(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
}

companion object {
const val KEY_URIS = "uris"
const val KEY_USER_INITIATED = "user_initiated"
const val KEY_PROGRESS = "progress"
const val NOTIFICATION_ID = 42
}
}

WorkerFactory or Hilt can provide PhotoUploader; omitted for brevity.

Register the worker with WorkManager (if you use a custom WorkerFactory, wire it in your Configuration).

Step 3: Enqueue unique work with constraints and backoff

object UploadWork {
private const val UNIQUE_NAME = "photo_upload"

fun buildRequest(
uris: List<String>,
userInitiated: Boolean
): OneTimeWorkRequest {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // or CONNECTED if you allow metered
.build()

return OneTimeWorkRequestBuilder<UploadWorker>()
.setConstraints(constraints)
.addTag(UNIQUE_NAME)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30, TimeUnit.SECONDS
)
.setInputData(
workDataOf(
UploadWorker.KEY_URIS to uris.toTypedArray(),
UploadWorker.KEY_USER_INITIATED to userInitiated
)
)
// If user-initiated and you need it to start ASAP, consider expedited:
// .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
}

fun enqueue(
context: Context,
request: OneTimeWorkRequest
) {
WorkManager.getInstance(context).enqueueUniqueWork(
UNIQUE_NAME,
ExistingWorkPolicy.APPEND_OR_REPLACE, // sequentialize batches
request
)
}
}

Notes:

  • Use APPEND or APPEND_OR_REPLACE to serialize uploads and avoid duplicates.
  • Use expedited work only for truly immediate operations; quotas may downgrade it to non-expedited.

Step 4: Observe progress in a ViewModel and display in Compose

ViewModel:

class UploadViewModel(
private val appContext: Context
) : ViewModel() {

private val workManager = WorkManager.getInstance(appContext)
private val _workId = MutableStateFlow<UUID?>(null)

val progress = _workId
.flatMapLatest { id ->
if (id == null) flowOf(0) else
workManager.getWorkInfoByIdFlow(id).map {
it.progress.getInt(UploadWorker.KEY_PROGRESS, 0)
}
}
.stateIn(viewModelScope, SharingStarted.Lazily, 0)

fun startUpload(uris: List<String>, userInitiated: Boolean) {
val request = UploadWork.buildRequest(uris, userInitiated)
_workId.value = request.id
UploadWork.enqueue(appContext, request)
}

fun cancel() {
_workId.value?.let { workManager.cancelWorkById(it) }
}
}

Compose UI:

@Composable
fun UploadScreen(vm: UploadViewModel) {
val progress by vm.progress.collectAsState()

Column(Modifier.padding(16.dp)) {
Text("Upload progress: $progress%")
LinearProgressIndicator(progress / 100f)
Row {
Button(onClick = {
// Example: user picks 3 images
vm.startUpload(uris = listOf("content://a", "content://b", "content://c"), userInitiated = true)
}) { Text("Start upload") }
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = vm::cancel) { Text("Cancel") }
}
}
}

Why this avoids Android 17 FGS type errors

  • No background-started services. WorkManager coexists with JobScheduler and OS constraints, avoiding ForegroundServiceStartNotAllowedException.
  • If and only if you enter foreground mode (setForeground), you:
    • Provide a ForegroundInfo with the correct type (DATA_SYNC).
    • Hold the matching permission (FOREGROUND_SERVICE_DATA_SYNC) on Android 14+.
  • If the work doesn’t need a foreground session, you don’t request foreground service privileges at all.

Additional production considerations

  • Dependency injection: Inject your repository/uploader into Worker via Hilt’s HiltWorker or a custom WorkerFactory.
  • Large files: Stream, chunk, and resume on retry. Avoid buffering entire files in memory.
  • Network/backoff: Use exponential backoff + retry after 5xx/IO errors; treat 4xx as terminal.
  • Constraints: Prefer CONNECTED for general cases; use UNMETERED if required by product.
  • Foreground lifetime: Only call setForeground when actually doing long-running user-visible work. Drop back to background when possible to reduce user-facing churn and battery impact.
  • Unique work: Tag and use enqueueUniqueWork to prevent duplicate runs from rapid user taps.
  • App upgrades and process death: WorkManager persists requests; no custom boot receivers required.

Testing across Android 14–17

  • Deny notification permission on Android 13+ and ensure your UX still works. For foreground mode, guide the user to grant it if you want a richer notification; otherwise, prefer background execution without foreground mode for non-essential cases.
  • Force background and sleep conditions:
    • adb shell cmd appops set your.app RUN_IN_BACKGROUND ignore
    • adb shell cmd deviceidle force-idle
  • Verify foreground service types when used:
    • adb shell dumpsys activity services | grep SystemForegroundService
  • Validate constraints by toggling Wi‑Fi/metered, battery saver, and charging state.

Troubleshooting

  • Crash: Service did not specify foregroundServiceType
    • In WorkManager, this happens if you call setForeground without ForegroundInfo specifying a type on Android 14+. Always pass ForegroundInfo(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_XXX).
  • SecurityException: Missing required permission for foreground service type
    • Add the matching uses-permission for the type (e.g., FOREGROUND_SERVICE_DATA_SYNC). Only add it if you truly run foreground workers.
  • ForegroundServiceStartNotAllowedException
    • You’re starting a service from the background or without user initiation. Use WorkManager. Don’t call startForegroundService directly.
  • Work never runs on some OEM devices
    • Ensure you aren’t doing blocking I/O on the main thread inside doWork.
    • Remove unrealistic constraints. Check WorkManager initialization logs. Consider avoiding expedited work unless necessary.
  • Duplicated uploads
    • Use enqueueUniqueWork with APPEND/KEEP/REPLACE policy and meaningful tags.

Migration checklist

  • Identify all non-essential FGS use cases (uploads, syncs, backups).
  • Replace with WorkManager:
    • OneTimeWorkRequest + Constraints + Backoff
    • Optional foreground mode with correct ForegroundInfo + permission
  • Remove legacy service components from the manifest.
  • Only keep FGS for system-approved continuous use cases.
  • Add UI to observe WorkInfo and show user progress or state.
  • Test on Android 14/15 and latest 17 preview builds with background restrictions.

Key takeaways

  • Most “Android 17 foreground service type” crashes are a symptom of using FGS for work that should be WorkManager.
  • With WorkManager, you get reliability, OS compliance, and fewer sharp edges, while still retaining foreground mode for truly user-visible, long-running tasks.
  • If you must use foreground mode, always set the correct type and permission via ForegroundInfo; otherwise, omit FGS entirely.
  • Adopt constraints, retries, unique work, and progress reporting for a production-grade solution.

Next steps: Audit your app’s services, pick one deferrable feature (like uploads or sync), and migrate it using the patterns in this guide. Once you see the stability and policy-compliance improvements, repeat for the rest.

Diagnosing and Mitigating UI Thread Blocking to Prevent ANRs in Kotlin Android Apps

Published: · 5 min read
Sandra Rosa Antony
Software Engineer, Appxiom

Android ANRs surface when the main (UI) thread is blocked long enough that the system stops trusting the process to respond. This post focuses on practical ways to diagnose UI thread blocking and main thread starvation, and how automated monitoring (including Appxiom) can detect UI hangs and reduce ANR rates in production.

What causes ANR in Android: UI thread blocking and main thread starvation

ANR in Android typically occurs when:

  • Input dispatch isn't handled within ~5s (Activity/Window focus and touch).
  • Service starts/operations run too long (~10s).
  • BroadcastReceiver execution exceeds its time budget (~200ms).

Common sources of UI thread blocking in Kotlin Android development:

  • Disk I/O on the main thread (file, SharedPreferences, SQLite).
  • Network calls accidentally executed on Dispatchers.Main or via runBlocking.
  • Heavy JSON parsing, bitmap decoding, or crypto on the main thread.
  • Over-synchronization or long critical sections that block the Looper.
  • Infinite/long animations or tight loops starving the MessageQueue.
  • Excessive work at startup (inflation, reflection, content providers).

Main thread starvation can happen even if no single call is “huge” but the Looper is constantly busy (e.g., tight re-posts to Dispatchers.Main.immediate, hot loops, too many tiny messages).

Kotlin Android development best practices to prevent ANRs

  • Move disk/network/CPU-heavy work off the main thread with coroutines:
class UserRepo(
private val dao: UserDao,
private val api: Api
) {
suspend fun loadUser(userId: String): User = withContext(Dispatchers.IO) {
val local = dao.getUser(userId)
local ?: api.fetchUser(userId).also { dao.insert(it) }
}
}

// UI layer
lifecycleScope.launch {
try {
val user = repo.loadUser("42") // switches to IO for work
render(user) // back on Main
} catch (t: Throwable) {
showError(t)
}
}
  • Prefer Dispatchers.IO for blocking I/O, Dispatchers.Default for CPU work; keep Dispatchers.Main for minimal UI updates.
  • Avoid runBlocking on the main thread; prefer suspend functions and structured concurrency.
  • In Compose, use remember/derivedStateOf wisely; in Views, avoid deep nested layouts and heavy work in onDraw/onLayout.

Enable StrictMode in debug builds to catch disk/network on main:

if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build()
)
}

Manual debugging: systrace/Perfetto, logs, and ANR traces

  1. Use Perfetto (successor to systrace) to visualize UI thread blocking:
    • Record a trace while reproducing the issue.
    • Inspect Main thread slices, Binder calls, CPU scheduling, and Choreographer frames.
    • Add lightweight markers:
Trace.beginSection("decodeBitmap")
// expensive decode off Main
Trace.endSection()
  1. Inspect ANR traces:

    • Pull from bugreport or /data/anr/traces.txt (requires appropriate permissions).
    • Look for “main” thread stack at the time of ANR; identify blocking call (disk, network, lock).
  2. Log Looper messages and frame jank locally:

    • JankStats (Jetpack) provides frame-dropped info tied to states:
val jankStats = JankStats.createAndTrack(window) { frameData ->
if (frameData.isJank) {
Log.w("Jank", "Jank frame: ${frameData.frameDurationUiNanos} ns, states=${frameData.states}")
}
}
  1. Correlate with app logs:
    • Time-stamp major operations.
    • Log key thread names and durations (e.g., DB queries, JSON parse) to see if they align with jank/ANR windows.

Automated UI hang detection in production

If you only rely on system ANRs, you’ll miss many “near-ANR” stalls that hurt UX. A lightweight watchdog detects UI thread stalls earlier:

class UiBlockDetector(
private val thresholdMs: Long = 700L
) {
@Volatile private var lastBeat = SystemClock.uptimeMillis()
private val handler = Handler(Looper.getMainLooper())

private val ticker = object : Runnable {
override fun run() {
lastBeat = SystemClock.uptimeMillis()
handler.post(this) // Re-post to run again on next loop spin
}
}

fun start() {
handler.post(ticker)
Thread {
while (!Thread.interrupted()) {
val since = SystemClock.uptimeMillis() - lastBeat
if (since > thresholdMs) {
// Main thread stalled; capture state for diagnostics
Log.e("UiBlock", "UI thread stall: ${since}ms")
}
Thread.sleep(thresholdMs / 2)
}
}.start()
}
}

More robust implementations combine:

  • Choreographer frame pacing to catch long frames.
  • Periodic stack sampling of the main thread when a stall is suspected.
  • Correlation with CPU/network/disk metrics to find bottlenecks.

How Appxiom helps reduce ANR rates

In production, Appxiom detects UI thread blocking and main thread starvation by:

  • Observing Choreographer and the main Looper to flag long frames and stalls before they escalate into an ANR in Android.
  • Sampling main-thread stacks during stalls to pinpoint code paths (e.g., disk I/O, network, heavy parsing).
  • Correlating stalls with screen, device, OS version, CPU load, network latency, and DB operations.
  • Aggregating “near-ANR” and ANR events to reveal top offenders and trends over releases.

This automated approach complements manual systrace and logs, making it easier to prevent ANRs at scale.

Quick checklist to prevent ANRs

  • Never block Dispatchers.Main; use withContext(IO/Default) for I/O/CPU.
  • Turn on StrictMode in debug and fix violations.
  • Avoid runBlocking and long synchronized sections on UI paths.
  • Use JankStats to monitor rendering jank; profile with Perfetto for deeper dives.
  • Add Trace sections around expensive code.
  • Deploy a UI stall watchdog in production and track main-thread stacks.
  • Continuously monitor ANR rates and near-ANR stalls; regressions often appear early after releases.

By combining solid Kotlin coroutine practices, manual traces, and automated UI hang detection, teams can reduce ANR rates and deliver a responsive Android experience.