Many years ago, during an internship at a company that imported generic tablets and sold them with a custom version of Android, I noticed a bottleneck. Every tablet needed thorough software and hardware testing before shipping to customers. To streamline the process, I decided to build an application that could run all these checks in one place, much faster. That was the day Testy was born.
The architecture and technical decisions behind Testy

Every long-lived utility app starts with a simple promise: solve one small problem well, and do it fast. When Testy was first built, it was a lightweight Android tool designed to run quick system checks and diagnostic tests. The initial codebase reflected that simplicity — a flat structure, straightforward Activity/Fragment patterns, and minimal abstractions.
However, as Testy evolved and the feature set expanded — adding asynchronous hardware queries, deeper system monitoring, background background tasks, and custom device benchmarks — the initial monolithic design began to show friction. Adding new features risked breaking existing functionality, and writing unit tests for tightly coupled UI and system-level logic became increasingly complex.
To ensure long-term maintainability, scale, and testability, I executed a complete refactoring of Testy’s architecture, transitioning the entire application to Clean Architecture principles.
Key Architectural Layers
By decoupling the codebase into distinct layers with strict dependency boundaries, Testy became modular and easy to extend:
LayerComponent ResponsibilitiesKey Dependencies & Data FlowUI / Presentation LayerJetpack Compose, ViewModels, UI State ReducersDepends on Domain Layer Use Cases; observes immutable UiState.Domain LayerPure Kotlin Business Logic, Use Cases, Repository ContractsFramework-agnostic; defines abstractions implemented by the Data Layer.Data & Framework LayerAndroid SDK System APIs (SensorManager, etc.), Hardware Managers, RepositoriesImplements Domain interfaces; encapsulates low-level system hardware.
Key Architectural Layers
By decoupling the codebase into distinct layers with strict dependency boundaries, Testy became modular and easy to extend:
┌────────────────────────────────────────────────────────────────────────┐
│ UI / PRESENTATION LAYER │
│ (Jetpack Compose / ViewModels / UI State Management) │
└───────────────────────────────────┬────────────────────────────────────┘
│ depends on
▼
┌────────────────────────────────────────────────────────────────────────┐
│ DOMAIN LAYER │
│ (Use Cases / Business Logic / Interface Contracts) │
└───────────────────────────────────▲────────────────────────────────────┘
│ implemented by
┌───────────────────────────────────┴────────────────────────────────────┘
│ DATA & FRAMEWORK LAYER │
│ (Android System APIs / Hardware Managers / Repository Impls) │
└────────────────────────────────────────────────────────────────────────┘
- Domain Layer (Pure Kotlin): Houses the core business logic and Android-agnostic models. By extracting diagnostic logic into self-contained Use Cases (e.g., CheckSensorStatusUseCase, MeasureDisplayLatencyUseCase), the core app behavior remains isolated from framework changes.
// Domain layer: pure Kotlin
data class BatteryInfo(
val levelPercent: Int,
val isCharging: Boolean,
)
interface BatteryRepository {
fun observeBatteryInfo(): Flow<BatteryInfo>
}
class ObserveBatteryInfoUseCase(
private val repository: BatteryRepository,
) {
operator fun invoke(): Flow<BatteryInfo> =
repository.observeBatteryInfo()
}
2. Data & Framework Layer: Wraps low-level Android SDK APIs (SensorManager, BatteryManager, TelephonyManager, etc.) behind clear interface abstractions. This layer manages hardware access, asynchronous data streams, and local caching.
// Data layer: Android-specific implementation
class AndroidBatteryRepository(
private val context: Context,
) : BatteryRepository {
override fun observeBatteryInfo(): Flow<BatteryInfo> = flow {
while (currentCoroutineContext().isActive) {
val batteryManager =
context.getSystemService<BatteryManager>()
val level = batteryManager
?.getIntProperty(
BatteryManager.BATTERY_PROPERTY_CAPACITY,
)
?: -1
emit(
BatteryInfo(
levelPercent = level,
isCharging = isDeviceCharging(context),
),
)
delay(5_000)
}
}
}
3. Presentation Layer: Built to reactively render device diagnostics, transforming domain models into clear, predictable UI state while handling lifecycle-aware operations safely.
State management and how the application evolved
As Testy evolved beyond its initial diagnostic capabilities, it expanded into a comprehensive utility suite. Everyday hardware tools were added — including a Voice Recorder, Precision Stopwatch, Bubble Level, and Digital Compass.
Later, Testy introduced two major power-user features:
- Advanced Application Manager: A deep-system inspection tool that allows users to view installed applications, review detailed permissions, and inspect raw AndroidManifest.xml files—details typically hidden by default Android launchers.
- Real-Time Performance Dashboard: A live monitor tracking CPU, memory, and thermal statistics, featuring an export engine that generates detailed diagnostic reports to share with phone repair technicians.
Managing State across Diverse Tools
Handling such varied tools presented unique state management challenges. High-frequency sensor streams from the digital compass require near-instantaneous UI updates, whereas the Application Manager performs heavy background parsing of installed APKs.
To keep the UI responsive and predictable, Testy relies on Unidirectional Data Flow (UDF) with Kotlin Flow and immutable UI State objects within ViewModels:
data class BatteryUiState(
val isLoading: Boolean = true,
val batteryLevel: Int? = null,
val isCharging: Boolean = false,
val error: String? = null,
)
class BatteryViewModel(
observeBatteryInfo: ObserveBatteryInfoUseCase,
) : ViewModel() {
val uiState: StateFlow<BatteryUiState> =
observeBatteryInfo()
.map { info ->
BatteryUiState(
isLoading = false,
batteryLevel = info.levelPercent,
isCharging = info.isCharging,
)
}
.catch { exception ->
emit(
BatteryUiState(
isLoading = false,
error = exception.message
?: "Unable to read battery information",
),
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = BatteryUiState(),
)
}
@Composable
fun BatteryScreen(
viewModel: BatteryViewModel,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
when {
state.isLoading -> {
CircularProgressIndicator()
}
state.error != null -> {
Text(text = state.error)
}
else -> {
Column {
Text("Battery: ${state.batteryLevel}%")
Text(
if (state.isCharging) {
"Charging"
} else {
"Not charging"
},
)
}
}
}
}
- Single Source of Truth: Each feature screen observes a single, immutable UiState stream exposed via StateFlow.
- Predictable State Transformations: UI actions and hardware events flow through dedicated ViewModel methods that apply clear state reductions (CurrentState, Change) -> NewState. This prevents race conditions when background system updates meet user input.
- Lifecycle-Aware Collection: UI components collect state updates using collectAsStateWithLifecycle(). High-frequency hardware sampling (like compass tilt or CPU monitoring) automatically pauses when the screen goes to the background, saving battery.
Core Technical Decisions
Integrating these complex utilities into a unified architecture relied on three foundational decisions:
- Decoupling System APIs from Context: Framework managers (SensorManager, PackageManager, AudioRecord) are wrapped behind clean domain interfaces. Decoupling hardware access from Activity lifecycles eliminated memory leaks and made system logic fully unit-testable.
- Asynchronous Execution with Coroutines: Offloading heavy system queries — such as manifest parsing or continuous audio monitoring — to dedicated background dispatchers (Dispatchers.IO / Dispatchers.Default) keeps the UI rendering smoothly at 60+ FPS.
- Modular Feature Design: Clean Architecture allowed new utilities to be added seamlessly. Building a new tool requires only writing its domain Use Cases, defining its state contract, and rendering a Composable view, without touching the core app pipeline.
:feature:display-diagnostics
├── domain
│ ├── MeasureDisplayLatencyUseCase.kt
│ └── DisplayDiagnosticsRepository.kt
├── data
│ └── DisplayDiagnosticsRepositoryImpl.kt
└── presentation
├── DisplayDiagnosticsViewModel.kt
└── DisplayDiagnosticsScreen.kt
Performance optimization and scaling

class CompassSensorDataSource(
private val sensorManager: SensorManager,
) {
private val accelerometer =
sensorManager.getDefaultSensor(
Sensor.TYPE_ACCELEROMETER,
)
fun observeAcceleration(): Flow<FloatArray> = callbackFlow {
val sensor = accelerometer
?: run {
close(
IllegalStateException(
"Accelerometer is unavailable",
),
)
return@callbackFlow
}
val listener = object : SensorEventListener {
override fun onSensorChanged(
event: SensorEvent,
) {
trySend(event.values.copyOf())
}
override fun onAccuracyChanged(
sensor: Sensor?,
accuracy: Int,
) = Unit
}
val registered = sensorManager.registerListener(
listener,
sensor,
SensorManager.SENSOR_DELAY_GAME,
)
if (!registered) {
close(
IllegalStateException(
"Unable to register sensor listener",
),
)
return@callbackFlow
}
awaitClose {
sensorManager.unregisterListener(listener)
}
}
}
Scaling an app that continuously queries hardware and system services required strict performance tuning:
- Throttling & Debouncing Streams: High-rate hardware sensors (e.g., accelerometer and gyroscope readings emitting at up to 100 Hz) are sampled and debounced before triggering state updates, preventing unnecessary UI recompositions.
@OptIn(FlowPreview::class)
fun observeCompassUiUpdates(
sensorDataSource: CompassSensorDataSource,
): Flow<FloatArray> {
return sensorDataSource
.observeAcceleration()
.sample(100)
}
- Lazy Loading & Pagination in App Manager: Querying all installed packages and parsing their manifests can degrade performance on devices with hundreds of apps. Manifest parsing is lazily executed on demand, while app lists are rendered using Compose LazyColumn with stable keys to avoid unnecessary redraws.
- Zero Memory Leaks with Lifecycle-Aware Disposal: System callbacks (SensorEventListener, LocationCallback, AudioRecord) are bound directly to screen lifecycles via coroutines and Compose side-effects, ensuring hardware listeners are immediately released when navigating away.
Any interesting engineering challenges or lessons learned while building Tasty
Building a system utility app like Testy means working directly with raw hardware, low-level APIs, and vendor quirks rather than predictable REST endpoints.
1. Vendor Fragmentation
- Challenge: Hardware sensors behave differently across OEM devices — emitting inconsistent frequencies, dropping readings, or returning uncalibrated values.
- Lesson learned: Never feed raw hardware events directly into the UI. Sanitizing and normalizing sensor streams within the Data layer guarantees predictable models for the rest of the app.
2. Battery & Resource Efficiency
- Challenge: Live tools like the digital compass or real-time CPU monitor can rapidly drain the battery if high-frequency polling stays active in the background.
- Lesson learned: Strict lifecycle management is critical. Using StateFlow with collectAsStateWithLifecycle() ensures hardware listeners immediately pause emissions the moment a screen is no longer visible.
3. Evolving Android SDK Requirements
- Challenge: Android updates continuously add stricter permissions, background execution limits, and mandatory layout behaviors (like edge-to-edge support).
- Lesson learned: Architecture boundaries pay off. Because system APIs are isolated in the Data layer, adapting to new SDK requirements never breaks core domain logic.
The Next Chapter
What started purely as a fun side project to experiment with modern Android architecture and solve personal utility needs grew far beyond my initial expectations. I never built Testy with monetization in mind — it was simply a sandbox for clean code and utility design.
That made it an even more incredible surprise when the team at Rounds.com came across Testy. They reached out with an offer to acquire the app, and after discussing the vision for its future, we reached a great deal together. It was a rewarding milestone to see a side project come full circle — from a simple diagnostic tool to an acquired app powered by a modern Clean Architecture foundation.
Last, but not least
Ultimately, this journey taught me that software is more than code, it’s a digital asset.
If you see a real need, build it clean, scale it thoughtfully, and enjoy the process.
And if you ever reach a point where you’re ready to focus on your next build, working with a direct buyer like Rounds.com is a great way to unlock that capital and keep moving forward.
How I Built & Scaled Testy (Acquired by Rounds.com) was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.