Agentic AI in Android App Development

Android apps are evolving—from tap-driven tools to intelligent systems that can think, plan, and act.

This shift is powered by Agentic AI.

Instead of writing rigid flows like:

“User clicks → API call → Show result”

We now design apps that understand intent:

“User goal → AI plans → executes actions → learns → improves”

If you're a Senior Android Engineer, this is the next big architectural leap.


What is Agentic AI?

Agentic AI refers to systems (agents) that can:

  • Understand user intent

  • Plan multi-step actions

  • Use tools (APIs, device features)

  • Learn from memory and feedback

  • Autonomously achieve goals

 In short:
Agentic AI = Reasoning + Action + Learning


Traditional vs Agentic Android Apps

Traditional AppsAgentic AI Apps
ReactiveProactive
Static UI flowsDynamic reasoning
Hardcoded logicAI-driven decisions
User-controlledGoal-oriented
Screen-basedConversational

Example

Traditional:
User searches → filters → books hotel

Agentic:
User says:

“Find the best hotel under $200 in Dallas and book it”

AI:

  • Understands intent

  • Searches hotels

  • Filters best options

  • Books automatically

  • Sends confirmation


The Core: Agent Loop (ReAct Pattern)

At the heart of Agentic AI is a continuous loop:

Think → Act → Observe → Reflect → Repeat

This is known as the ReAct pattern (Reason + Act).

User Intent → Plan → Execute → Observe → Update → Final Output

This loop enables apps to adapt and improve over time.


Android Architecture with Agentic AI

To integrate Agentic AI, extend Clean Architecture with a new layer:

Presentation (Jetpack Compose UI)
        ↓
ViewModel (State + Intent)
        ↓
 Agent Layer
   ├── Planner (LLM reasoning)
   ├── Memory (context + history)
   ├── Tool Executor (APIs, DB, device)
        ↓
Domain (Use Cases)
        ↓
Data (Repository + API + DB)

 This keeps your system:

  • Scalable

  • Testable

  • Maintainable


Core Components Explained

1.  LLM (Reasoning Engine)

Handles:

  • Intent understanding

  • Planning

  • Decision-making

Examples: OpenAI GPT, Claude, Gemini


2.  Memory System

TypeAndroid Tech
Short-termViewModel
Long-termRoom
PreferencesDataStore
SemanticVector DB

3. Tools / Actions

Agents interact with:

  • REST APIs (Retrofit)

  • Camera, GPS

  • Local database

  • Third-party services


4. Planner

Creates structured steps:

class Planner(private val llm: LLMClient) {
    suspend fun createPlan(input: String): Plan {
        return llm.generatePlan(input)
    }
}

5.  Tool Executor

Executes actions:

class ToolExecutor(private val api: ApiService) {

    suspend fun execute(action: Action): Result {
        return when(action.type) {
            "SEARCH" -> api.search(action.params)
            "BOOK" -> api.book(action.params)
            else -> Result.Error("Unknown action")
        }
    }
}

6. Agent

Coordinates everything:

class Agent(
    private val planner: Planner,
    private val executor: ToolExecutor
) {
    suspend fun process(input: String): AgentResult {
        val plan = planner.createPlan(input)
        return plan.steps.map { executor.execute(it.action) }
    }
}

Building Conversational UI with Jetpack Compose

Agentic apps shine with chat-style UI:

@Composable
fun AgentScreen(viewModel: AgentViewModel) {
    val state by viewModel.state.collectAsState()

    Column {
        LazyColumn {
            items(state.messages) {
                Text(it.text)
            }
        }

        TextField(
            value = state.input,
            onValueChange = viewModel::updateInput
        )

        Button(onClick = viewModel::send) {
            Text("Ask AI")
        }
    }
}

Agentic RAG (Retrieval-Augmented Generation)

Enhance AI with real data:

Flow:

  1. User query

  2. Retrieve (DB/API)

  3. Inject into prompt

  4. Generate answer

Example:
Banking app → fetch transactions → AI explains spending


Multi-Agent Systems

Break complex tasks into specialized agents:

AgentResponsibility
PlannerTask breakdown
ExecutorPerform actions
CriticValidate output
MemoryStore context

Real-World Use Cases

πŸ’³ Banking

  • Expense analysis

  • Fraud detection

  • AI financial advisor

✈️ Travel

  • Trip planning

  • Auto booking

  • Smart suggestions

πŸ›’ E-commerce

  • AI shopping assistant

  • Price comparison

  • Personalized deals

πŸ₯ Healthcare

  • Symptom checker

  • Appointment booking

  • Medication reminders


Challenges & Solutions

Hallucination

AI may take wrong actions
✔ Add validation layer


Latency

LLM calls are slow
✔ Use caching + streaming


Cost

API usage is expensive
✔ Hybrid AI (on-device + cloud)


Security

Sensitive data risk
✔ Encryption + tokenization


Over-Automation

Too much autonomy harms UX
✔ Human-in-the-loop design


Testing Strategy

LayerTesting
AgentMock LLM
APIRetrofit mock
UICompose tests
FlowIntegration tests

Best Practices

  • Use MVVM + Clean Architecture

  • Keep Agent Layer isolated

  • Add fallback & retry logic

  • Implement observability (logs, metrics)

  • Design transparent AI UX


Future of Android with Agentic AI

  • Apps become AI copilots

  • UI shifts to conversation-first

  • Multi-agent collaboration inside apps

  • On-device AI becomes mainstream


Conclusion

Agentic AI is transforming Android development:

From: Reactive apps

                πŸ‘‡ 

To:Autonomous intelligent systems

This is more than a feature—it’s a new architecture paradigm.


Ad • Suggested for you

ShopHub

Price tracker • Deals • Smart shopping

⭐ 4.8 • Free
Install

Android CLI : The Future of Android Development from Terminal

Android development is no longer limited to Android Studio.

Google has introduced the Android CLI, a modern command-line interface designed for developers, automation systems, and AI agents.

In simple terms:

Android CLI = Build, run, test, and automate Android apps from your terminal — faster and smarter

 

What is Android CLI?

The Android CLI is a unified terminal interface that simplifies Android development workflows such as:

  • Project setup

  • SDK management

  • Emulator control

  • App deployment

  • UI inspection

  • Documentation access

It acts as a standardized entry point to Android tools and best practices.


Why Android CLI 

From Google’s official announcement:

  • 3× faster development using agents + CLI

  • 70% reduction in LLM token usage

  • Designed for AI-driven (agentic) workflows

This is a major shift:

Old ApproachNew Approach
Android Studio-centricCLI + AI agents
Manual setupAutomated setup
IDE-bound workflowsScriptable workflows


