A Parental Lock That Never Worked, Accidentally Fixed by an Unrelated Feature
In software engineering, the hardest bugs to track down are rarely the ones that crash your application. Instead, they are the logical flaws that masquerade as working code, “silently failing” while surviving multiple layers of testing and code reviews. Recently, while working on a Live TV app for an Android TV set-top box, I stumbled upon a dormant architectural defect that was accidentally exposed by a completely unrelated UI requirement.
This is a real-world case study on Kotlin Coroutine state management, testing blind spots, and runtime lifecycles.
Background: The Architecture of the Parental Rating Lock
The data flow for the parental rating lock in this project was originally designed as follows: a rating index is stored in the system’s platform settings. The app uses a lookup table to map this index to an actual rating threshold. When a program’s rating meets or exceeds this threshold, the system locks the screen and prompts for a PIN code.
The lookup logic is located in SettingsCodecs.kt (actual rating values are represented by age-based classifications):
fun ratingThreshold(index: Int): Int = when (index) {
0 -> PROTECTED // Protected
1 -> PG12 // PG-12
2 -> PG15 // PG-15
else -> RESTRICTED // Restricted
}
There’s a logical detail here that’s easy to read backwards: the lower the threshold, the stricter the lock. If the threshold is set to “Protected”, it means all programs rated “Protected” and above must be locked. This is the strictest setting. Conversely, if the threshold is set to “Restricted”, only restricted programs will trigger the lock—this is the most lenient option in the table.
The Repository layer wraps this setting into a Flow:
override fun getRatingThreshold(): Flow<Int> = refresh.map {
SettingsCodecs.ratingThreshold(getInt(SettingsKeys.PARENTAL_RATING, SettingsKeys.DEFAULT_PARENTAL_RATING))
}
Next, the ViewModel converts it into a StateFlow (located in LiveTvViewModel.kt:137) for subsequent use:
private val currentRatingThreshold = settingsRepository.getRatingThreshold()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = RESTRICTED // Default to Restricted
)
Finally, the app reads this threshold value in two core paths: tuneTo when changing channels, and the reevaluateCurrentChannel logic when transitioning between programs:
val threshold = currentRatingThreshold.value
val line: LockLine? = when {
channel.isBlocked -> LockLine.CHANNEL_BLOCK
else -> when (checkRatingLockUseCase(isAdult, rating, threshold)) { ... }
}
This implementation seems perfectly reasonable. This code lived peacefully in the project through several development phases, easily passed code review, and kept all 277 JVM unit tests glowing green.
The Problem Emerges: An Unrelated Feature Triggers the Lock
One day, I finished another requirement that was completely unrelated to the rating lock—adding a lock icon to the program infobar that pops up when switching channels.
I installed the new build on a physical device and switched through a few channels using the remote control. Suddenly, the parental lock’s PIN input screen popped up. Strangely, this specific device had never locked before. My first reaction was: the new feature broke something; this is a regression. So, I immediately grabbed the logcat output:
LOCK_TRIGGERED_BY_REEVAL sid=<Masked> rate=<Protected>
The logs showed that the program currently playing on the device was rated “Protected”. I dug further into the platform settings on the device and found that the parental rating threshold was indeed set to “Protected”—the program met the threshold and triggered the lock. This behavior was absolutely correct.
So the direction of the investigation completely flipped: the key wasn’t why it locked this time, but why it never locked before.
More importantly, that “Protected” setting wasn’t the factory default. I had manually changed it to the strictest rating myself during my previous task to test the parental lock. And when I made that change back then, no channels were locked.
Root Cause Analysis: Why Did the Security Mechanism Silently Fail?
After a deep dive, it turned out this issue was a perfect storm created by the superimposition of four seemingly reasonable coincidences.
1. WhileSubscribed is Lazy, and .value Doesn’t Count as a Subscription
The semantic design of SharingStarted.WhileSubscribed is: “start the upstream collection only when there are active subscribers, and stop it 5 seconds after the last subscriber leaves”. If there are no subscribers, the upstream getRatingThreshold() Flow will never be collected, and the StateFlow will remain indefinitely in its initialValue state.
Looking back at our reading end, they were all written like this:
val threshold = currentRatingThreshold.value
.value is a synchronous read operation; it does not establish a subscription, nor does it wake up the upstream.
Searching through the entire codebase, currentRatingThreshold appeared in exactly four places: one declaration, one newly added icon logic, and two .value reads. This meant that prior to this change, there were exactly zero flow subscribers in the entire project.
The result was a chain reaction: stateIn was forever dormant → .value always returned initialValue → the real parental settings on the platform were never actually read. Even if a user set the rating to the strictest level in the settings page, the app would blindly use the default “Restricted” threshold for comparison.
2. “Restricted” Sounds Strict, But It’s Actually the Most Lenient
initialValue = RESTRICTED // Default to Restricted
The developer who wrote this line probably thought, “I’ll default to the strictest setting, that’s safer.” And the word “Restricted” does literally sound like the most stringent classification.
But in the context of this lookup table, it represents the threshold, not just the rating: setting the threshold to Restricted means only Restricted programs get locked. This is the most lenient option in the table.
This resulted in a default value that read like a fail-safe, but actually behaved like a fail-open. If the initial value had been frozen at the “Protected” threshold (the strictest), this bug would have blown up on day one—because a bunch of channels would have been inexplicably locked, and someone definitely would have reported it. But because it was frozen at the most lenient end, the observable behavior was “the parental lock never triggers”. And a lock that never triggers looks exactly the same to the naked eye as a lock that “just happens to not have any programs exceeding its threshold”.
No one reports a lock that doesn’t happen.
3. Why Did All 277 Tests Miss It?
Let’s look at how the mock was set up in the ViewModel test:
every { settingsRepository.getRatingThreshold() } returns flowOf(RESTRICTED)
The mock returns “Restricted”, and the initialValue of stateIn is also “Restricted”.
Because these two values were identical, the test was structurally incapable of distinguishing between stateIn being awake and stateIn being asleep—in either case, .value would return the exact same value.
This doesn’t mean the tests weren’t written. The tests for the rating lock definitely existed, and they genuinely fed a “Restricted” program into it and successfully asserted that LockLine.RATING popped up (a restricted program meets the restricted threshold, the logic holds, the test passes). But what it verified was only that the “comparison logic is correct”; the threshold value it received was merely a coincidence, not the actual result flowing through the data pipeline.
Looking further down at the pure-function level CheckRatingLockUseCase:
operator fun invoke(isAdultChannel: Boolean, programRating: Int, threshold: Int): LockKind? =
session.evaluate(isAdultChannel, programRating, threshold)
threshold is just a passed-in parameter. Its unit tests had even less chance of catching the issue, because where the threshold came from was completely outside of its purview. This bug didn’t reside in the internal logic of any single unit; it hid in whether “the value actually flowed through”, which is exactly what every layer of mocking skips for you. Coupled with the repository’s default lookup also being “Restricted”, on any device where “the user hasn’t touched parental settings”, the dormant value and the correct value were perfectly identical, making their behaviors entirely indistinguishable.
4. Why Did Manual Testing Miss It Too?
During my previous task, I did manually test the parental lock, and the testing steps were standard: go to the settings page, manually change the rating threshold to the strictest ‘Protected’ level, then go back and switch channels to see if they lock. The result at that time was that zero channels were locked.
My rationalization at that moment was: at that specific time, all channels were broadcasting “General” rated programs, which are below the “Protected” threshold, so they shouldn’t be locked anyway. This rationalization was completely correct. It was the truth.
But this truth simultaneously masked the real problem. Because at that moment:
- If the app is working: threshold is Protected, program is General → do not lock.
- If the app is broken (dormant at Restricted threshold): program is General → also do not lock.
The observed outcomes for both scenarios are identical. That manual test would yield a “not locked” conclusion regardless of whether the app was good or bad. Its actual information yield was zero—yet it created the illusion that it had been “tested and works fine”.
For the dormant value and the true value to produce divergent behaviors, a program with a rating falling between the two (Protected or above, but below Restricted) is required. And when I switched channels this time, I coincidentally landed on a channel broadcasting a “Protected” program, fulfilling this condition. Combined with the new UI feature landing, this dormant bug finally surfaced under the guise of a “regression”.
There was actually an established rule next to our acceptance checklist: When there are no testable channels on the device, the item must be marked ‘Blocked: No testable channels’, and must not be marked PASS. This was a case of a null result being misread as a pass—and I misread it myself.
The Fix and Its Load-Bearing Side Effects
So, how exactly was this bug fixed?
To display the lock icon on the infobar, the new feature had to include the threshold value inside the UI state’s combine block (located in LiveTvViewModel.kt:184):
combine(
epgRepository.observeNextProgram(ch.uri),
_currentProgram,
currentRatingThreshold // ← The first real subscriber in the entire project
) { next, cur, threshold ->
...
channelMarkLocked = ch.isBlocked ||
ChannelGroup.ADULT in ch.groups ||
(rating >= 0 && rating >= threshold)
}
When combine runs, it collects all of its upstreams. This line of code gave currentRatingThreshold its first flow subscriber, awakening WhileSubscribed and prompting it to start collecting data from the repository, finally loading the real threshold value from the platform.
Consequently, both reading points—including the channel-switching path that had absolutely nothing to do with the new feature—became correct simultaneously.
A requirement to draw an icon inadvertently turned on a long-dormant security feature. And its very first appearance in the field looked like a regression.
The decision at that moment was: Do not fix the original business logic. That device locking up was correct; what needed fixing was the implicit dependency in the system architecture.
Right now, the parental lock functions correctly, relying entirely on the fact that the combine block for the infobar subscribes to currentRatingThreshold. This is an implicit dependency. It’s not documented anywhere, the type system can’t see it, and tests can’t guarantee it. In the future, if someone decides the combine block is getting too bloated and switches back to a .value read, refactors out the lock icon, or splits up the UI state, the parental lock will silently revert to its dormant state. It will compile successfully, tests will pass with flying colors, and the parental lock will quietly fail all over again.
This is what’s known as a load-bearing side effect. The most dangerous thing about it is that the code change removing it will look completely innocuous.
The architecturally correct fix is to ensure the security mechanism doesn’t rely on “someone happening to subscribe to the UI”. We can change the start strategy:
started = SharingStarted.Eagerly
Alternatively, we can use viewModelScope to launch a persistent collection directly. The correctness of core features like a parental lock should be active across all code paths, even before the UI has finished assembling. Of course, the trade-off is that after switching to eager, we must comprehensively verify that the behavior of obtaining the true value across all paths matches our expectations.
A Generalized Heuristic
From this experience, we can deduce a straightforward heuristic:
stateIn(WhileSubscribed) + reading exclusively via .value = this StateFlow is forever initialValue.
Taken individually, both mechanisms are perfectly normal. WhileSubscribed is the officially recommended approach for battery efficiency, and .value is perfectly valid for synchronous reads within a suspend function. The problem lies in their combination. If your project has a WhileSubscribed StateFlow, and all read points use .value without any collect, combine, or flatMapLatest to subscribe to it, then it’s just a decoration that will only ever return its initial value.
Another defensive strategy is: Don’t choose an initialValue that “looks safe”; choose a value that is immediately obviously wrong. If the initial value this time had been set to a sentinel value that wasn’t even in the lookup table, someone would have noticed the abnormal threshold value on day one. Instead, it was set to a value that was valid, the most lenient, identical to the default, and identical to the test mock—four coincidences stacking up to create this silent failure.
Key Takeaways
Finally, this incident left us with three critical engineering lessons:
-
Test mock values should not equal the default values of the code under test. Looking back, the only place this bug could have manifested in unit tests was that single mock line. As long as the mock provided something other than “Restricted”—say, any intermediate level—the same assertion would still pass, but the moment it covered boundary testing, the dormant state would have been instantly exposed. When the mock and the default are identical, the test merely verifies a “coincidence” rather than the “flow”.
-
A test where “nothing happened” is not evidence until the preconditions are confirmed. This was the most expensive lesson I learned. I tested the parental lock, got a “not locked” result, and provided a rational explanation (all programs were rated General). But “did not observe X” only has meaning if “X should have happened” is true. The information yield of a test when conditions are unmet is zero. If you can’t satisfy the conditions, it should be marked as blocked, not waved through.
-
“Statically green” and “running correctly” are two different things. This change went through a full planning review, was checked line-by-line by two independent reviewers, and passed all 277 JVM tests. From a static perspective, it was impeccable. But static reviews see code; they don’t see the runtime lifecycle. “Does this StateFlow have a subscriber at this exact moment?” is a runtime state. You can’t see that it never woke up. Defects involving cold starts, timing, and lifecycles only reveal themselves on real physical devices running the actual application. On-device acceptance testing is never just an empty formality.