Add new functions or properties to existing classes without inheritance.
fun String.isEmail(): Boolean = contains("@") && contains(".")
val List<Int>.sumOfSquares: Int get() = sumOf { it * it }Kotlin provides five standard scoped functions: let, run, with, apply, and also. They make code more concise and readable by introducing a temporary scope for an object.
| Function | Object Reference | Returns | Use Case |
|---|---|---|---|
| let | it | Result of lambda | Transform, chain, null checks |
| run | this | Result of lambda | Object configuration, computation |
| with | this | Result of lambda | Group operations on an object |
| apply | this | The object itself | Initialize or configure an object |
| also | it | The object itself | Additional effects (logging, validation) |
val name = "Android"
// let
name.let {
println(it.uppercase())
}
// run
val length = name.run {
println(this)
length
}
// with
val result = with(name) {
println(this)
reversed()
}
// apply
val sb = StringBuilder().apply {
append("Hello, ")
append(name)
}
// also
val list = mutableListOf(1, 2, 3).also {
it.add(4)
println("List updated: $it")
}- Use
letfor null checks and chaining. - Use
runfor object configuration and returning a result. - Use
withfor grouping operations on a single object. - Use
applyfor initializing or configuring an object. - Use
alsofor performing additional actions (logging, validation) without modifying the object.
Do:
- Use for utility methods and DSLs.
- Keep extensions focused and non-intrusive.
Don’t:
- Overuse for core business logic.
- Break encapsulation or rely on internals.
Functions that take or return other functions.
fun <T> List<T>.customFilter(predicate: (T) -> Boolean): List<T> =
this.filter(predicate)
val evens = listOf(1,2,3,4).customFilter { it % 2 == 0 }Data classes in Kotlin are used to hold data. They automatically provide useful methods like equals(), hashCode(), toString(), and copy(). To declare a data class, use the data keyword:
data class User(val name: String, val age: Int)- Automatic generation of
equals(),hashCode(), andtoString() copy()function for easy cloning with modifications- Component functions for destructuring
val user1 = User("Alice", 25)
val user2 = user1.copy(age = 26)
println(user1) // Output: User(name=Alice, age=25)Data classes are ideal for representing simple models and transferring data.
Do:
- Use for callbacks, collection operations, and DSLs.
- Prefer inline for small, performance-critical lambdas.
Don’t:
- Capture unnecessary references in lambdas (can cause leaks).
- Overcomplicate APIs with too many function parameters.
Restrict class hierarchies for type-safe state and event handling.
sealed class Result<out T>
data class Success<T>(val data: T) : Result<T>()
data class Error(val exception: Throwable) : Result<Nothing>()
object Loading : Result<Nothing>()
fun handle(result: Result<String>) = when (result) {
is Success -> println("Data: ${result.data}")
is Error -> println("Error: ${result.exception}")
Loading -> println("Loading...")
}Do:
- Use sealed classes for state machines, events, and results.
- Always handle all cases in
when(exhaustiveness).
Don’t:
- Use sealed classes for open-ended hierarchies.
- Forget to update
whenbranches when adding subclasses.
Control how lambdas are inlined for performance and restrictions.
inline fun runTwice(block: () -> Unit) {
block(); block()
}Do:
- Use
inlinefor small, performance-sensitive HOFs. - Use
crossinlineto prevent non-local returns.
Don’t:
- Inline large functions (increases bytecode size).
- Use
noinlineunless you need to store lambdas.
Reuse and compose behavior using the by keyword for both properties and interfaces.
Delegate implementation of an interface to another object, promoting composition over inheritance.
interface Logger { fun log(msg: String) }
class ConsoleLogger : Logger { override fun log(msg: String) = println(msg) }
class Service(logger: Logger) : Logger by loggerDelegate property access logic to reusable delegates.
val config: String by lazy { loadConfig() }
var name: String by Delegates.observable("<init>") { _, old, new ->
println("Name changed from $old to $new")
}by lazy: initializes the value on first access.by Delegates.observable: runs a callback on value changes.
Custom property delegate:
class NonEmptyString {
private var value: String = ""
operator fun getValue(thisRef: Any?, property: KProperty<*>) = value
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) {
require(newValue.isNotEmpty()) { "Value cannot be empty" }
value = newValue
}
}
var username: String by NonEmptyString()Do:
- Use property delegation for lazy initialization, observable properties, or validation.
- Use interface delegation to compose behaviors and avoid deep inheritance.
- Prefer delegation for cross-cutting concerns (logging, caching, security).
Don’t:
- Overuse delegation for trivial or one-off cases (adds indirection).
- Delegate to objects with complex or hidden side effects.
- Use property delegation when a simple getter/setter suffices.
Summary Table:
| Delegation Type | Use Case | Example | Best Practice |
|---|---|---|---|
| Interface Delegation | Compose behaviors, avoid inheritance | class A(b: B) : B by b |
Use for cross-cutting concerns |
| Property Delegation | Lazy, observable, custom logic | val x by lazy { ... } |
Use for reusable property patterns |
Data Store is a modern data storage solution in Android that provides a safe and asynchronous way to store key-value pairs or typed objects. For sensitive data, use Encrypted DataStore to ensure data is encrypted at rest.
- Replaces SharedPreferences with a safer, more robust API
- Supports both key-value and typed object storage (Preferences and Proto DataStore)
- Handles data migration and corruption
- Supports encryption for sensitive data
- Use Encrypted DataStore (with Jetpack Security library) for sensitive information
- Never store passwords or tokens in plain text
- Use device authentication (BiometricPrompt) for extra security
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val dataStore = PreferenceDataStoreFactory.create(
produceFile = { File(context.filesDir, "datastore/sensitive.preferences_pb") },
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
migrations = listOf(),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
encryptionScheme = PreferenceDataStoreFactory.EncryptionScheme.AES256_GCM(masterKey)
)
suspend fun saveToken(token: String) {
dataStore.edit { prefs ->
prefs[stringPreferencesKey("auth_token")] = token
}
}
suspend fun getToken(): String? =
dataStore.data.first()[stringPreferencesKey("auth_token")]Serviceautomatically implementsLoggerby forwarding calls to the providedlogger.
Real-life uses:
- Logging, analytics, or event tracking: inject different loggers (console, file, remote).
- Wrapping APIs: add cross-cutting concerns (e.g., caching, validation) without inheritance.
LiveData is a lifecycle-aware observable data holder in Android. It provides two main methods to update its value:
- Must be called from the main (UI) thread
- Updates the value immediately and notifies observers synchronously
- Can be called from any thread (background or main)
- Posts the update to the main thread, notifies observers asynchronously
- Use
setValue()when you are already on the main thread (e.g., in UI callbacks) - Use
postValue()when updating LiveData from a background thread (e.g., in coroutines, background tasks)
// On main thread
myLiveData.setValue("new value")
// On background thread
myLiveData.postValue("new value")Note: If postValue() is called multiple times quickly, only the last value is dispatched to observers.
Use structured concurrency, channels, flows, and robust exception handling to manage asynchronous work in Android.
- Always launch coroutines in a well-defined scope (
viewModelScope,lifecycleScope, or a custom scope). - Child coroutines are canceled automatically when their parent scope is canceled, preventing leaks.
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
val result = fetchData()
// update UI state
}
}
}- Use channels for communication between coroutines (producer-consumer, pipelines).
- Prefer
Flowfor most data streams; use channels for hot, push-based, or multi-consumer scenarios.
val channel = Channel<Int>()
viewModelScope.launch { channel.send(42) }
viewModelScope.launch { println(channel.receive()) }-
Cold Flows:
- A cold flow starts emitting values only when collected. Each collector triggers a new execution.
- Use for data sources where each consumer should get a fresh stream (e.g., database queries, network calls).
- Example:
val numbers = flow { for (i in 1..3) emit(i) } numbers.collect { println(it) } // Each collect runs the block anew
-
Hot Flows:
- A hot flow emits values regardless of collectors. All collectors share the same stream.
- Use for shared, ongoing events (e.g., UI events, system broadcasts, shared state).
- Implemented via
SharedFlow(stateless) orStateFlow(stateful). - Example:
val sharedFlow = MutableSharedFlow<Int>() // Emitting values launch { sharedFlow.emit(1) } // Multiple collectors receive the same emissions sharedFlow.collect { println("A: $it") } sharedFlow.collect { println("B: $it") }
-
When to Use Each:
- Cold Flow:
- One-off data fetches (e.g., repository pattern, database query).
- Each consumer needs an independent stream.
- Example:
fun getUser(id: Int): Flow<User> = flow { emit(api.fetchUser(id)) }
- Hot Flow:
- Shared state or events (e.g., UI state, notifications, connectivity changes).
- Multiple consumers need to observe the same updates.
- Example:
// ViewModel exposes UI state val uiState = MutableStateFlow(UiState()) // UI observes changes uiState.collect { render(it) }
- Cold Flow:
-
Operators:
- Combine, transform, and collect flows with operators (
map,filter,combine, etc.). - Example:
numbers .map { it * 2 } .flowOn(Dispatchers.Default) .collect { println(it) }
- Combine, transform, and collect flows with operators (
- Use
try/catchinside coroutines for local errors. - Use
CoroutineExceptionHandlerfor top-level uncaught exceptions. - Prefer
supervisorScopewhen you want sibling coroutines to continue after one fails.
val handler = CoroutineExceptionHandler { _, ex -> Log.e("Coro", "Error: $ex") }
viewModelScope.launch(handler) {
// ...
}Do:
- Use
supervisorScopefor independent child coroutines. - Handle exceptions with
CoroutineExceptionHandler. - Use
withContext(Dispatchers.IO)for blocking I/O. - Cancel coroutines in
onCleared(ViewModel) oronDestroy(Activity/Fragment). - Prefer
viewModelScope/lifecycleScopeoverGlobalScope.
Don’t:
- Launch coroutines in
GlobalScopefor app logic (leads to leaks). - Block threads inside coroutines (use
withContextfor blocking code). - Ignore cancellation signals (check
isActiveor use cooperative functions). - Forget to handle exceptions (uncaught exceptions cancel the scope).
- Network + Database: Fetch from network, save to DB, emit updates via
Flow. - UI Events: Debounce user input with
Flowoperators. - Periodic Work: Use
repeatOnLifecycleorwhile(isActive)in a coroutine for polling. - Timeouts: Use
withTimeoutorwithTimeoutOrNullfor network/database calls.
suspend fun fetchData(): String = withContext(Dispatchers.IO) { /* ... */ }
val channel = Channel<Int>()
viewModelScope.launch { channel.send(42) }Simplify complex types and add type safety.
typealias UserId = String
@JvmInline
value class Email(val value: String)Do:
- Use type aliases for readability.
- Use inline/value classes for lightweight wrappers.
Don’t:
- Abuse type aliases for unrelated types.
Kotlin smart-casts types after checks; contracts can help the compiler.
fun printLength(obj: Any) {
if (obj is String) println(obj.length) // smart cast
}Do:
- Use smart casts for null and type checks.
- Use contracts for custom control-flow (advanced).
Don’t:
- Rely on smart casts after multi-threaded changes.
Use generics for type safety; reified for type checks at runtime.
inline fun <reified T> Gson.fromJson(json: String): T =
fromJson(json, T::class.java)Do:
- Use
reifiedfor type-safe reflection. - Use variance (
out,in) for API design.
Don’t:
- Use star-projections (
*) unless necessary.
Define custom behavior for operators.
data class Point(val x: Int, val y: Int) {
operator fun plus(other: Point) = Point(x + other.x, y + other.y)
}Do:
- Overload operators only when it makes semantic sense.
Don’t:
- Overload for unrelated or confusing behaviors.
Build readable APIs using lambdas with receivers.
fun html(block: HtmlBuilder.() -> Unit): Html = HtmlBuilder().apply(block).build()Do:
- Use for configuration, builders, and UI.
Don’t:
- Overcomplicate with deeply nested DSLs.
Access class metadata and custom behaviors.
@Target(AnnotationTarget.CLASS)
annotation class JsonSerializable
@JsonSerializable
class User(val name: String)Do:
- Use annotations for code generation and metadata.
- Use reflection sparingly (runtime cost).
Don’t:
- Rely on reflection for performance-critical code.
| Feature | Do | Don’t |
|---|---|---|
| Extension Functions | Utility, DSLs, non-intrusive | Core logic, break encapsulation |
| Higher-Order Functions | Callbacks, collection ops, inline for perf | Capture unnecessary refs, overuse |
| Sealed Classes | State/events, exhaustiveness in when |
Open-ended hierarchies, miss cases |
| Delegation | Property/interface reuse | Trivial cases |
| Coroutines | Structured concurrency, handle exceptions | GlobalScope, thread blocking |
| Generics/Reified | Type safety, reflection | Star-projections |
| Operator Overloading | Semantic, natural operators | Confusing/unrelated behaviors |
| DSLs | Builders, config, UI | Deeply nested, unreadable |
| Reflection/Annotations | Metadata, codegen | Perf-critical code |
The primary UI entry point for an app, implemented as a subclass of Activity (typically AppCompatActivity). The activity lifecycle (onCreate, onStart, onResume, onPause, onStop, onDestroy) dictates UI setup and resource management.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// Initialization logic
}
// onStart, onResume, onPause, etc.
}Do:
- Use
savedInstanceStateto restore UI state. - Keep main-thread work minimal (inflate layouts, set up listeners) to avoid slow startup.
Don’t:
- Perform long-running operations on the UI thread (use coroutines, threads, or WorkManager).
- Leak the Activity context via static fields or long-lived references.
A reusable UI component with its own lifecycle, always hosted by an Activity or another Fragment. Fragments encapsulate portions of UI and can be added, replaced, or removed dynamically.
class ProfileFragment : Fragment(R.layout.fragment_profile) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// Initialize views, set up observers, etc.
}
}Do:
- Use
childFragmentManagerfor nested fragments. - Communicate with other components via interfaces or shared
ViewModels.
Don’t:
- Add fragments directly to the back stack without user flow.
- Retain references to destroyed views (use
onDestroyViewcleanup or view binding properly).
Components for background work without a UI. Services enable your app to keep running tasks even when the user is not interacting with the app.
- Started Service: Launched via
startService()orContextCompat.startForegroundService(). Runs until stopped withstopSelf()orstopService(). - Bound Service: Allows components to bind via
bindService()and interact with the service through anIBinderinterface. - Foreground Service: A started service that must display a persistent notification. Required for background tasks on Android 8.0+.
- IntentService (deprecated): Use a regular Service with coroutines or WorkManager instead.
onCreate(): Called once when the service is first created.onStartCommand(intent, flags, startId): Called each time the service is started.onBind(intent): Called when a client binds to the service.onUnbind(intent): Called when all clients have unbound.onRebind(intent): Called when new clients bind afteronUnbind.onDestroy(): Called when the service is destroyed.
class MyForegroundService : Service() {
override fun onCreate() { /* ... */ }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = NotificationCompat.Builder(this, "channel_id")
.setContentTitle("Service Running")
.setSmallIcon(R.drawable.ic_service)
.build()
startForeground(1, notification)
CoroutineScope(Dispatchers.IO).launch {
doBackgroundWork()
stopSelf()
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() { /* ... */ }
}class MyBoundService : Service() {
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): MyBoundService = this@MyBoundService
}
override fun onBind(intent: Intent?): IBinder = binder
fun performAction() { /* ... */ }
}Client:
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
val myService = (service as MyBoundService.LocalBinder).getService()
myService.performAction()
}
override fun onServiceDisconnected(name: ComponentName) {}
}
bindService(Intent(this, MyBoundService::class.java), connection, Context.BIND_AUTO_CREATE)- Must call
startForeground()with a notification within 5 seconds of starting. - Declare
android:foregroundServiceTypein the manifest for specific use cases.
Do:
- Offload heavy work from the main thread using coroutines, threads, or executors.
- Use
WorkManagerfor deferrable, guaranteed background work. - Use foreground services for user-visible, long-running tasks.
- Handle service restarts and process death gracefully.
- Declare all services in
AndroidManifest.xml. - Request runtime permissions if needed.
- Stop the service with
stopSelf()orstopService()when work is complete.
Don’t:
- Run long or blocking operations on the main thread.
- Forget to release resources in
onDestroy(). - Use services for tasks that can be handled by
JobIntentServiceorWorkManager. - Leak memory by holding references to activities or contexts.
- Start background services from the background on Android 8.0+.
<service
android:name=".MyForegroundService"
android:exported="false"
android:foregroundServiceType="location" />- Set
android:exported="false"unless you intend for other apps to bind/start your service. - Use permissions (
android:permission) to restrict access if needed.
- For scheduled or deferrable work, prefer
WorkManager. - For short-lived background tasks, use coroutines or
ExecutorServicein ViewModel or Repository layers.
| Service Type | Use Case | Threading | Lifecycle Control | UI? |
|---|---|---|---|---|
| Started Service | Ongoing background work | Main (default) | App or self stops | No |
| Foreground Service | User-visible, long-running task | Main (default) | App or self stops | No |
| Bound Service | Client-server communication | Main (default) | Unbinds when no clients | No |
Components that listen for broadcasted Intents (from the system or other apps). They have no UI and must be registered (statically in the manifest or dynamically in code).
class NetworkChangeReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val isOnline = /* check network state from intent */
// Handle connectivity change
}
}Do:
- Unregister dynamic receivers in
onPause/onDestroyto avoid leaks.
Don’t:
- Do heavy processing in
onReceive(it times out if too slow). - Assume order of broadcast delivery or guaranteed delivery.
Components that manage structured app data and expose it via a content URI. Often backed by a database.
class MyProvider : ContentProvider() {
override fun onCreate(): Boolean = true
override fun query(
uri: Uri, projection: Array<String>?, selection: String?,
selectionArgs: Array<String>?, sortOrder: String?
): Cursor? {
// Perform database query and return Cursor
}
// Implement insert(), update(), delete(), getType()...
}Do:
- Declare the provider in
AndroidManifest.xmlwith<provider>, and usecontent://URIs to share data.
Don’t:
- Expose sensitive data without permissions.
- Block the calling process; use asynchronous operations.
Provides LifecycleOwner and LifecycleObserver/DefaultLifecycleObserver to react to lifecycle changes.
class LoggerObserver : DefaultLifecycleObserver {
override fun onPause(owner: LifecycleOwner) {
Log.d("Lifecycle", "Paused!")
}
}
// In an Activity or Fragment:
lifecycle.addObserver(LoggerObserver())Do:
- Use
onCreateView/onDestroyViewin Fragments for view-related setup/teardown. - Remove observers when no longer needed.
Don’t:
- Leak references to the
LifecycleOwner.
ConstraintLayout is a flexible and efficient Android layout that allows you to position and size widgets in a flat view hierarchy using constraints.
- Constraints: Define relationships (left/right/top/bottom/baseline) between views or parent.
- Chains: Create horizontal or vertical groups with flexible distribution.
- Guidelines & Barriers: Position views relative to invisible lines or dynamic boundaries.
- Bias: Fine-tune positioning between two constraints (e.g., 30% from left, 70% from right).
- Flat Hierarchy: Reduces nested layouts, improving performance.
- Prefer ConstraintLayout for complex layouts over nested LinearLayouts/RelativeLayouts.
- Use
match_constraint(0dp) for flexible sizing between constraints. - Use tools in Android Studio Layout Editor for visual design.
Holds UI-related data that survives configuration changes and can restore state after process death.
class CounterViewModel(
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _count = savedStateHandle.getLiveData<Int>("count", 0)
val count: LiveData<Int> = _count
fun increment() {
_count.value = (_count.value ?: 0) + 1
savedStateHandle["count"] = _count.value
}
}- Use
SavedStateHandleto persist and restore state (e.g., after process death or navigation). - Access with constructor injection; works automatically with navigation and Hilt.
With Hilt:
@HiltViewModel
class CounterViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle
) : ViewModel() { /* ... */ }- Annotate with
@HiltViewModeland inject dependencies (includingSavedStateHandle). - Obtain ViewModel in UI with
by viewModels<CounterViewModel>().
Do:
- Access shared ViewModels via
viewModels()oractivityViewModels(). - Use
viewModelScopefor coroutines. - Use
SavedStateHandlefor restoring state after process death. - Use Hilt for dependency injection in ViewModels.
Don’t:
- Put UI references (Context, Views) in a ViewModel.
- Replace ViewModel with Singleton or global state.
- Store large objects or non-serializable data in
SavedStateHandle.
An observable, lifecycle-aware data holder.
viewModel.count.observe(viewLifecycleOwner) { value ->
textView.text = "Count: $value"
}Do:
- Use
MutableLiveDataprivately and expose immutableLiveData.
Don’t:
- Observe LiveData with non-lifecycle owners.
- Perform long tasks in
setValue.
Simplifies in-app navigation and back-stack management.
NavHost(navController, startDestination = "home") {
composable("home") { HomeScreen(navController) }
composable("details/{id}") { backStackEntry ->
val id = backStackEntry.arguments?.getString("id")
DetailsScreen(id)
}
}Do:
- Use Safe Args to pass type-safe arguments.
- Handle Up (back) navigation with
navController.navigateUp().
Don’t:
- Mix manual
FragmentTransactionwith Navigation. - Hardcode deep link URIs.
Jetpack persistence library that abstracts SQLite.
@Entity
data class User(@PrimaryKey val id: Int, val name: String)
@Dao
interface UserDao {
@Query("SELECT * FROM user")
fun getAllUsers(): Flow<List<User>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: User)
}
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract val userDao: UserDao
}Do:
- Expose
FloworLiveDatain DAO for reactive queries. - Use migrations or
fallbackToDestructiveMigration()for schema changes.
When you change your database schema (add/remove columns, tables, etc.), you must provide a migration to preserve user data.
- Increment the database version in your
@Databaseannotation. - Create a Migration object specifying the start and end versions.
- Update your Room database builder to include the migration.
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE user ADD COLUMN age INTEGER NOT NULL DEFAULT 0")
}
}
val db = Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2)
.build()- Always test migrations with
MigrationTestHelper. - Never use destructive migration in production unless you can afford data loss.
- Write SQL that works for all existing data.
- Chain multiple migrations if upgrading across several versions.
Don’t:
- Run database operations on the main thread.
- Forget to close
Cursors or resources if using raw queries.
WorkManager is the recommended AndroidX library for deferrable, guaranteed background work that must be executed even if the app exits or the device restarts. It is suitable for tasks that are expected to run eventually, but not necessarily immediately.
- Guaranteed Execution: Work is persisted and retried if the app or device restarts.
- Constraint Support: Run work only under specific conditions (e.g., network, charging, idle).
- Chaining & Input/Output: Chain multiple work requests and pass data between them.
- Periodic & One-Time Work: Supports both one-off and recurring background tasks.
- Backoff & Retry: Automatic retry with exponential or linear backoff.
- Foreground Services Integration: For long-running or user-visible work.
- Observability: Observe work status via LiveData or Flow.
- Unique & Named Work: Prevent duplicate work or replace existing work by name.
-
OneTimeWorkRequest
- For tasks that should run once.
val workRequest = OneTimeWorkRequestBuilder<SyncWorker>().build() WorkManager.getInstance(context).enqueue(workRequest)
-
PeriodicWorkRequest
- For tasks that should repeat at regular intervals (minimum 15 minutes).
val periodicWork = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES).build() WorkManager.getInstance(context).enqueue(periodicWork)
-
Unique Work
- Ensures only one instance of a named work runs at a time.
WorkManager.getInstance(context).enqueueUniqueWork( "sync", ExistingWorkPolicy.REPLACE, workRequest )
-
Chained Work
- Chain multiple work requests sequentially or in parallel.
WorkManager.getInstance(context) .beginWith(workA) .then(workB) .enqueue()
Set conditions for when work should run:
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresCharging(true)
.build()
val workRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.build()Available Constraints:
- Network type (connected, unmetered, not required, etc.)
- Charging state
- Device idle
- Battery not low
- Storage not low
- Worker: Standard, synchronous or blocking work.
- CoroutineWorker: For suspending (coroutine) work.
- ListenableWorker: For advanced, callback-based async work.
Example: CoroutineWorker
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
// Coroutine-based background work
return Result.success()
}
}Input:
val input = workDataOf("key" to "value")
val workRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(input)
.build()Output:
override fun doWork(): Result {
val output = workDataOf("result" to "done")
return Result.success(output)
}WorkManager.getInstance(context)
.getWorkInfoByIdLiveData(workRequest.id)
.observe(lifecycleOwner) { info ->
if (info?.state == WorkInfo.State.SUCCEEDED) { /* ... */ }
}WorkManager is designed to replace most background and foreground service use cases for deferrable, guaranteed work. Here’s how to integrate both patterns:
Background Work:
For most background tasks (sync, uploads, etc.), use a Worker or CoroutineWorker as shown above. WorkManager handles scheduling, retries, and constraints.
Foreground Work (User-Visible):
If your background task must run as a foreground service (e.g., long-running upload, user-visible notification), WorkManager supports this via setForeground().
Steps:
-
Extend Worker/CoroutineWorker:
class UploadWorker( context: Context, params: WorkerParameters ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { // Set foreground notification setForeground(createForegroundInfo()) // Do long-running work uploadFiles() return Result.success() } private fun createForegroundInfo(): ForegroundInfo { val notification = NotificationCompat.Builder(applicationContext, "channel_id") .setContentTitle("Uploading files") .setSmallIcon(R.drawable.ic_upload) .build() return ForegroundInfo(1, notification) } }
-
Enqueue the Worker:
val workRequest = OneTimeWorkRequestBuilder<UploadWorker>().build() WorkManager.getInstance(context).enqueue(workRequest)
-
Declare Foreground Service Permission in Manifest:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
Key Points:
- Use
setForeground()in your Worker to show a notification and avoid background restrictions. - WorkManager will promote your task to a foreground service as needed.
- For background-only work, no notification is needed—just use a regular Worker.
Migrating from Services to WorkManager:
- Move your logic from
Service/IntentServiceinto a Worker. - For user-visible, long-running work, use
setForeground()in the Worker. - For periodic or deferred work, use
PeriodicWorkRequest. - Use WorkManager’s APIs to observe status and handle retries.
References:
- Not for Immediate Work: Minimum delay for periodic work is 15 minutes; not suitable for real-time or low-latency tasks.
- No Exact Timing: WorkManager does not guarantee exact execution time (OS may batch or delay).
- Battery Optimizations: Subject to Doze mode and background restrictions; may be deferred by the system.
- No UI Interaction: Workers run in the background, not on the main thread, and cannot directly update UI.
- Limited to Background Work: Not suitable for tasks requiring user interaction or foreground UI.
- Uploading logs, syncing data, or refreshing content in the background.
- Tasks that must be completed even if the app is killed or device restarts.
- Chained or dependent background tasks.
- Periodic background jobs (e.g., daily sync).
| Use Case | Use WorkManager? | Use AlarmManager? | Use Service? |
|---|---|---|---|
| Guaranteed, deferred work | ✅ | ❌ | ❌ |
| Exact time (e.g., alarm at 7:00) | ❌ | ✅ | ❌ |
| Immediate, short-lived work | ❌ | ❌ | ✅ (IntentService) |
| Foreground, user-visible work | ❌ | ❌ | ✅ (ForegroundService) |
| UI interaction | ❌ | ❌ | ✅ (Activity/Service) |
AlarmManager:
- Use when you need to trigger work at an exact time (e.g., calendar alarms, reminders).
- Not guaranteed to run if device is in Doze mode unless using
setExactAndAllowWhileIdle()(still not 100% reliable). - Does not persist work across device reboots unless you re-register alarms.
Services:
- Use for immediate, ongoing, or user-initiated work (e.g., music playback, file downloads).
- ForegroundService is required for long-running or user-visible background work.
- Not automatically persisted or retried after process death unless managed manually.
Do:
- Use WorkManager for background tasks that must be reliable and survive process/device restarts.
- Set appropriate constraints to avoid unnecessary battery drain.
- Use chaining for complex workflows.
- Use input/output Data for passing results between workers.
Don’t:
- Use WorkManager for tasks that need to run at an exact time or immediately.
- Use for foreground or UI work.
- Forget to handle failure and retries.
Jetpack Compose is Android’s modern UI toolkit using @Composable functions.
Basic Usage:
@Composable
fun Greeting(name: String) {
Text(text = "Hello, $name!")
}State & Recomposition:
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Clicked $count times")
}
}Side-Effects:
@Composable
fun LoadDataEffect(viewModel: MyViewModel) {
LaunchedEffect(Unit) {
viewModel.loadData()
}
}UI Architecture:
class MyViewModel : ViewModel() {
private val _text = MutableStateFlow("Hello")
val text: StateFlow<String> = _text
}
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
val text by viewModel.text.collectAsState()
Text(text = text)
}Compose Best Practices:
- Keep composables small and stateless.
- Lift state to ViewModels or caller.
- Avoid heavy logic in the UI layer.
Dos and Don’ts:
- Do: Use
Modifierfor layout, styling, and click handlers. - Do: Use
@Previewto rapidly iterate on UI. - Do: Use
rememberSaveablefor state that should survive process death. - Don’t: Perform side-effects or I/O directly in composable bodies.
- Don’t: Over-nest composables excessively.
Kotlin Coroutines provide asynchronous programming with structured concurrency.
Structured Concurrency:
Coroutines are launched in a CoroutineScope, and child coroutines are automatically canceled when the scope ends.
Coroutine Scopes:
Common scopes include MainScope(), viewModelScope, lifecycleScope, IO or Default (via Dispatchers).
Best Practice:
Avoid GlobalScope. Inject and manage scopes via architecture.
Flow:
A cold asynchronous stream of values.
val flow: Flow<Int> = flow {
for (i in 1..5) {
delay(100)
emit(i)
}
}
viewModelScope.launch {
flow.collect { value ->
println("Received $value")
}
}Channels:
A coroutine-safe queue for passing values between coroutines.
val channel = Channel<Int>()
launch { for (i in 1..5) channel.send(i); channel.close() }
launch { for (value in channel) println("Got $value") }Best Practices:
- Inject
CoroutineDispatcher. - Make public APIs main-safe.
- Only use
runBlockingin tests or main functions. - Handle exceptions with structured coroutine builders.
Dagger:
A compile-time DI framework using @Module, @Component, and @Inject.
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder().build()
}Hilt:
A Jetpack library built on Dagger that reduces boilerplate.
@HiltAndroidApp
class MyApp : Application()
@AndroidEntryPoint
class MainActivity : AppCompatActivity() { ... }
class Repo @Inject constructor(private val api: ApiService) { ... }Do:
- Prefer Hilt for Android projects.
- Use constructor injection.
Don’t:
- Create your own singletons manually.
- Mix Hilt and manual Dagger components without careful design.
Separates UI (View) from business logic (ViewModel) and data (Model).
- View: Activities/Fragments observing
LiveData/StateFlow. - ViewModel: Holds UI state and calls use-cases or repository methods.
- Model (Repository): Manages data (network, database).
Emphasizes unidirectional data flow.
sealed class MyIntent { object LoadData : MyIntent() }
data class MyState(val loading: Boolean = false, val items: List<String> = emptyList())
class MyViewModel : ViewModel() {
private val _state = MutableStateFlow(MyState())
val state: StateFlow<MyState> = _state
fun processIntent(intent: MyIntent) {
when (intent) {
is MyIntent.LoadData -> loadData()
}
}
private fun loadData() = viewModelScope.launch {
_state.value = MyState(loading = true)
val result = repository.getItems()
_state.value = MyState(items = result)
}
}Do:
- Keep state immutable and emit new state on each update.
- Render UI purely from the state.
Don’t:
- Have multiple independent sources of truth.
Organizes code into layers with strict dependencies.
Presentation ---> Domain ---> (Interfaces) ---> Data
Example:
// Domain layer
class FetchUsersUseCase(private val repo: UserRepository) {
suspend operator fun invoke(): List<User> = repo.getAllUsers()
}
// Presentation layer (ViewModel)
class UserViewModel(private val fetchUsers: FetchUsersUseCase): ViewModel() {
val users = liveData { emit(fetchUsers()) }
}
// Data layer (implements UserRepository)
class UserRepositoryImpl : UserRepository {
override suspend fun getAllUsers() = userDao.getAll()
}Do:
- Keep domain layer free of Android or framework libraries.
- Use interfaces for repository contracts.
Don’t:
- Allow data or presentation to access Android APIs in the domain layer.
- Bypass use-cases.
Modularize large codebases into Gradle modules (features, libraries) to improve build performance and code organization.
Setup:
Use “New Module” to create :core, :feature:login, etc.
Dynamic Feature Modules:
With Android App Bundles, you can deliver modules on-demand.
<dist:module
dist:instant="false"
dist:title="@string/title_profile">
<dist:delivery>
<dist:onDemand />
</dist:delivery>
</dist:module>Do:
- Enforce encapsulation.
- Use Gradle composite builds or version catalogs.
Don’t:
- Over-modularize.
- Duplicate code across modules.
Use the Android Studio Memory Profiler to track allocations and leaks.
override fun onTrimMemory(level: Int) {
if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) {
// Release UI-related memory
}
if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
// Release background processing memory
}
}Do:
- Use
ApplicationContextwhere appropriate. - Utilize tools like LeakCanary.
Don’t:
- Cache large objects or bitmaps without clearing them.
- Use too much static in Android.
Diagnose slow UI frames with Profile GPU Rendering and Layout Inspector.
Tips:
- Use flatter view hierarchies.
- Offload complex rendering to the GPU.
- Ensure
android:hardwareAccelerated="true".
Minimize cold start time.
Tools:
- Android Studio Startup Profiler or Perfetto.
- Enable
reportFullyDrawn().
Do:
- Delay non-critical work to after launch.
Don’t:
- Do disk or network I/O synchronously at startup.
Override drawing methods for unique UI components.
class CircleView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLUE }
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val radius = min(width, height) / 2f
canvas.drawCircle(width / 2f, height / 2f, radius, paint)
}
}Do:
- Handle padding and
onMeasureif needed. - Use
invalidate()to request redraw.
Don’t:
- Allocate new objects inside
onDraw. - Block the UI in custom view drawing.
Use property animation APIs for view animations.
ObjectAnimator.ofFloat(myView, "alpha", 1f, 0f).apply {
duration = 500L
start()
}Do:
- Use hardware-accelerated properties.
- Use
AnimatorListenerordoOnEndto trigger logic after animation.
Don’t:
- Animate layout params on the main thread in a loop.
- Forget to
.cancel()animators.
- Process Model:
- Each Android app runs in its own Linux process, isolated from other apps for security and stability.
- The process is started by the system when any component (Activity, Service, BroadcastReceiver, ContentProvider) is needed.
- By default, all app components run in the main process, but you can specify a different process in the manifest using
android:process.
- Application Object:
- The
Applicationclass is instantiated before any other class when the process starts. - Override
onCreate()for global initialization (dependency injection, logging, analytics). - Only one
Applicationinstance exists per process.
- The
- Component Lifecycle:
- Activities, Services, etc., are created and destroyed by the system as needed.
- The process may be killed at any time to reclaim resources; always save state in
onSaveInstanceStateor persistent storage.
- Process Death:
- The system may kill the process to free memory (especially in the background).
- Use persistent storage (SharedPreferences, database) for critical data.
- Avoid holding onto static references that may leak memory across process restarts.
- Main Thread (UI Thread):
- All UI operations must occur on the main thread.
- The main thread has a
Looperand a message queue that processes messages and runnables sequentially.
- Looper:
- A
Loopermanages a message queue for a thread. - The main thread’s
Looperis prepared automatically; for background threads, callLooper.prepare()andLooper.loop().
- A
- Handler:
- A
Handlerposts messages and runnables to a thread’s message queue. - Use
Handler(Looper.getMainLooper())to post work to the main thread from any thread. post(),postDelayed(),sendMessage()are common methods.
- A
- HandlerThread:
- A
HandlerThreadis a thread with its ownLooper. - Useful for background work that needs to process messages sequentially.
- Always quit the
HandlerThreadwhen done to avoid leaks.
- A
val handler = Handler(Looper.getMainLooper())
handler.postDelayed({ textView.text = "Hello" }, 1000L)Do:
- Use
HandlerThreadfor background threads that need a message queue. - Use
Handlerto schedule work on the main thread or a background thread with a Looper. - Remove callbacks and messages (
handler.removeCallbacksAndMessages(null)) when no longer needed to avoid leaks.
Don’t:
- Touch or update views from a non-main thread (causes exceptions and undefined behavior).
- Block the main thread’s
Looper(e.g., with long-running operations or infinite loops). - Forget to quit background
Loopers when done.
- Binder:
- The core IPC (Inter-Process Communication) mechanism in Android.
- Enables communication between different processes (e.g., app and system services, or two apps).
- Used internally for system services (e.g., ActivityManager, PackageManager).
- AIDL (Android Interface Definition Language):
- Used to define the interface for remote services.
- Generates code to marshal/unmarshal data across process boundaries.
- Only needed for IPC between different processes; for in-process communication, use interfaces or direct method calls.
- How Binder Works:
- Calls are marshaled into parcels and sent to the target process.
- The receiving process handles the call on a thread from the Binder thread pool (not the main thread).
- Be careful with thread safety and avoid blocking Binder threads.
- Security:
- Use permissions in the manifest to restrict which apps can bind to your service.
- Validate all incoming data in AIDL methods.
Key Points:
- Use AIDL only when you need to communicate across processes.
- All AIDL calls are asynchronous and may come in on any thread (typically a Binder thread).
- Avoid heavy work in AIDL methods; offload to background threads if needed.
- Memory Management:
- Android uses the ART (Android Runtime) garbage collector to manage memory.
- Memory is limited per app; exceeding limits causes
OutOfMemoryError. - Use tools like Android Studio Profiler and LeakCanary to detect leaks.
- Threading:
- The main thread handles UI and must not be blocked.
- Use background threads for disk, network, or CPU-intensive work.
- Options:
Thread,HandlerThread,Executors, Kotlin coroutines (Dispatchers.IO,Dispatchers.Default), RxJava.
- Thread Pooling:
- Use
Executors.newFixedThreadPool()or coroutines to limit concurrency. - 2–4 threads are usually sufficient for intensive work; too many threads cause context switching overhead and memory pressure.
- Use
- Best Practices:
- Use
Executors.newSingleThreadExecutor()for serialized background tasks. - Use
Dispatchers.IOfor disk/network I/O in coroutines. - Always shut down executors and threads when done.
- Avoid unbounded thread creation (can exhaust system resources).
- Do not assume objects are tied to a specific thread unless documented.
- Use synchronization or thread-safe data structures when sharing data across threads.
- Use
Do:
- Limit the number of concurrent threads for background work.
- Use thread pools or coroutines for efficient concurrency.
- Profile memory and thread usage regularly.
Don’t:
- Spawn unbounded or excessive threads.
- Block the main thread with heavy work.
- Assume thread-affinity for objects unless explicitly required (e.g., Views must be accessed on the main thread).
A type-safe HTTP client for Android and Java, used to interact with REST APIs.
- Interface-based API: Define endpoints as Kotlin interfaces.
- Annotations: Use
@GET,@POST,@PUT,@DELETE,@PATCH,@Query,@Body,@Header, etc. - Converters: Serialize/deserialize JSON or XML (Moshi, Gson, kotlinx.serialization, etc.).
- Call Adapters: Integrate with coroutines, RxJava, LiveData, etc.
- Base URL: All requests are relative to this.
- OkHttp Integration: Handles HTTP requests, logging, interceptors, and more.
1. Define a Data Model
data class User(
val id: Int,
val name: String,
val email: String
)2. Define the API Interface
interface ApiService {
@GET("users/{id}")
suspend fun getUser(@Path("id") id: Int): Response<User>
@GET("users")
suspend fun getUsers(@Query("page") page: Int): List<User>
@POST("users")
suspend fun createUser(@Body user: User): Response<User>
@PUT("users/{id}")
suspend fun updateUser(@Path("id") id: Int, @Body user: User): Response<User>
@DELETE("users/{id}")
suspend fun deleteUser(@Path("id") id: Int): Response<Unit>
}3. Build Retrofit Instance
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(MoshiConverterFactory.create()) // or GsonConverterFactory
.build()
val api = retrofit.create(ApiService::class.java)4. Make API Calls (with Coroutines)
viewModelScope.launch {
val response = api.getUser(1)
if (response.isSuccessful) {
val user = response.body()
// Use user
} else {
// Handle error
}
}Query Parameters
@GET("search")
suspend fun searchUsers(
@Query("name") name: String,
@Query("age") age: Int?
): List<User>Dynamic URLs
@GET
suspend fun getFromUrl(@Url url: String): Response<ResponseBody>Headers
@GET("users")
suspend fun getUsers(@Header("Authorization") token: String): List<User>
@Headers("Cache-Control: no-cache")
@GET("users")
suspend fun getNoCacheUsers(): List<User>Form-Encoded and Multipart
@FormUrlEncoded
@POST("login")
suspend fun login(
@Field("username") username: String,
@Field("password") password: String
): Response<Token>
@Multipart
@POST("upload")
suspend fun uploadFile(
@Part file: MultipartBody.Part,
@Part("description") description: RequestBody
): Response<Unit>Custom OkHttpClient (Timeouts, Logging, Interceptors)
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
val client = OkHttpClient.Builder()
.addInterceptor(logging)
.connectTimeout(30, TimeUnit.SECONDS)
.build()
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()- Always check
response.isSuccessful. - Use
response.errorBody()for error details. - Use try/catch for network exceptions.
try {
val response = api.getUser(1)
if (response.isSuccessful) {
val user = response.body()
} else {
val error = response.errorBody()?.string()
}
} catch (e: IOException) {
// Network error
}Call adapters in Retrofit determine how the HTTP response is returned from your API interface methods. They allow you to use different return types, such as suspend (for coroutines), LiveData, Flow, RxJava types, or even custom types.
Coroutines (suspend functions):
- Use
suspendfor modern Kotlin code. Retrofit will execute the request asynchronously and return the result directly.
@GET("users")
suspend fun getUsers(): List<User>Usage:
viewModelScope.launch {
val users = api.getUsers()
// Use users
}LiveData:
- Return a
LiveDataobject that emits the response. Useful for integration with Android's architecture components.
@GET("users")
fun getUsers(): LiveData<List<User>>Usage:
api.getUsers().observe(this) { users ->
// Update UI
}Flow (Kotlin Coroutines):
- With the
retrofit2-kotlin-coroutines-adapteror custom adapters, you can return aFlow.
@GET("users")
fun getUsers(): Flow<List<User>>Usage:
viewModelScope.launch {
api.getUsers().collect { users ->
// Use users
}
}You can create your own call adapter to support custom return types or integrate with other frameworks.
Example: Custom Result Wrapper
Suppose you want all API calls to return a sealed Result<T> type:
sealed class Result<out T> {
data class Success<T>(val data: T): Result<T>()
data class Error(val exception: Throwable): Result<Nothing>()
}Custom Call Adapter Factory:
class ResultCallAdapterFactory : CallAdapter.Factory() {
override fun get(
returnType: Type,
annotations: Array<Annotation>,
retrofit: Retrofit
): CallAdapter<*, *>? {
// Check if returnType is Result<T>, then return your adapter
// (Implementation omitted for brevity)
}
}Register the Adapter:
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(MoshiConverterFactory.create())
.addCallAdapterFactory(ResultCallAdapterFactory())
.build()Usage:
@GET("users")
suspend fun getUsers(): Result<List<User>>
// In your code:
when (val result = api.getUsers()) {
is Result.Success -> { /* Use result.data */ }
is Result.Error -> { /* Handle error */ }
}Summary Table:
| Return Type | Call Adapter Needed? | Example Usage |
|---|---|---|
suspend |
Built-in (since 2.6.0) | suspend fun getUsers(): List<User> |
LiveData<T> |
Yes (LiveDataCallAdapter) |
fun getUsers(): LiveData<List<User>> |
Flow<T> |
Yes (FlowCallAdapter) |
fun getUsers(): Flow<List<User>> |
Single<T>/RxJava |
Yes (RxJava adapter) | fun getUsers(): Single<List<User>> |
Custom (Result<T>) |
Yes (custom adapter) | suspend fun getUsers(): Result<List<User>> |
References:
- Gson:
addConverterFactory(GsonConverterFactory.create()) - Moshi:
addConverterFactory(MoshiConverterFactory.create()) - kotlinx.serialization:
addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
val mockWebServer = MockWebServer()
mockWebServer.enqueue(MockResponse().setBody("""{"id":1,"name":"Test","email":"test@example.com"}"""))
mockWebServer.start()
val retrofit = Retrofit.Builder()
.baseUrl(mockWebServer.url("/"))
.addConverterFactory(MoshiConverterFactory.create())
.build()Do:
- Use
suspendfunctions for coroutines. - Always call APIs off the main thread.
- Use dependency injection for Retrofit/ApiService.
- Secure API keys (do not hardcode).
- Use interceptors for authentication, logging, and error handling.
- Handle pagination and errors gracefully.
Don’t:
- Expose secrets or tokens in code.
- Ignore HTTP error codes.
- Block the main thread with network calls.
| Feature | Annotation/Usage | Example |
|---|---|---|
| GET | @GET |
@GET("users") |
| POST | @POST |
@POST("users") |
| Path Param | @Path |
@GET("users/{id}") |
| Query Param | @Query |
@GET("users?age=20") |
| Body | @Body |
@POST("users") |
| Header | @Header/@Headers |
@Header("Auth") |
| Form | @FormUrlEncoded/@Field |
@POST("login") |
| Multipart | @Multipart/@Part |
@POST("upload") |
| Dynamic URL | @Url |
@GET |
// Data model
data class User(val id: Int, val name: String, val email: String)
// API interface
interface ApiService {
@GET("users/{id}")
suspend fun getUser(@Path("id") id: Int): Response<User>
@GET("users")
suspend fun getUsers(@Query("page") page: Int): List<User>
@POST("users")
suspend fun createUser(@Body user: User): Response<User>
}
// Retrofit setup
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(MoshiConverterFactory.create())
.build()
val api = retrofit.create(ApiService::class.java)
// Usage in ViewModel or Repository
suspend fun fetchUser(id: Int): User? {
return try {
val response = api.getUser(id)
if (response.isSuccessful) response.body() else null
} catch (e: Exception) {
null
}
}References:
The HTTP client used by Retrofit.
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
val client = OkHttpClient.Builder()
.addInterceptor(logging)
.readTimeout(30, TimeUnit.SECONDS)
.build()Do:
- Use connection pooling, caching, and timeouts.
- Handle exceptions.
Don’t:
- Perform retries without backoff.
- Ignore SSL issues.
Unit tests verify business logic in isolation, typically using JUnit. In Kotlin, use kotlin.test or JUnit 4/5.
- Mocks: Simulate dependencies by verifying interactions or stubbing responses. Use for verifying calls, argument matching, or when the dependency is complex (e.g., network, database).
- Fakes: Lightweight implementations with working logic, often in-memory. Use for simple, predictable behavior (e.g., in-memory repository).
When to use:
- Use mocks when you need to verify interactions or simulate errors.
- Use fakes when you want realistic, fast, and deterministic behavior.
Suppose you have a UserRepository and a UserViewModel that loads users.
Mock Example (using MockK):
class UserViewModelTest {
private val repository = mockk<UserRepository>()
@Test
fun `loadUsers updates users list`() = runTest {
coEvery { repository.getUsers() } returns listOf(User(1, "Alice"))
val viewModel = UserViewModel(repository)
viewModel.loadUsers()
assertEquals(listOf(User(1, "Alice")), viewModel.users.value)
coVerify { repository.getUsers() }
}
}Fake Example:
class FakeUserRepository : UserRepository {
private val users = mutableListOf<User>()
override suspend fun getUsers(): List<User> = users
fun addUser(user: User) { users.add(user) }
}
@Test
fun `loadUsers returns users from fake repo`() = runTest {
val fakeRepo = FakeUserRepository().apply { addUser(User(2, "Bob")) }
val viewModel = UserViewModel(fakeRepo)
viewModel.loadUsers()
assertEquals(listOf(User(2, "Bob")), viewModel.users.value)
}- Use
@Beforefor setup and@Afterfor cleanup. - Use
runTestfor coroutine code. - Avoid real network or database in unit tests.
Robolectric runs Android framework code on the JVM, enabling fast tests for Activities, Fragments, and Views without an emulator.
Sample Robolectric Test:
@RunWith(RobolectricTestRunner::class)
class MainActivityTest {
@Test
fun clickingButton_showsText() {
val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get()
activity.findViewById<Button>(R.id.button).performClick()
val text = activity.findViewById<TextView>(R.id.textView).text
assertEquals("Clicked!", text)
}
}Notes:
- Use Robolectric for logic/UI tests that don't require device hardware or full integration.
- For real device features (camera, sensors), use instrumentation tests.
- Robolectric is best for fast feedback and CI.
Use AndroidX Test and Espresso for UI tests on device/emulator.
@Test
fun clickButton_opensDetail() {
onView(withId(R.id.buttonNext)).perform(click())
onView(withText("Details Page")).check(matches(isDisplayed()))
}Use createComposeRule() and onNode assertions.
@get:Rule val composeTestRule = createComposeRule()
@Test
fun testGreetingDisplay() {
composeTestRule.setContent {
Greeting("Android")
}
composeTestRule.onNodeWithText("Hello, Android!").assertIsDisplayed()
}Use runTest and collect Flows. The Turbine library simplifies this.
@Test
fun testFlowEmitsValues() = runTest {
val fakeRepo = FakeRepository()
val viewModel = MyViewModel(fakeRepo)
viewModel.scores().test {
fakeRepo.emit(1)
assertEquals(10, awaitItem())
fakeRepo.emit(2)
assertEquals(20, awaitItem())
cancelAndIgnoreRemainingEvents()
}
}Do:
- Test coroutine code with test dispatchers.
- Use Turbine or
toList()inrunTestfor collecting Flows.
Don’t:
- Rely on timing; make tests deterministic.
- Test private methods directly.
- Use
InstantTaskExecutorRulefor LiveData tests. - Use
@HiltAndroidTestfor DI-enabled tests. - Prefer deterministic, isolated, and fast tests.
- Use coverage tools (
jacoco) to measure test coverage. - Separate unit, integration, and UI tests in your Gradle setup.
Do not store sensitive data in plaintext SharedPreferences or files. Use Android Keystore and AndroidX Security libraries.
- Android Keystore: Store cryptographic keys so they are non-exportable.
- EncryptedSharedPreferences: Uses keystore-backed keys to encrypt preferences.
Example: Securely storing data with EncryptedSharedPreferences
// Add dependency in build.gradle:
// implementation "androidx.security:security-crypto:1.1.0-alpha06"
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val sharedPreferences = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
// Store sensitive data
sharedPreferences.edit().putString("token", "my_secret_token").apply()
// Retrieve sensitive data
val token = sharedPreferences.getString("token", null)Example: Generating and using a key with Android Keystore
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
// Generate a key
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
)
keyGenerator.init(
KeyGenParameterSpec.Builder(
"my_key_alias",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
)
keyGenerator.generateKey()
// Retrieve the key
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val secretKey = keyStore.getKey("my_key_alias", null) as SecretKey
// Use the key for encryption
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val ciphertext = cipher.doFinal("Sensitive data".toByteArray())Do:
- Use
EncryptedSharedPreferencesfor storing sensitive key-value data. - Use Android Keystore for cryptographic operations and key management.
Don’t:
- Store secrets, tokens, or passwords in plaintext or regular SharedPreferences.
- Hardcode sensitive data in your codebase.
Obfuscation helps protect your app from reverse engineering by renaming classes, methods, and fields, removing unused code, and optimizing bytecode. Android uses R8 (default) or ProGuard for code shrinking and obfuscation.
Enable R8/ProGuard for release builds in your build.gradle:
android {
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}Customize obfuscation and shrinking with rules in proguard-rules.pro.
Common Rules:
-
Keep Application Classes:
-keep class com.example.app.** { *; } -
Keep All Activities, Services, BroadcastReceivers, ContentProviders:
-keep public class * extends android.app.Activity -keep public class * extends android.app.Service -keep public class * extends android.content.BroadcastReceiver -keep public class * extends android.content.ContentProvider -
Keep Custom Views (used in XML):
-keep public class * extends android.view.View { public <init>(android.content.Context, android.util.AttributeSet); } -
Keep Serializable/Parcelable Models:
-keepclassmembers class * implements java.io.Serializable { static final long serialVersionUID; private static final java.io.ObjectStreamField[] serialPersistentFields; private void writeObject(java.io.ObjectOutputStream); private void readObject(java.io.ObjectInputStream); java.lang.Object writeReplace(); java.lang.Object readResolve(); } -keep class * implements android.os.Parcelable { *;
Native code can also be reverse-engineered.
Do:
- Avoid storing secrets in native code or resources.
Always use HTTPS/TLS. Use Network Security Config to enforce security policies.
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
</domain-config>
</network-security-config>Do:
- Update
android:networkSecurityConfigin the Manifest. - Use certificate pinning for extra security.
Don’t:
- Trust user-added CAs or allow cleartext. --
A Man-in-the-Middle (MITM) attack occurs when an attacker intercepts and possibly alters the communication between your app and the server, often by exploiting insecure network connections or trusting compromised Certificate Authorities (CAs).
Risks:
- Sensitive data (tokens, credentials) can be stolen.
- API requests/responses can be tampered with.
-
Always Use HTTPS/TLS:
Never transmit sensitive data over HTTP. -
Validate Certificates:
Ensure your app only trusts valid, non-expired certificates. -
SSL Pinning:
Restrict your app to trust only specific server certificates or public keys, even if a CA is compromised.
What is SSL Pinning?
SSL pinning means your app only accepts a specific certificate or public key for your backend, preventing attackers from using fraudulent certificates.
How to Implement SSL Pinning with OkHttp:
-
Obtain the SHA-256 hash of your server’s certificate or public key.
-
Configure OkHttp with a CertificatePinner:
val certificatePinner = CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()- Replace the hash with your actual certificate’s SHA-256 pin.
- You can get the pin using
opensslor tools like SSL Labs.
Multiple Pins (for rotation):
.add("api.example.com", "sha256/OLD_PIN==")
.add("api.example.com", "sha256/NEW_PIN==")- If the server’s certificate does not match the pinned value, OkHttp will throw an
SSLPeerUnverifiedException. - Test with a proxy (e.g., Charles Proxy, mitmproxy) to ensure MITM attempts are blocked.
Do:
- Pin to the public key (not the certificate) to allow certificate renewal without breaking the app.
- Pin all relevant subdomains if your API uses them.
- Rotate pins before certificates expire.
Don’t:
- Pin to a test or staging certificate in production builds.
- Disable certificate validation for debugging (never ship with
trustAll).
- OkHttp Certificate Pinning Docs
- Android Security: Network Security Config
- OWASP: Certificate and Public Key Pinning
Follow Android’s official tips for faster builds.
- Enable Gradle Daemon and parallel builds in
gradle.properties. - Use KSP instead of Kapt.
- Avoid unnecessary resources.
- Keep Android Gradle Plugin and dependencies up to date.
Write tasks in Kotlin DSL.
tasks.register("printVersion") {
doLast {
println("App Version: ${project.properties["VERSION_NAME"]}")
}
}Define buildTypes and productFlavors in Gradle.
android {
defaultConfig { applicationId "com.example.app" }
buildTypes {
release {
isMinifyEnabled = true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug { }
}
flavorDimensions "tier"
productFlavors {
demo { dimension "tier"; applicationIdSuffix ".demo" }
full { dimension "tier" }
}
}Do:
- Use
applicationIdSuffixorversionNameSuffixto distinguish flavors.
Build an Android App Bundle (AAB) for release. Use Play App Signing.
Signing:
Upload a cryptographic key to Play Console or enroll in Google Play App Signing.
Versioning:
Follow semantic versioning with versionCode and versionName in Gradle.
Integrate the Play Integrity API to ensure your app is genuine.
Use Dynamic Feature Modules for on-demand or conditional delivery.
Do: Test your release build and signing process.
Don’t: Release debug builds or un-optimized APKs.
Do: Monitor Android Vitals in Play Console.
Don’t: Violate Play policies.
Sources: Android official documentation and developer guides.