Version & Release

  • Version: 0.7

  • Release Date: April 2026

  • Status: First official release


Installation (Official)

Linux

curl -fsSL https://dl.google.com/android/cli/latest/linux_x86_64/install.sh | bash

macOS

curl -fsSL https://dl.google.com/android/cli/latest/darwin_arm64/install.sh | bash

Windows

curl.exe -fsSL https://dl.google.com/android/cli/latest/windows_x86_64/install.cmd -o "%TEMP%\i.cmd" && "%TEMP%\i.cmd"

Verify

which android

Update CLI

android update

Core Capabilities (Simplified)

Project Creation

android create --name MyApp --output ./MyApp

Uses official templates and applies best practices automatically.


SDK Management

android sdk install platforms/android-34
android sdk list
android sdk update

Install only required components for a lean environment.


Emulator Control

android emulator create
android emulator start medium_phone
android emulator list

No need to open Android Studio.


Run App

android run --apks=app-debug.apk

Direct APK deployment.


Project Analysis

android describe

Outputs APK paths, build targets, and project structure.


UI Automation

android screen capture --output=ui.png --annotate
android screen resolve --screenshot=ui.png --string="input tap #3"

Converts UI elements into coordinates for automation.


Layout Inspection

android layout --pretty

Returns UI tree as JSON for testing and accessibility.


Built-in Documentation

android docs search "performance"
android docs fetch kb://android/topic/performance/overview

Access official Android documentation inside the terminal.


Android CLI and AI Agents

Android CLI is designed to work with tools like:

  • Gemini

  • Claude Code

  • Codex

These agents can create projects, run apps, fix issues, and follow best practices automatically.


Android Skills

Android Skills are modular instruction sets for AI agents that help them:

  • Follow best practices

  • Avoid outdated patterns

  • Execute workflows correctly

Examples include:

  • Navigation setup

  • Edge-to-edge UI

  • Compose migration

  • R8 optimization

android skills list
android skills add --all


Android Knowledge Base

The CLI integrates a live knowledge system including:

  • Android Docs

  • Firebase Docs

  • Kotlin Docs

This ensures AI tools always use the latest Android guidance.


Real Workflow (Senior Engineer)

# Create project
android create --name DemoApp --output ./DemoApp

# Install SDK
android sdk install platforms/android-34

# Start emulator
android emulator create
android emulator start medium_phone

# Build app
./gradlew assembleDebug

# Deploy app
android run --apks=app/build/outputs/apk/debug/app-debug.apk

# Inspect UI
android layout --pretty

CI/CD Example

#!/bin/bash

android sdk install platforms/android-34
./gradlew clean assembleRelease
android run --apks=app-release.apk

Architecture

Developer / AI Agent
        ↓
   Android CLI
        ↓
SDK + Emulator + Device
        ↓
 Android App

Android CLI vs Android Studio

FeatureAndroid CLIAndroid Studio
AutomationYesLimited
AI IntegrationNativeGrowing
CI/CDIdealNot suitable
UI DesignNoStrong
DebuggingBasicAdvanced

Known Limitations

  • Windows emulator support is limited

  • Still early version (v0.7)

  • Requires Gradle for builds


Future of Android CLI

Expected improvements include:

  • Fully AI-driven development

  • Instant project scaffolding

  • Automated UI testing without frameworks

  • Cloud-native Android workflows


The Android CLI represents a shift in Android development.

Key takeaway:

Android Studio is for UI and debugging
Android CLI is for automation, scalability, and AI workflows

Android CLI is Google’s new unified terminal interface for Android development. It supports automation, CI/CD, and AI-driven workflows. I use it for project setup, SDK management, deployment, and UI automation using layout and screen commands. It integrates well with AI agents and Android skills.


References


Why Koin Can Be More Powerful Than Hilt in Android Development

Dependency Injection (DI) has become a cornerstone of modern Android development. Two of the most popular DI frameworks today are Koin and Hilt.

While Hilt is officially backed by Google and widely adopted, many developers argue that Koin can be more powerful in certain scenarios—especially in terms of simplicity, flexibility, and developer productivity.

In this article, we’ll explore why Koin can feel more powerful than Hilt, and when you should consider using it.


Understanding the Core Difference

At a high level:

  • Hilt → Compile-time Dependency Injection

  • Koin → Runtime Dependency Injection

This fundamental difference shapes everything else.


1. Simplicity & Developer Experience

Koin: Minimal Boilerplate

Koin is designed to be idiomatic Kotlin, meaning it feels natural to write and read.

val appModule = module {
    single { UserRepository(get()) }
    viewModel { UserViewModel(get()) }
}

No annotations. No generated code. No complex setup.

Hilt: Annotation-heavy

@HiltViewModel
class UserViewModel @Inject constructor(
    private val repository: UserRepository
) : ViewModel()

Plus modules, components, scopes, and generated code.

Why Koin feels more powerful here:

  • Faster onboarding

  • Less cognitive overhead

  • Cleaner codebase


2. No Compile-Time Overhead

Hilt Problem

Hilt relies on annotation processing (KAPT/KSP), which:

  • Slows down build times

  • Adds complexity to Gradle setup

  • Can cause cryptic compile errors

Koin Advantage

Koin works at runtime:

  • No annotation processing

  • Faster incremental builds

  • Fewer build failures

For large projects, this is a huge productivity win.


3. Dynamic Dependency Injection

Koin allows runtime decisions when injecting dependencies.

single {
    if (BuildConfig.DEBUG) DebugLogger() else ReleaseLogger()
}

Why this matters

  • Feature toggles

  • A/B testing

  • Environment-based configs

  • Dynamic modules

Hilt Limitation

Hilt is static (compile-time graph), so:

  • Less flexible for runtime conditions

  • Requires complex qualifiers or modules

Koin wins in flexibility and dynamic behavior.


4. Easier Testing

Testing with Koin is extremely simple.

val testModule = module {
    single<UserRepository> { FakeUserRepository() }
}

You can override modules easily.

Hilt Testing Challenges

  • Requires @HiltAndroidTest

  • Needs special test runners

  • More setup for mocking dependencies

Koin is more lightweight and test-friendly.


5. No Generated Code (Better Debugging)

Hilt

  • Generates hidden code

  • Stack traces can be confusing

  • Harder to debug DI issues

Koin

  • Everything is explicit

  • Easier to trace dependency issues

  • No magic behind the scenes

This transparency makes Koin feel more controllable.


6. Better for Kotlin Multiplatform (KMP)

If you're exploring Kotlin Multiplatform, Koin has a major edge.

  • Works seamlessly across platforms

  • No Android-specific dependencies

  • Lightweight and portable

Hilt, on the other hand:

  • Is tightly coupled to Android

  • Not suitable for KMP

