If your Jetpack Compose app supports light and dark themes, you’ve probably noticed the default behavior: a sudden cut between color schemes when the system UI mode changes.
I think we’ve all seen theme changes that look like this:
We can do better.
By wrapping your theme setup with some animation, we can achieve smooth transitions between light and dark themes that feel much more polished — without rebuilding your entire app structure.
If you’ve been working with Android for any amount of time, you’ve probably run into APIs that expose their results using callbacks or listeners. Whether it’s something like LocationManager, a custom SDK, or a third-party service like Firebase, you’re often stuck adapting old-school async patterns into your modern reactive code.
This approach is especially helpful in apps using Jetpack Compose, coroutines, or unidirectional data flow.
Fortunately, Kotlin’s callbackFlow makes this much easier. In this post, we’ll show how to wrap a listener-based API using callbackFlow, so you can collect updates as a Flow. We’ll use the Firebase Realtime Database as an example, but this pattern works for nearly anything.
As Android applications grow in complexity, maintaining a robust testing strategy becomes increasingly challenging. The “Robot Testing Pattern” offers a structured approach to UI testing that can significantly improve your test suite’s maintainability and reliability. This guide is designed for Android developers who have basic experience with testing and want to enhance their testing methodology.
Understanding the Robot Pattern
The Robot Testing Pattern, also known as the Robot Framework or Robot Pattern, is a testing methodology that creates an abstraction layer between your test code and UI interactions. Think of robots as specialized assistants that handle all the UI interactions on behalf of your tests.
Key Benefits
The Robot Pattern provides several advantages that make it particularly valuable for Android testing:
Improved Test Readability: Tests become high-level descriptions of user behavior rather than low-level UI interactions, making them easier to understand and maintain.
Enhanced Maintainability: When UI changes occur, updates are needed only in the robot implementation rather than across multiple test files.
Code Reusability: Common interactions can be shared across multiple test cases, reducing duplication and ensuring consistency.
Better Test Organization: The pattern enforces a clear separation between test logic and UI interaction code.
Simplified Parallel Testing: Isolated UI interaction logic enables efficient parallel test execution and better test stability.
Implementation Guide
Let’s explore how to implement the Robot Pattern in both Jetpack Compose and traditional View-based applications.
Jetpack Compose Implementation
Here’s a basic implementation for a login screen using Compose:
123456789101112131415161718192021222324
class LoginRobot {
private val composeTestRule = createComposeRule()
fun enterUsername(username: String) = apply {
composeTestRule.onNodeWithContentDescription("UsernameTextField")
.performTextInput(username)
}
fun enterPassword(password: String) = apply {
composeTestRule.onNodeWithContentDescription("PasswordTextField")
.performTextInput(password)
}
fun clickLoginButton() = apply {
composeTestRule.onNodeWithContentDescription("LoginButton")
.performClick()
}
// Add verification methods
fun verifyErrorMessage(message: String) = apply {
composeTestRule.onNodeWithText(message)
.assertIsDisplayed()
}
}
View-Based Implementation
For applications using traditional Views, the robot implementation looks slightly different:
12345678910111213141516171819202122
class LoginRobot {
fun enterUsername(username: String) = apply {
onView(withId(R.id.editTextUsername))
.perform(typeText(username))
}
fun enterPassword(password: String) = apply {
onView(withId(R.id.editTextPassword))
.perform(typeText(password))
}
fun clickLoginButton() = apply {
onView(withId(R.id.buttonLogin))
.perform(click())
}
// Add verification methods
fun verifyErrorMessage(message: String) = apply {
onView(withId(R.id.textViewError))
.check(matches(withText(message)))
}
}
Writing Tests with Robots
With these robot implementations, your tests become much more readable and maintainable:
1234567891011121314151617181920212223242526272829
@Test
fun loginWithValidCredentials() {
val loginRobot = LoginRobot()
composeTestRule.setContent {
LoginScreen()
}
loginRobot
.enterUsername("user@example.com")
.enterPassword("password123")
.clickLoginButton()
.verifyLoginSuccess()
}
@Test
fun loginWithInvalidCredentials() {
val loginRobot = LoginRobot()
composeTestRule.setContent {
LoginScreen()
}
loginRobot
.enterUsername("invalid@example.com")
.enterPassword("wrongpassword")
.clickLoginButton()
.verifyErrorMessage("Invalid credentials")
}
Creating a Base Robot
To promote code reuse and establish common testing patterns, create a base robot class:
1234567891011121314151617181920212223
abstract class BaseRobot {
protected val composeTestRule = createComposeRule()
protected fun waitForElement(
matcher: SemanticsMatcher,
timeoutMs: Long = 5000
) {
composeTestRule.waitUntil(timeoutMs) {
composeTestRule
.onAllNodes(matcher)
.fetchSemanticsNodes().isNotEmpty()
}
}
protected fun assertIsDisplayed(matcher: SemanticsMatcher) {
composeTestRule.onNode(matcher).assertIsDisplayed()
}
protected fun assertTextEquals(matcher: SemanticsMatcher, text: String) {
composeTestRule.onNode(matcher)
.assertTextEquals(text)
}
}
Best Practices
Keep Robots Focused: Each robot should represent a specific screen or component. Avoid creating “super robots” that handle multiple screens.
Include Verification Methods: Add methods to verify the state of the UI after actions are performed.
Use Method Chaining: Return the robot instance from each method to enable fluent method chaining.
Handle Async Operations: Include proper wait mechanisms for loading states and animations.
Document Robot Methods: Provide clear documentation for each robot method to explain its purpose and expected behavior.
The Robot Testing Pattern significantly improves the maintainability and reliability of Android UI tests. By separating UI interaction logic into dedicated robot classes, you create more organized, readable, and maintainable tests. When UI changes occur, updates are localized to the robot implementations rather than scattered across test files. Happy coding!
SharedPreferences has long been a staple for storing small pieces of data and user preferences in Android apps. However, it has notable limitations, such as a lack of type safety, no support for safe schema evolution, and potential performance issues on the main thread. Google introduced Proto DataStore as a modern and robust alternative, offering:
Strong typing with Protocol Buffers
Safe schema evolution
Built-in migration support
Flow API for reactive programming
Coroutines support for main-thread safety
In this post, we’ll walk through the process of migrating your existing SharedPreferences data to Proto DataStore without data loss, including best practices and common pitfalls to avoid.
Step 1: Add Dependencies
First, add the necessary dependencies to your app’s build.gradle file:
class UserPreferencesRepository(private val context: Context) {
private val dataStore: DataStore<UserPreferences> = context.createDataStore(
fileName = "user_prefs.pb",
serializer = UserPreferencesSerializer
)
val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(UserPreferences.getDefaultInstance())
} else {
throw exception
}
}
suspend fun migrateFromSharedPreferences() = withContext(Dispatchers.IO) {
try {
val sharedPrefs = context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE)
// Read existing preferences
val userName = sharedPrefs.getString("user_name", "") ?: ""
val notificationsEnabled = sharedPrefs.getBoolean("notifications_enabled", true)
val theme = sharedPrefs.getString("theme", "system") ?: "system"
// Migrate to DataStore
dataStore.updateData { preferences ->
preferences.toBuilder()
.setUserName(userName)
.setNotificationsEnabled(notificationsEnabled)
.setTheme(theme)
.setSchemaVersion(1)
.build()
}
// Verify migration
val migratedData = dataStore.data.first()
if (migratedData.userName == userName &&
migratedData.notificationsEnabled == notificationsEnabled &&
migratedData.theme == theme) {
// Clear SharedPreferences after successful migration
sharedPrefs.edit().clear().apply()
Result.success(Unit)
} else {
Result.failure(Exception("Migration verification failed"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun updateUserName(name: String) {
dataStore.updateData { preferences ->
preferences.toBuilder()
.setUserName(name)
.build()
}
}
suspend fun updateNotificationsEnabled(enabled: Boolean) {
dataStore.updateData { preferences ->
preferences.toBuilder()
.setNotificationsEnabled(enabled)
.build()
}
}
suspend fun updateTheme(theme: String) {
dataStore.updateData { preferences ->
preferences.toBuilder()
.setTheme(theme)
.build()
}
}
}
Step 5: Use in Your App
Here’s how to use the repository in your app:
1234567891011121314151617181920212223242526
class MainActivity : AppCompatActivity() {
private lateinit var repository: UserPreferencesRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
repository = UserPreferencesRepository(applicationContext)
// Migrate data on app first launch
lifecycleScope.launch {
repository.migrateFromSharedPreferences()
}
// Observe preferences changes
lifecycleScope.launch {
repository.userPreferencesFlow.collect { preferences ->
// Update UI based on preferences
updateUI(preferences)
}
}
}
private fun updateUI(preferences: UserPreferences) {
// Update your UI components based on preferences
}
}
Testing Your Migration
Here’s an example of how to test your migration:
123456789101112131415161718192021222324252627
@Test
fun testMigrationFromSharedPreferences() = runTest {
val context = ApplicationProvider.getApplicationContext<Context>()
// Set up test SharedPreferences
val sharedPrefs = context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE)
sharedPrefs.edit()
.putString("user_name", "Test User")
.putBoolean("notifications_enabled", true)
.putString("theme", "dark")
.apply()
// Perform migration
val repository = UserPreferencesRepository(context)
val result = repository.migrateFromSharedPreferences()
// Verify migration
assert(result.isSuccess)
val migratedData = repository.userPreferencesFlow.first()
assertEquals("Test User", migratedData.userName)
assertTrue(migratedData.notificationsEnabled)
assertEquals("dark", migratedData.theme)
// Verify SharedPreferences was cleared
assertTrue(sharedPrefs.all.isEmpty())
}
Best Practices and Common Pitfalls
Schema Evolution: When adding new fields to your proto schema, always:
Use new field numbers
Provide default values
Increment the schema version
Error Handling: Always handle potential exceptions when reading/writing data:
IOException for file system issues
CorruptionException for invalid data
InvalidProtocolBufferException for proto parsing errors
Performance:
Perform migrations in the background using Coroutines
Use Flow’s collectLatest when frequent updates aren’t necessary
Consider caching frequently accessed values
Testing:
Write unit tests for your repository
Test migration with various SharedPreferences states
Include error cases in your tests
Test schema evolution scenarios
Conclusion
Migrating from SharedPreferences to Proto DataStore requires some initial setup, but the benefits of type safety, schema evolution, and reactive programming make it worthwhile. The resulting code will be more maintainable, type-safe, and resistant to runtime errors. Happy coding!
Ktlint is a powerful linting tool for Kotlin code that helps maintain code quality and consistency. While it comes with a set of built-in rules, there may be cases where you want to create custom rules tailored to your project’s specific requirements. In this tutorial, we’ll walk you through the process of writing a custom ktlint rule that detects and removes Android Log statements from your Kotlin code.
Prerequisites
Before we dive into writing custom ktlint rules, ensure you have ktlint installed in your project. The tasks ktlintApplyToIdea and addKtlintCheckTask are provided by the ktlint Gradle plugin. If you haven’t already, include the plugin in your project by adding the following to your build.gradle.kts file:
123
plugins {
id("org.jlleitschuh.gradle.ktlint") version "<latest-version>"
}
Once the plugin is applied, run the following command to set up ktlint in your project:
1
./gradlew ktlintApplyToIdea addKtlintCheckTask
Writing a Custom ktlint Rule
1. Create a New Module
To write a custom ktlint rule, start by creating a new module in your Kotlin project.
2. Set Up Your Project
In your new module, make sure you have ktlint as a dependency. Add it to your build.gradle.kts or build.gradle file:
Now, let’s define our custom rule. Create a Kotlin class that extends the Rule class and override the visit method to define the logic for your rule. In this example, we want to detect Android Log statements.
As with any code we write, it’s great practice to write tests for your custom rule to ensure it behaves as expected. Use the ktlint testing library to create a test case that demonstrates the rule’s functionality.
Run ktlint in your project to check for Android Log statements and automatically remove them using the --apply-to-idea flag:
1
./gradlew ktlintApplyToIdea
Conclusion
In this tutorial, we’ve shown you how to write a custom ktlint rule to detect and remove Android Log statements from your Kotlin code. Custom ktlint rules can help you maintain code quality and consistency by enforcing project-specific coding standards. This is obviously a simple example, but this is a good example to adapt for other custom rules that fit your project’s needs.
Ktlint is a powerful tool that, when combined with custom rules, can significantly improve your Kotlin codebase’s quality and readability. Happy coding!
Modern Android development often involves modularization to enhance build performance and maintainability. As you break down your app into multiple modules, sharing test utilities such as fixtures, mocks, or fakes between modules can become challenging. A few years ago I wrote about the concept of test fixtures, but at the time they weren’t supported for Android modules. Thankfully, with Android Gradle Plugin (AGP) 8.5.0, Google introduced native support for test fixtures, streamlining this process significantly.
What Are Test Fixtures?
Test fixtures are reusable components such as test data, helper classes, or mocks that you can use to support your tests. Prior to AGP 8.5.0, sharing these utilities across modules often required workarounds like creating dedicated “test” modules or manually wiring dependencies. With AGP 8.5.0, the testFixtures feature makes it easier to declare and consume test fixtures directly from your modules.
Setting Up Test Fixtures
To enable test fixtures for a module, you need to include the testFixtures feature in your module’s build.gradle.kts file. Here’s how you can set it up:
123456789
android {
// Enable test fixtures for the module
testFixtures.enable = true
}
dependencies {
// Declare dependencies for your test fixtures
testFixturesImplementation("com.example:some-library:1.0.0")
}
The testFixtures source set is automatically created under the src directory:
You can now place reusable test utilities, such as mock data generators or fake implementations, inside the testFixtures directory.
Consuming Test Fixtures
Modules can consume test fixtures by declaring a dependency on the testFixtures configuration of another module. For example, if module-b needs to use the test fixtures from module-a, add the following dependency:
Once this dependency is added, the test code in module-b can seamlessly access the fixtures provided by module-a.
Example: Sharing a Fake API Client
Suppose you have a fake API client used in multiple test cases. You can define it in the testFixtures source set of module-a:
123456
// module-a/src/testFixtures/kotlin/com/example/api/FakeApiClient.kt
package com.example.api
class FakeApiClient {
fun getFakeData(): String = "Fake Data"
}
Then, in module-b, you can consume and use this fake client:
1234567891011121314
// module-b/src/test/kotlin/com/example/test/ApiClientTest.kt
package com.example.test
import com.example.api.FakeApiClient
import org.junit.Test
class ApiClientTest {
@Test
fun testFakeData() {
val fakeApiClient = FakeApiClient()
assert(fakeApiClient.getFakeData() == "Fake Data")
}
}
Conclusion
The testFixtures feature in AGP 8.5.0 is a game-changer for modularized Android projects. It eliminates the friction of sharing test utilities, allowing you to write cleaner, more maintainable test setups. If you’re working on a modularized project and haven’t tried testFixtures yet, now is the perfect time to incorporate it into your testing strategy.
Tracking how users install your app can be tricky, especially when you want to measure the effectiveness of your campaigns or ads. That’s where the Google Play Install Referrer API comes in handy—it gives you reliable data about how users found your app.
If you’re developing an Android application, this guide will walk you through how to set up and use the InstallReferrerClient with Kotlin’s callbackFlow to make integration simpler and more maintainable.
What is the Install Referrer?
The install referrer contains information about the source of the app installation. For example, if a user clicks an ad campaign link that leads to your app’s Play Store page, the referrer might include details about the campaign source, medium, or other specifics.
Here’s how this data can help:
Attribution: Identify which campaigns drive installs.
Measure Success: Evaluate your marketing efforts’ impact.
Prevent Fraud: Verify referrer data to avoid fraudulent installs.
Setting Up and Using the InstallReferrerClient
Step 1: Add the Dependency
First, include the Install Referrer library in your build.gradle file:
Kotlin Multiplatform (KMP) offers a powerful way to share code across platforms like Android, iOS, and the JVM. However, when it comes to testing, you might encounter a common challenge: how to handle test resources such as JSON files, configurations, or other data needed for tests. In this post, we’ll dive into strategies for accessing test resources in KMP projects.
Why Test Resources Matter
Test resources are essential for validating your code against real-world scenarios. For example, if you’re writing a library to parse JSON, you’d want to test it against diverse JSON samples representing different edge cases. While resource access is straightforward in single-platform projects, KMP’s multi-target nature requires some additional setup.
The Challenge in Kotlin Multiplatform
In KMP, test code resides in the commonTest source set, but resources aren’t directly bundled with it. Each platform manages file paths and resource access differently, so you need platform-specific setups for your resources and shared logic to load them efficiently.
Organizing Test Resources
Start by structuring your resources in a way that aligns with your project layout:
This setup ensures that each target platform can access its respective resources while keeping them logically grouped.
Loading Resources by Platform
For Android, place your test resources in the androidTest/resources directory. Use the javaClass.classLoader to load them:
12345
fun loadTestResource(resourceName: String): String {
val inputStream = javaClass.classLoader?.getResourceAsStream(resourceName)
return inputStream?.bufferedReader()?.use { it.readText() }
?: throw IllegalArgumentException("Resource not found: $resourceName")
}
For iOS, add your resources to the test target in Xcode. Use platform-specific code to load them:
12345678
import platform.Foundation.*
fun loadTestResource(resourceName: String): String {
val bundle = NSBundle.bundleForClass(MyTestClass::class)
val path = bundle.pathForResource(resourceName, ofType = null)
?: throw IllegalArgumentException("Resource not found: $resourceName")
return NSString.stringWithContentsOfFile(path, encoding = NSUTF8StringEncoding, error = null) as String
}
Sharing the Resource Loader
To avoid duplication, define a common function in commonTest and use the expect/actual pattern for platform-specific implementations:
1234567891011121314151617
// commonTest
expect fun loadTestResource(resourceName: String): String
// androidTest
actual fun loadTestResource(resourceName: String): String {
val inputStream = javaClass.classLoader?.getResourceAsStream(resourceName)
return inputStream?.bufferedReader()?.use { it.readText() }
?: throw IllegalArgumentException("Resource not found: $resourceName")
}
// iosTest
actual fun loadTestResource(resourceName: String): String {
val bundle = NSBundle.bundleForClass(MyTestClass::class)
val path = bundle.pathForResource(resourceName, ofType = null)
?: throw IllegalArgumentException("Resource not found: $resourceName")
return NSString.stringWithContentsOfFile(path, encoding = NSUTF8StringEncoding, error = null) as String
}
Putting It All Together
With this setup, your commonTest code can seamlessly access resources across platforms. For example:
123456
@Test
fun testJsonParsing() {
val jsonData = loadTestResource("test-data.json")
val parsedData = parseJson(jsonData)
assertEquals(expectedData, parsedData)
}
Accessing test resources in Kotlin Multiplatform involves a bit of extra setup, but the payoff is a unified testing strategy across platforms. By leveraging the expect/actual pattern, you can create platform-specific resource loaders while keeping your test logic shared and consistent. Dive into KMP’s capabilities, and streamline your tests for every target platform!
A long, long time ago, I wrote a blog post about how I was using Flipper as one of my favorite development tools. Since then, Android Studio has come a long way adding tons of features like a new Logcat and Layout Inspector.
However, there are often times that you’ll need a tool more specific to your own workflow that Android Studio doesn’t provide, and that’s exactly where Flipper’s extensibility really shines. As an example, I’d like to go through building a custom plugin for Flipper, similar to one that I’ve used on my own projects, that demonstrates how easy it is to get started building these tools.
Late last year, Google announced the Android Developer Challenge, a contest for Android developers to show off new experiences made possible by on-device Machine Learning. Since then, tons of developers have submitted their ideas and been hard at work developing their apps. Today, the winners of the challenge have been announced!