For future-proof architecture, Koin is often the better choice.


7. Learning Curve

AspectKoin Hilt 
SetupEasyComplex
Learning CurveLowMedium–High
DebuggingSimpleHard
BoilerplateMinimalHigh

Koin empowers developers to focus on business logic instead of DI complexity.


When Hilt is Still Better

To be fair, Hilt has strong advantages:

  • Official Google support

  • Compile-time safety (fewer runtime crashes)

  • Better for very large teams with strict architecture

  • Deep integration with Android components

If you want strict, scalable architecture enforcement, Hilt may be the better choice.


Why Koin Feels More Powerful

Koin is not necessarily “better” in every case—but it feels more powerful because it gives you:

  • Full control at runtime

  • Faster development cycles

  • Simpler codebase

  • Better developer experience

  • Flexibility for modern architectures (KMP, dynamic features)


My Recommendation 

Given your experience level, here’s a practical guideline:

  • Use Koin when:

    • You want speed + flexibility

    • You’re working with Kotlin-first or KMP projects

    • You prefer clean, readable DI

  • Use Hilt when:

    • You need strict architecture enforcement

    • You’re in a large enterprise Android team

    • You want compile-time guarantees


In modern Android development, productivity and maintainability matter just as much as correctness.

Koin empowers developers to move fast without fighting the framework, while Hilt enforces structure and safety.

The real power lies in choosing the right tool for your context.


Migrating from Navigation 2 to Navigation 3 in Android

Modern Android development evolves rapidly, and Google's Jetpack libraries continuously improve developer experience and application architecture. One such evolution is the transition from Navigation 2 (Nav2) to Navigation 3 (Nav3).

If you're building modern apps with Jetpack Compose, Navigation 3 introduces a simpler, type-safe, and scalable navigation system that improves code maintainability and reduces runtime errors.


Why Navigation 3?

Jetpack Navigation 2 works well but has several limitations:

Common issues with Nav2:

  • String-based routes

  • Runtime navigation errors

  • Difficult deep-link handling

  • Hard-to-maintain argument passing

  • Poor type safety

Navigation 3 introduces:

✔  Type-safe destinations
✔  Compile-time safety
✔  Cleaner navigation APIs
✔  Better Compose integration
✔  Reduced boilerplate


Nav2 vs Nav3: Key Differences

FeatureNavigation 2Navigation 3
Route definitionString basedType-safe
ArgumentsManual parsingStrongly typed
SafetyRuntime errors possibleCompile-time safety
Compose integrationGoodExcellent
MaintainabilityModerateHigh

Dependency Setup

First, ensure your project is using the latest navigation library.

dependencies {
    implementation("androidx.navigation:navigation-compose:2.8.0")
}

Navigation 3 features are enabled via typed routes.


Navigation 2 Example (Before Migration)

Typical Nav2 implementation uses string routes.

Define Routes

object Routes {
    const val HOME = "home"
    const val DETAILS = "details/{id}"
}

NavHost Setup

NavHost(
    navController = navController,
    startDestination = Routes.HOME
) {

    composable(Routes.HOME) {
        HomeScreen {
            navController.navigate("details/10")
        }
    }

    composable(
        route = Routes.DETAILS,
        arguments = listOf(navArgument("id") { type = NavType.IntType })
    ) { backStackEntry ->

        val id = backStackEntry.arguments?.getInt("id")

        DetailScreen(id)
    }
}

Problems:

  • Hardcoded strings

  • Argument errors occur at runtime

  • Difficult refactoring


Navigation 3 Approach (Recommended)

Navigation 3 introduces type-safe destinations using Kotlin serialization or sealed classes.


Step 1: Define Type-safe Routes

Use sealed classes or data classes.

sealed class Screen {

    data object Home : Screen()

    data class Detail(val id: Int) : Screen()
}

This gives:

  • Compile-time safety

  • Strongly typed navigation


Step 2: Navigation Setup

NavHost(
    navController = navController,
    startDestination = Screen.Home
) {

    composable<Screen.Home> {

        HomeScreen {
            navController.navigate(Screen.Detail(10))
        }
    }

    composable<Screen.Detail> { entry ->

        val detail = entry.toRoute<Screen.Detail>()

        DetailScreen(detail.id)
    }
}

Now navigation is:

✔ type-safe
✔ compile-time validated
✔ easier to maintain


Step 3: Passing Arguments Safely

Old approach:

navController.navigate("details/10")

New Nav3 approach:

navController.navigate(Screen.Detail(10))

No more:

  • string concatenation

  • route parsing

  • argument mismatch errors


Migration Strategy for Production Apps

Large applications cannot migrate everything at once.

Recommended migration strategy:

Step 1 — Introduce Typed Routes

Convert existing routes to sealed classes gradually.

Step 2 — Replace String Navigation

Replace:

navController.navigate("details/$id")

With:

navController.navigate(Screen.Detail(id))

Step 3 — Remove navArgument()

Typed navigation removes the need for manual argument definitions.

Step 4 — Refactor Navigation Graph

Update composables to use typed navigation.


Best Practices for Navigation 3

1. Use Sealed Classes for Screens

sealed interface AppScreen

Keeps navigation organized.


2. Use Feature-Based Navigation

Structure navigation by feature modules.

Example:

navigation
 ├── auth
 ├── home
 ├── profile

Each feature owns its navigation graph.


3. Avoid Passing Large Objects

Pass IDs instead of full models.

Good:

Screen.Detail(productId)

Bad:

Screen.Detail(product)

4. Keep Navigation in One Layer

Recommended architecture:

UI Layer
   ↓
Navigation Layer
   ↓
ViewModel
   ↓
Repository

Example Project Structure

A clean navigation architecture might look like this:

app
 ├── navigation
 │     AppNavHost.kt
 │     Screen.kt
 │
 ├── features
 │     ├── home
 │     ├── detail
 │     ├── profile
 │
 ├── ui
 ├── data
 ├── domain

Performance Benefits

Navigation 3 also improves performance because:

  • Fewer runtime checks

  • Cleaner back stack management

  • Reduced route parsing


Common Migration Pitfalls

Avoid these mistakes:

❌ Mixing typed routes with string routes
❌ Passing complex objects between screens
❌ Creating huge navigation graphs

Instead:

✔ Keep navigation modular
✔ Use feature-based graphs
✔ Pass only necessary arguments


When Should You Migrate?

Migration is recommended if:

  • Your app uses Jetpack Compose

  • You maintain a large codebase

  • You want compile-time navigation safety

If your project still uses Fragments, Nav2 may still be sufficient.


My Thoughts

Navigation 3 represents a major improvement in Android navigation architecture, especially for Compose-first applications.

By adopting:

  • Type-safe routes

  • Cleaner APIs

  • Compile-time safety

Android engineers can build more maintainable, scalable, and safer navigation systems.

If you are starting a new Jetpack Compose project today, Navigation 3 should be your default choice.


Android App Startup Performance Guide

“First impressions happen in milliseconds — your app’s startup time is the handshake between the user and your code.”

As Android engineers, we often obsess over features, UI polish, and API integration. Yet, one of the most critical (and overlooked) aspects of user experience is how fast your app launches. Users won’t wait long to experience brilliance — a slow start is often a dead start.

This article explores modern strategies and best practices for improving startup performance using the latest Android tools and techniques.


Understanding Startup Phases

Before optimizing, let’s define the types of app starts:

  • Cold Start: App launches fresh — no process in memory.

  • Warm Start: Process exists, but app is not in foreground.

  • Hot Start: App resumes instantly from background.

Cold starts are the most expensive and the main target for optimization.


Measure Before You Optimize

Optimization without metrics is guesswork.
Start by measuring startup time with precision using these tools:

  • Android Studio Profiler → Startup Profiler Tab

  • Macrobenchmark & Baseline Profiles

  • Perfetto and Systrace

  • Firebase Performance Monitoring

Quick CLI check:

adb shell am start -W com.example/.MainActivity

It displays total launch time directly.


Use Baseline Profiles (Game Changer)

A Baseline Profile precompiles critical code paths, reducing startup time by up to 50% on real devices.

Steps to implement:

  1. Add macrobenchmark and baselineprofile dependencies.

  2. Record startup behavior via test.

  3. Bundle baseline-prof.txt with your release build.

Example:

@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
    @get:Rule val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun startup() = benchmarkRule.measureRepeated(
        packageName = "com.example",
        metrics = listOf(StartupTimingMetric()),
        iterations = 5,
        setupBlock = { pressHome() }
    ) {
        startActivityAndWait()
    }
}

Lightweight Application Initialization

Avoid

  • Heavy SDKs in Application.onCreate().

  • Synchronous analytics or DI initialization.

Use

  • Lazy initialization

  • Coroutines on Dispatchers.IO

  • WorkManager for deferred setup

Example:

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
            initAnalytics()
            initCrashlytics()
        }
    }
}

This keeps your main thread clean and your splash screen quick.


Controlled Initialization with App Startup

Modern Android provides App Startup (AndroidX) for structured initialization.

class AnalyticsInitializer : Initializer<Analytics> {
    override fun create(context: Context): Analytics {
        return Analytics.initialize(context)
    }
    override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

You can control initialization order using:

android:initOrder="2"

Use this to load non-critical SDKs later.


Optimize Dependency Injection (Hilt/Dagger)

DI is powerful but can slow startup if used carelessly.

Tips:

  • Keep eager singletons minimal.

  • Use lazy injection for heavy objects.

  • Avoid large modules initializing in Application scope.

Example:

@InstallIn(SingletonComponent::class)
@Module
object NetworkModule {
    @Provides
    fun provideRetrofit(): Retrofit =
        Retrofit.Builder().baseUrl(BASE_URL).build()
}

Compose UI and Layout Optimization

  • Use Jetpack Compose for faster inflation and less overhead.

  • Keep composition scopes small.

  • Use the SplashScreen API to manage the pre-draw phase gracefully.

Example:

installSplashScreen().setKeepOnScreenCondition {
    viewModel.isLoading.value
}

Preload Smartly

Load just enough data to make the first screen functional:

  • Use Room for cached data.

  • Use DataStore instead of SharedPreferences.

  • Fetch fresh data asynchronously after rendering.


Defer Non-Critical Tasks

Use WorkManager or delayed coroutines for:

  • Crashlytics

  • Analytics

  • Remote Config

  • SDK initializations

Example:

Handler(Looper.getMainLooper()).postDelayed({
    initRemoteConfig()
}, 3000)

Resource & Build Optimization

  • Use R8 + ProGuard for code shrinking.

  • Convert images to WebP.

  • Use VectorDrawables over PNGs.

  • Enable resource shrinking:

    shrinkResources true
    minifyEnabled true
    
  • Deliver via Android App Bundle (AAB) for smaller installs.


Post-Launch Monitoring

After deployment, continuously track:

  • Cold/Warm start times

  • ANR rates

  • Frozen frames

  • Startup crashes

Use Firebase Performance Monitoring or Play Console metrics.


Example Startup Timeline Strategy

Component Strategy Timing
Core DI Graph Lazy load On first need
Analytics Deferred init After 3s
Remote Config Background coroutine After splash
Compose UI Minimal recomposition First frame
Cached Data Room + Coroutine Async load
Baseline Profile Pre-compiled Pre-release

My View as a Sr. Android Engineer

Startup optimization is more than trimming milliseconds — it’s about engineering trust.
When your app launches instantly, it signals quality, reliability, and care.
By combining Baseline Profiles, lazy initialization, and controlled startup sequencing, you ensure that your code performs as thoughtfully as it’s designed.


Analogy

“Startup optimization is like preparing for a long flight — pack essentials in your carry-on and check the rest in later.”


 πŸ“’ Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! Please leave a comment below. I’d love to hear from you! πŸ‘‡

Happy coding! πŸ’»

Building Custom Reusable UI Components in Jetpack Compose

The Modern Android Way

“Reusable UI is not just a coding habit — it’s a design philosophy that scales apps, teams, and ideas.”



As Android engineers, we often find ourselves solving the same UI problem over and over — a stylized button, a progress loader, a card with actions. In traditional XML days, we would duplicate layouts or inflate custom views.

But with Jetpack Compose, UI development is declarative, modular, and reactive — a perfect environment for reusable, scalable, and testable components.

In this article, I’ll walk through how to design custom reusable UI components using modern Compose patterns aligned with Clean Architecture and feature modularization — the same structure that powers enterprise-grade fintech apps.


Why Reusable UI Components Matter

Reusable UI is more than DRY (Don’t Repeat Yourself). It enables:

  • Consistency across the app’s look and feel.

  • Scalability, so new features integrate faster.

  • Testability, since each UI piece is independent.

  • Theming flexibility, to support brand-level customization.

  • Faster CI/CD, since UI updates are isolated to one module.


Modular Architecture Overview

Compose works beautifully with modularization.
Here’s a recommended structure for large apps:

app/
 ├── MainActivity.kt
 ├── navigation/
core/
 ├── designsystem/
 │    ├── components/
 │    │    ├── CustomButton.kt
 │    │    ├── CustomProgress.kt
 │    │    ├── CustomCard.kt
 │    ├── theme/
 │    ├── typography/
 ├── utils/
feature/
 ├── dashboard/
 │    ├── ui/
 │    ├── viewmodel/
 │    ├── domain/

Rule of thumb:

  • core/designsystem → Your reusable UI kit (like Material 3 but customized).

  • feature/* → Screens that consume those components.

  • app → Wires navigation and dependency injection.


Step 1: Creating a Reusable Custom Button

Let’s start simple — a custom button that can be reused across modules.

@Composable
fun AppButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    backgroundColor: Color = MaterialTheme.colorScheme.primary,
    textColor: Color = Color.White,
    enabled: Boolean = true
) {
    Button(
        onClick = onClick,
        modifier = modifier
            .fillMaxWidth()
            .height(56.dp),
        enabled = enabled,
        colors = ButtonDefaults.buttonColors(
            containerColor = backgroundColor,
            contentColor = textColor
        ),
        shape = RoundedCornerShape(12.dp)
    ) {
        Text(
            text = text,
            style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold)
        )
    }
}

What makes this reusable

  • Parameterized: text, onClick, color, enabled.

  • Styled centrally using your theme.

  • No hardcoded logic — just a pure, stateless composable.


Step 2: Making It Themed & Adaptive

Your core/designsystem/theme defines your app’s brand look:

@Composable
fun AppTheme(content: @Composable () -> Unit) {
    MaterialTheme(
        colorScheme = lightColorScheme(
            primary = Color(0xFF1565C0),
            secondary = Color(0xFF64B5F6)
        ),
        typography = Typography,
        content = content
    )
}

Now, every AppButton automatically aligns with the theme colors and typography across your app.


Step 3: Building a Custom Progress Indicator

Let’s add a reusable loading animation with Compose’s Canvas:

@Composable
fun AppProgressBar(
    progress: Float,
    modifier: Modifier = Modifier,
    color: Color = MaterialTheme.colorScheme.primary,
    strokeWidth: Dp = 6.dp
) {
    Canvas(modifier = modifier.size(100.dp)) {
        val sweepAngle = 360 * progress
        drawArc(
            color = color,
            startAngle = -90f,
            sweepAngle = sweepAngle,
            useCenter = false,
            style = Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Round)
        )
    }
}

Reusable Advantage:
This progress bar can be dropped into any screen — dashboard loading, biometric scanning, or file uploads — without extra setup.


Step 4: Adding Motion and State

Composable components are state-driven. Let’s animate progress reactively:

@Composable
fun AnimatedProgress(targetValue: Float) {
    val progress by animateFloatAsState(
        targetValue = targetValue,
        animationSpec = tween(1500)
    )
    AppProgressBar(progress = progress)
}

Every change to targetValue triggers an animation — clean, declarative, and side-effect-safe.


Step 5: Composable Composition

Compose encourages composition over inheritance. You can easily compose smaller reusable elements:

@Composable
fun AppCard(
    title: String,
    subtitle: String,
    icon: ImageVector,
    onClick: () -> Unit
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .clip(RoundedCornerShape(16.dp))
            .background(MaterialTheme.colorScheme.surfaceVariant)
            .clickable { onClick() }
            .padding(16.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
        Spacer(Modifier.width(12.dp))
        Column {
            Text(title, style = MaterialTheme.typography.titleMedium)
            Text(subtitle, style = MaterialTheme.typography.bodySmall)
        }
    }
}

Now your feature modules can combine AppCard, AppButton, and AppProgressBar to build complex UIs effortlessly.


Step 6: Integrating Reusable Components in Features

In your feature module (e.g., Dashboard):

@Composable
fun DashboardScreen(viewModel: DashboardViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsState()

    when (uiState) {
        is UiState.Loading -> AnimatedProgress(0.7f)
        is UiState.Success -> AppCard(
            title = "Balance",
            subtitle = "$12,450.00",
            icon = Icons.Default.AttachMoney,
            onClick = { /* Navigate */ }
        )
        is UiState.Error -> AppButton("Retry", onClick = viewModel::fetchData)
    }
}

The result: clean, modular, theme-aware UIs that require no repetitive logic.


Step 7: Accessibility and Testing

Accessibility should never be an afterthought. Compose makes it simple:

Modifier.semantics {
    contentDescription = "Loading ${progress * 100}%"
}

For testing:

@get:Rule val composeTestRule = createComposeRule()

@Test
fun testButtonDisplaysText() {
    composeTestRule.setContent { AppButton("Submit", onClick = {}) }
    composeTestRule.onNodeWithText("Submit").assertExists()
}

Design System Philosophy

Think of your core/designsystem as a mini Material library for your brand.
When a designer updates the theme or typography, every screen reflects it automatically.

That’s how large teams scale UI with confidence — Compose turns UI consistency into an architectural feature.


Key Takeaways

Principle Description
Stateless Composables Avoid internal logic; take data via parameters.
Theming Define global color, shape, and typography systems.
Composition > Inheritance Build larger components from smaller ones.
Accessibility Always use semantics for TalkBack support.
Testing Use ComposeTestRule for UI validation.
Modularity Keep your design system separate from features.

My Thoughts

As Android engineers, our UI should evolve as fast as user expectations.
Jetpack Compose gives us the freedom to innovate — while modular architecture keeps our codebase structured and scalable.

Reusable UI isn’t just a best practice; it’s a strategy for sustainable growth in modern Android apps.

“Compose taught us that great UIs aren’t built — they’re composed.”



πŸ“’ Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! Please leave a comment below. I’d love to hear from you! πŸ‘‡

Happy coding! πŸ’»

Functional vs Non-Functional Requirements in Modern Android App Development

A Senior Android Engineer’s Guide to Building Reliable, Scalable, and Delightful Apps

“Great apps don’t just work — they feel right.”



In modern Android app development, success isn’t measured only by what your app does (features), but also by how it performs, scales, and delights users.

That’s where Functional and Non-Functional Requirements (NFRs) come into play.

Functional requirements define what the system should do — the visible behaviors and actions.
Non-functional requirements define how the system should behave — the invisible qualities that separate a mediocre app from a world-class product.

Let’s explore both through real-world Android use cases, best practices, and architecture principles.


Functional Requirements — The “What”

These describe the core features and interactions users directly experience.
They define the app’s functional capabilities — tasks, data processing, business rules, and UI behaviors.

Examples in Android

  1. User Authentication

    • Sign-in with biometric, PIN, or OAuth (e.g., Google, Schwab Secure Login).

    • Use BiometricPrompt API and Jetpack Security for encryption.

  2. Data Fetching & Display

    • Fetch real-time stock prices using Retrofit + Coroutines + Flow.

    • Display data using Jetpack Compose with sealed UI states (Loading, Success, Error).

  3. Offline Mode

    • Cache the latest currency or weather data using Room or DataStore.

    • Sync changes when the device is online using WorkManager.

  4. Push Notifications

    • Implement FCM for trade alerts or balance updates.

    • Handle foreground and background states gracefully.

  5. Accessibility & Localization

    • Support TalkBack, font scaling, and dynamic color theming.

    • Provide localized strings (en, es, ne) with Android’s resources/values structure.

Best Practices

  • Follow MVVM Clean Architecture for modular, testable design.

  • Use sealed classes for predictable UI state management.

  • Use Kotlin Coroutines + Flow for structured concurrency and reactive data flow.

  • Validate business logic in the domain layer, not UI.


Non-Functional Requirements — The “How”

These define the quality attributes that make your app performant, secure, scalable, and user-friendly.

They often determine whether the app succeeds in real-world conditions.

Common Non-Functional Aspects

Category Description Android Example
Performance App speed, memory use, responsiveness Optimize recompositions in Jetpack Compose using remember and stable keys
Security Data protection, secure communication Use EncryptedSharedPreferences, TLS 1.3, certificate pinning
Scalability Ability to handle growing users/data Modular architecture + Repository pattern
Reliability Stability under various network or device conditions Use Retry strategies with Flow.retryWhen()
Usability UX clarity and accessibility Material 3 components, motion transitions, accessible color contrast
Maintainability Ease of code updates and testing Follow SOLID, Clean Architecture, Dependency Injection via Hilt
Compatibility Support for different Android API levels & devices Leverage backward compatibility libraries (AppCompat, Core KTX)
Observability Logs, crash monitoring, metrics Integrate Firebase Crashlytics, Macrobenchmark, or Perfetto

Use Case 1 — Mobile Banking App

Functional:

  • Biometric login

  • Fund transfer

  • Real-time transaction updates

Non-Functional:

  • End-to-end encryption (TLS + Keystore)

  • PCI-DSS compliance for card data

  • Low latency (under 200ms API response time)

  • Smooth UI transitions (Compose animation APIs)

Best Practice:

  • Use WorkManager for secure background synchronization.

  • Employ Hilt for dependency management across features.

  • Implement ProGuard & R8 for obfuscation.


Use Case 2 — Trading App (Thinkorswim-like Example)

Functional:

  • Real-time stock charts

  • Trade placement

  • Watchlist synchronization

Non-Functional:

  • High throughput WebSocket connections

  • Optimized rendering with custom Canvas charts

  • Security: Token encryption via Jetpack Security

  • Accessibility for visually impaired traders (TalkBack + custom semantics)

Best Practice:

  • Use Coroutines + Channels for WebSocket streams.

  • Employ Macrobenchmark to test frame drops during chart updates.

  • Build with modular architecture: core-network, core-ui, feature-trade, feature-watchlist.


Modern Best Practices for Balancing Both

Principle Description
Shift-Left Quality Consider NFRs early in the development cycle.
CI/CD Automation Integrate Lint, Detekt, Unit/UI tests in Jenkins or Bitrise.
Structured Concurrency Use viewModelScope and SupervisorJob for predictable task cancellation.
Jetpack Compose Performance Tuning Use derivedStateOf, LaunchedEffect, and smart recomposition strategies.
Monitoring & Observability Integrate Firebase Performance, ANR Watchdog, and custom metrics.

Final Thoughts

Functional requirements define your product’s purpose, while non-functional requirements define its quality and soul.

As a Senior Android Engineer, your mission is to deliver both:

  • Features that users love, and

  • Experiences that feel fast, safe, and delightful.

“An app may function well, but it only thrives when it’s performant, secure, and inclusive.”
— Dharma Kshetri


πŸ“’ Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! Please leave a comment below. I’d love to hear from you! πŸ‘‡

Happy coding! πŸ’»


Structured Concurrency in Android

“Concurrency without structure is chaos. Structured concurrency makes coroutines predictable, safe, and lifecycle-aware.”

As Android engineers, we juggle multiple tasks: fetching API data, updating UI, caching results, syncing offline data, and handling user interactions—all happening asynchronously. Without proper management, this can lead to leaks, zombie coroutines, or even crashes.

This is where Structured Concurrency in Kotlin comes in. It enforces scoped, hierarchical coroutine management that aligns perfectly with Android lifecycles.


 What is Structured Concurrency?

Structured concurrency means that every coroutine has a parent scope, and when the parent is canceled (e.g., Activity destroyed, ViewModel cleared), all its child coroutines are automatically canceled.

Instead of launching fire-and-forget coroutines (like GlobalScope.launch), structured concurrency ensures:

  • Lifecycle awareness: Coroutines cancel when their UI component dies.

  • Error propagation: Exceptions bubble up to parent scopes.

  • Predictable flow: All children complete or cancel together.


 Building Blocks in Android

1. CoroutineScope

Defines the lifecycle boundary for coroutines.

  • viewModelScope → Tied to ViewModel lifecycle.

  • lifecycleScope → Tied to Activity/Fragment lifecycle.

  • rememberCoroutineScope → For Composables.

class MyViewModel : ViewModel() {
    fun fetchData() {
        viewModelScope.launch {
            val data = repository.getData()
            _uiState.value = data
        }
    }
}

2. Job & SupervisorJob

  • Job: Cancels all child coroutines when canceled.

  • SupervisorJob: Allows siblings to fail independently.

val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

3. Coroutine Builders

  • launch { } → Fire-and-forget.

  • async { } → Returns Deferred<T> result, use await().

  • withContext { } → Switch dispatcher safely.

  • coroutineScope { } → Ensures children complete or cancel as a group.


 Real-World Use Cases in Android

1. Safe API Calls in ViewModel

class UserViewModel(private val repo: UserRepository) : ViewModel() {
    val userState = MutableStateFlow<User?>(null)

    fun loadUser() {
        viewModelScope.launch {
            try {
                val user = repo.fetchUser()
                userState.value = user
            } catch (e: IOException) {
                // Handle error gracefully
            }
        }
    }
}

 When the ViewModel clears, viewModelScope cancels the coroutine, preventing memory leaks.


2. Parallel Requests with async

suspend fun fetchProfileAndPosts() = coroutineScope {
    val profile = async { api.getProfile() }
    val posts = async { api.getPosts() }
    profile.await() to posts.await()
}

 If one request fails, both are canceled, avoiding inconsistent UI states.


3. With Timeout

withTimeout(5000) {
    repository.syncData()
}

 Cancels automatically if it takes longer than 5 seconds.


 4. In Jetpack Compose

@Composable
fun UserScreen(repo: UserRepository) {
    val scope = rememberCoroutineScope()
    var name by remember { mutableStateOf("") }

    Button(onClick = {
        scope.launch {
            name = repo.getUser().name
        }
    }) {
        Text("Load User")
    }
}

When the Composable leaves the composition, rememberCoroutineScope is canceled.


 Error Handling in Structured Concurrency

Centralized Exception Handling

val handler = CoroutineExceptionHandler { _, exception ->
    Log.e("CoroutineError", "Caught: $exception")
}

val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + handler)

Supervisor Scope Example

suspend fun safeParallelCalls() = supervisorScope {
    val first = launch { riskyCall1() }
    val second = launch { riskyCall2() }
    // If one fails, the other keeps running
}

 Best Practices for Android Engineers

  1. Never use GlobalScope → Causes leaks and unmanaged coroutines.

  2. Always tie to lifecycle → Use viewModelScope, lifecycleScope, or rememberCoroutineScope.

  3. Use coroutineScope or supervisorScope for child tasks.

  4. Use Dispatchers wisely:

    • Main → UI updates.

    • IO → Network/database.

    • Default → CPU-intensive tasks.

  5. Write tests with runTest or TestCoroutineDispatcher.

  6. Handle cancellation properly (isActive, ensureActive()).


Analogy: Family Trip 


Think of structured concurrency like a family trip:
  • The parent (scope) starts the trip.

  • All kids (child coroutines) must stick together.

  • If the parent says "Trip’s over," everyone goes home.

  • No child keeps wandering alone (no leaks).


Closing Thoughts

Structured concurrency is not just a Kotlin feature—it’s a mindset. It brings discipline to concurrency, making apps safer, cleaner, and more maintainable.

As Android engineers, embracing structured concurrency ensures our coroutines don’t outlive their purpose, keeping apps fast, stable, and memory-leak free.


- By following these practices, you’ll write modern, production-ready Android code with Kotlin coroutines that scales across features, modules, and teams.


πŸ“’ Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! Please leave a comment below. I’d love to hear from you! πŸ‘‡

Happy coding! πŸ’»




Token Management in Android App Development: Best Practices for Engineers

As Android engineers, we’re constantly building apps that need to securely authenticate users and interact with backend APIs. One of the most critical parts of this puzzle is token management — making sure authentication is safe, seamless, and user-friendly.


 What is a Token?

A token is a digital key that grants access to protected resources (like APIs). Instead of repeatedly sending usernames and passwords (insecure and inefficient), the client app uses tokens to prove identity and permissions.

Tokens usually come in two flavors:

  • Access Token – short-lived, used for every API request.

  • Refresh Token – longer-lived, used to obtain a new access token once it expires.

Think of an access token as a hotel room key card and the refresh token as your hotel booking record. If your key card stops working, the receptionist (auth server) issues a new one, as long as your booking is valid.


Token Lifecycle in Android

Here’s the typical flow:

  1. Login – User authenticates with email/password/biometric → server issues access + refresh token.

  2. Store Securely – Tokens are stored securely on-device.

  3. Use in Requests – Access token is attached to each API call.

  4. Refresh When Expired – On 401 Unauthorized, refresh token is used to fetch a new access token.

  5. Logout – Tokens are cleared.

 Visual Flow:


 Best Practices for Token Management

  1. Use Access + Refresh Token Strategy

    • Access tokens: short-lived.

    • Refresh tokens: long-lived but securely stored.

  2. Secure Storage

    • Use EncryptedSharedPreferences / Encrypted DataStore.

    • Use Android Keystore for refresh tokens (hardware-backed security).

    • Keep access token in memory only to reduce exposure.

  3. Automatic Token Handling

    • Use OkHttp Interceptor to attach tokens.

    • Use OkHttp Authenticator to refresh tokens when expired.

    • Never manually add tokens in API calls.

  4. Follow Clean Architecture

    • Keep token logic in Data layer.

    • Expose login/refresh/logout via Use Cases (Domain layer).

    • UI observes AuthState from ViewModel.

  5. Security Best Practices

    • Always use HTTPS (TLS).

    • Never log tokens.

    • Force logout if refresh fails.

    • Test token expiration scenarios.


 Example Implementation in Android

Retrofit API

interface AuthApi {
    @POST("auth/login")
    suspend fun login(@Body request: LoginRequest): TokenResponse

    @POST("auth/refresh")
    suspend fun refreshToken(@Body request: RefreshRequest): TokenResponse
}

Token Storage

class TokenStorage(private val context: Context) {
    private val Context.dataStore by preferencesDataStore("secure_prefs")
    private val ACCESS_TOKEN = stringPreferencesKey("access_token")
    private val REFRESH_TOKEN = stringPreferencesKey("refresh_token")

    suspend fun saveTokens(access: String, refresh: String) {
        context.dataStore.edit {
            it[ACCESS_TOKEN] = access
            it[REFRESH_TOKEN] = refresh
        }
    }

    suspend fun getAccessToken() = context.dataStore.data.first()[ACCESS_TOKEN]
    suspend fun getRefreshToken() = context.dataStore.data.first()[REFRESH_TOKEN]
    suspend fun clearTokens() = context.dataStore.edit { it.clear() }
}

OkHttp Interceptor

class AuthInterceptor(private val tokenProvider: TokenProvider) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val token = tokenProvider.getAccessToken()
        val newRequest = chain.request().newBuilder()
            .addHeader("Authorization", "Bearer $token")
            .build()
        return chain.proceed(newRequest)
    }
}

OkHttp Authenticator (Auto Refresh)

class TokenAuthenticator(private val repo: AuthRepository) : Authenticator {
    override fun authenticate(route: Route?, response: Response): Request? {
        return runBlocking {
            if (repo.refreshToken()) {
                repo.getAccessToken()?.let { newToken ->
                    response.request.newBuilder()
                        .header("Authorization", "Bearer $newToken")
                        .build()
                }
            } else null
        }
    }
}

Key Takeaways

  • Access tokens should be short-lived → reduces risk.

  • Refresh tokens should be secured in Keystore → prevents theft.

  • Automatic handling via Interceptor + Authenticator → reduces bugs.

  • Clean architecture separation → makes token logic testable + maintainable.

By following these best practices, you’ll deliver apps that are secure, reliable, and seamless for users.


Common Mistakes to Avoid

Even experienced developers sometimes fall into these pitfalls:

  1. Storing tokens in plain SharedPreferences

    • ❌ Vulnerable to root access and backup extraction.

    • ✅ Use Encrypted DataStore or Keystore.

  2. Using long-lived access tokens

    • ❌ If stolen, attackers can access APIs indefinitely.

    • ✅ Keep access tokens short-lived, rely on refresh tokens.

  3. Not handling 401 Unauthorized properly

    • ❌ App just crashes or shows an error.

    • ✅ Automatically refresh the token using Authenticator.

  4. Storing refresh tokens in memory only

    • ❌ Refresh token will be lost when the app restarts, forcing re-login.

    • ✅ Store refresh token securely on disk (Keystore-backed).

  5. Logging tokens in Crashlytics or Logcat

    • ❌ Attackers can retrieve sensitive tokens from logs.

    • ✅ Never log tokens, redact them if necessary.

  6. Not clearing tokens on logout

    • ❌ User data can remain exposed.

    • ✅ Always clear tokens from secure storage on logout.

  7. Refreshing tokens too eagerly

    • ❌ Wastes server resources and battery.

    • ✅ Refresh only when expired (lazy refresh).


 Token Lifecycle Management

  • On login → store both access + refresh token securely.

  • On every request → add access token.

  • On 401 → refresh token automatically.

  • On app start → check if refresh token exists & is valid.

  • On logout → clear all tokens from storage.




Final Words

Token management might sound like a low-level detail, but in reality, it’s the backbone of secure Android apps. Whether you’re building fintech, healthcare, or large-scale consumer apps, these practices ensure your app is both safe and user-friendly.


πŸ“’ Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! Please leave a comment below. I’d love to hear from you! πŸ‘‡

Happy coding! πŸ’»

Part 2: Hardware-Backed Encrypted DataStore in Android Security (with Example)

 Alright — let’s make Part 2 of this blog: a full hardware-backed Encrypted DataStore implementation for production use.

We’ll combine:

  • Jetpack DataStore (Preferences) for safe, async storage

  • Hardware-backed AES key in Android Keystore (StrongBox when available)

  • AES/GCM/NoPadding encryption with IV handling

  • Easy API for saving/reading/deleting sensitive strings



1. Why Combine Hardware-Backed Keys with DataStore?

From Part 1, we learned that hardware-backed encryption ensures that encryption keys never leave secure hardware and can’t be extracted.

DataStore is the modern alternative to SharedPreferences:

  • Asynchronous (no ANRs)

  • Type-safe

  • Corruption-handling

  • Flow-based API

By encrypting all values before storing them in DataStore — with a hardware-backed AES key — we get:

  • Encryption at rest + secure key storage

  • Resilience against root and file dump attacks

  • Modern, maintainable API


2. Dependencies

Add to your build.gradle:

dependencies {
    implementation "androidx.datastore:datastore-preferences:1.1.1"
}

No extra crypto libraries are needed — we’ll use Android’s built-in Keystore and javax.crypto.


3. Crypto Helper (Hardware-Backed)

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import android.util.Base64
import java.security.SecureRandom

object HardwareCrypto {
    private const val KEY_ALIAS = "app_secure_datastore_key"
    private const val ANDROID_KEYSTORE = "AndroidKeyStore"
    private const val TRANSFORMATION = "AES/GCM/NoPadding"
    private const val IV_SIZE_BYTES = 12
    private const val TAG_LENGTH_BITS = 128

    fun getOrCreateKey(): SecretKey {
        val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
        val existingKey = keyStore.getKey(KEY_ALIAS, null) as? SecretKey
        if (existingKey != null) return existingKey

        val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
        val spec = KeyGenParameterSpec.Builder(
            KEY_ALIAS,
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
        )
            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
            .setKeySize(256)
            .setIsStrongBoxBacked(true) // Use StrongBox if available
            .build()

        keyGenerator.init(spec)
        return keyGenerator.generateKey()
    }

    fun encrypt(secretKey: SecretKey, plainText: String): String {
        val cipher = Cipher.getInstance(TRANSFORMATION)
        val iv = ByteArray(IV_SIZE_BYTES).also { SecureRandom().nextBytes(it) }
        cipher.init(Cipher.ENCRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH_BITS, iv))
        val cipherBytes = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))
        return Base64.encodeToString(iv + cipherBytes, Base64.NO_WRAP)
    }

    fun decrypt(secretKey: SecretKey, encryptedBase64: String): String {
        val decoded = Base64.decode(encryptedBase64, Base64.NO_WRAP)
        val iv = decoded.copyOfRange(0, IV_SIZE_BYTES)
        val cipherBytes = decoded.copyOfRange(IV_SIZE_BYTES, decoded.size)
        val cipher = Cipher.getInstance(TRANSFORMATION)
        cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH_BITS, iv))
        return String(cipher.doFinal(cipherBytes), Charsets.UTF_8)
    }
}

4. Encrypted DataStore Manager

import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.preferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.crypto.SecretKey

private val Context.secureDataStore by preferencesDataStore(name = "secure_prefs")

class EncryptedDataStoreManager(private val context: Context) {

    private val secretKey: SecretKey by lazy { HardwareCrypto.getOrCreateKey() }

    suspend fun saveString(key: String, value: String) {
        val encrypted = HardwareCrypto.encrypt(secretKey, value)
        context.secureDataStore.edit { prefs ->
            prefs[preferencesKey<String>(key)] = encrypted
        }
    }

    fun readString(key: String): Flow<String?> {
        return context.secureDataStore.data.map { prefs ->
            prefs[preferencesKey<String>(key)]?.let {
                try { HardwareCrypto.decrypt(secretKey, it) } catch (e: Exception) { null }
            }
        }
    }

    suspend fun removeKey(key: String) {
        context.secureDataStore.edit { prefs ->
            prefs.remove(preferencesKey<String>(key))
        }
    }

    suspend fun clearAll() {
        context.secureDataStore.edit { it.clear() }
    }
}

5. Example Usage

val secureStore = EncryptedDataStoreManager(context)

// Saving
lifecycleScope.launch {
    secureStore.saveString("auth_token", "super_secret_token_123")
}

// Reading
lifecycleScope.launch {
    secureStore.readString("auth_token").collect { token ->
        println("Decrypted token: $token")
    }
}

6. Benefits of This Approach

  • Hardware-backed keys protect encryption keys at the hardware level

  • Asynchronous DataStore prevents ANRs

  • AES-256 GCM provides confidentiality + integrity verification

  • StrongBox support ensures even higher security on compatible devices

  • Simple API for engineers to integrate


7. Final Thoughts

If your app handles any sensitive data — authentication tokens, API secrets, offline cached PII — you should never store it in plain text. Combining hardware-backed keys with modern DataStore gives you an end-to-end secure storage layer that’s:

  • Modern

  • Maintainable

  • Resistant to common mobile security threats

In a security audit, this design will be a strong point in your architecture.


πŸ“’ Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! Please leave a comment below. I’d love to hear from you! πŸ‘‡

Happy coding! πŸ’»