<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Prem Singh | Android Engineering]]></title><description><![CDATA[Prem Singh | Android Engineering]]></description><link>https://prem1997.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 27 Oct 2026 00:53:32 GMT</lastBuildDate><atom:link href="https://prem1997.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Beyond the Run Button: Android Debugging with ADB and Gradle]]></title><description><![CDATA[A practical Android workflow: build with Gradle, install with ADB, inspect logs, test deep links and permissions, and capture bug evidence.]]></description><link>https://prem1997.hashnode.dev/beyond-the-run-button-android-debugging-with-adb-and-gradle</link><guid isPermaLink="true">https://prem1997.hashnode.dev/beyond-the-run-button-android-debugging-with-adb-and-gradle</guid><category><![CDATA[Android]]></category><dc:creator><![CDATA[Prem Singh]]></dc:creator><pubDate>Sun, 18 Oct 2026 03:38:35 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad434485cee9186f4ea072e/897c25ab-91c8-4712-a9b5-beccbe41322a.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>“It works when I press Run” is a starting point. A useful debugging workflow also lets another developer reproduce the same build, launch the same route, and collect evidence of the failure.</p>
<p>Here is a compact terminal workflow for a debug app. Each step answers a question rather than adding another command to memorize.</p>
<h2>Before you start</h2>
<p>Examples use macOS/Linux shell syntax, an <code>app</code> module, and a <code>debug</code> variant. On Windows, use <code>gradlew.bat</code> and adapt shell variables and quoting. Run Gradle commands from the project root. Your configured JDK must be compatible with the project's Android Gradle Plugin.</p>
<p>Have SDK Platform Tools on your PATH and an authorized test device or emulator connected:</p>
<pre><code class="language-bash">adb devices -l
export ANDROID_SERIAL=emulator-5554
</code></pre>
<p>Replace the serial with one from the output. <code>ANDROID_SERIAL</code> selects the target for subsequent ADB commands. An <code>unauthorized</code> device needs approval on the unlocked device.</p>
<h2>1. Can someone else build this exact variant?</h2>
<p>Use the project's Gradle Wrapper:</p>
<pre><code class="language-bash">./gradlew :app:assembleDebug
</code></pre>
<p>For a conventional project, the APK is at <code>app/build/outputs/apk/debug/app-debug.apk</code>. Flavors and output customization change tasks and paths; inspect the actual project:</p>
<pre><code class="language-bash">./gradlew :app:tasks --all
</code></pre>
<p>For a flavor named <code>demo</code>, look for <code>assembleDemoDebug</code>. A successful build produces an artifact; it doesn't mean the app was installed or launched. These task conventions are described in the <a href="https://developer.android.com/build/building-cmdline">Android command-line build guide</a>.</p>
<h2>2. Is the build on the intended device?</h2>
<pre><code class="language-bash">adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell am start -W -n com.example.debugcli/.MainActivity
</code></pre>
<p>Replace the application ID and activity with your debug variant's actual values. <code>-r</code> replaces an installed compatible package while retaining its data. A signature mismatch needs investigation; deleting an app is not a neutral fix because it removes local data.</p>
<p>Alternatively, <code>./gradlew :app:installDebug</code> builds and installs. Check its output to confirm the selected device or devices when several are connected. See the <a href="https://developer.android.com/tools/adb">ADB guide</a>.</p>
<h2>3. Which logs belong to the running process?</h2>
<p>After launching the app, get its current PID:</p>
<pre><code class="language-bash">adb shell pidof -s com.example.debugcli
</code></pre>
<p>Suppose it prints <code>12345</code>. Use that value:</p>
<pre><code class="language-bash">adb logcat --pid=12345 -v threadtime
</code></pre>
<p>Stop streaming with Ctrl+C. An empty PID usually means the process isn't running or the application ID is wrong. Recheck it after a restart: a new process can have a new PID. Separate app processes need separate consideration.</p>
<p>A PID filter is focused, but can hide useful system-side evidence. For crash investigation, also inspect the crash buffer:</p>
<pre><code class="language-bash">adb logcat -b crash -d -v threadtime &gt; crash-log.txt
</code></pre>
<p>This dumps available crash-buffer entries, which can include other apps and older failures. Match timestamps and package/process details. It is not a complete ANR diagnosis. Inspect supported options with <code>adb logcat --help</code>; see the <a href="https://developer.android.com/tools/logcat">Logcat command-line reference</a>.</p>
<h2>4. Does the deep link actually reach the right screen?</h2>
<p>Assume your manifest declares <code>debugcli://product/42</code> as a supported route:</p>
<pre><code class="language-bash">adb shell am start -W -a android.intent.action.VIEW -d 'debugcli://product/42' com.example.debugcli
</code></pre>
<p>The package restriction checks routing inside your app. Also test without that restriction to observe normal system resolution:</p>
<pre><code class="language-bash">adb shell am start -W -a android.intent.action.VIEW -d 'debugcli://product/42'
</code></pre>
<p>Success means more than an activity opening: verify the destination, arguments, sign-in behavior, and Back navigation. A custom scheme doesn't prove HTTPS App Links verification. For HTTPS links, test the real domain and its verification separately. See <a href="https://developer.android.com/training/app-links/create-deeplinks">Create deep links</a>.</p>
<h2>5. Does the feature handle a denied permission?</h2>
<p>For an app that declares CAMERA and supports runtime permissions:</p>
<pre><code class="language-bash">adb shell pm revoke com.example.debugcli android.permission.CAMERA
adb shell pm grant com.example.debugcli android.permission.CAMERA
</code></pre>
<p>Run these separately: revoke, exercise the denied path, then grant and exercise the granted path. Permission changes can affect the app process, so recheck its PID.</p>
<p>Shell grants don't test the permission dialog or every denial state. Also test the real request flow and the feature's fallback behavior. See <a href="https://developer.android.com/training/permissions/requesting">Request runtime permissions</a>.</p>
<h2>6. Can you attach evidence to the issue?</h2>
<pre><code class="language-bash">adb exec-out screencap -p &gt; debug-screen.png
adb shell screenrecord --time-limit 20 /sdcard/debug-demo.mp4
adb pull /sdcard/debug-demo.mp4 ./debug-demo.mp4
</code></pre>
<p>The recording command waits while recording. Pull the file after it finishes. Standard <code>screenrecord</code> doesn't record audio; protected content may be unavailable. See <a href="https://developer.android.com/tools/adb#screencap">ADB screen capture and recording</a>.</p>
<h2>Turn the workflow into a useful bug report</h2>
<p>Attach a short reproduction recipe with the variant, application ID, device/Android version, triggering command, expected result, and observed result. Add a timestamped log excerpt and a short recording when they explain the failure. Use test data and review logs and media before sharing them.</p>
<p>For your portfolio, include a <code>DEBUGGING.md</code> beside a sample app. Document one reproducible bug, the evidence that isolated it, and the fix. That shows the reasoning behind your tooling skills.</p>
<p>Which command saves you the most time when a bug refuses to reproduce from Android Studio?</p>
<hr />
<p>Official references linked above. Commands are adaptable examples.</p>
<p>Prem Singh · Lead Android Engineer · Kotlin, Jetpack Compose &amp; Android architecture</p>
<p><a href="https://github.com/premsinghsodha7">GitHub</a> · <a href="https://www.linkedin.com/in/premsinghsodha7/">LinkedIn</a></p>
]]></content:encoded></item><item><title><![CDATA[The Compose Search Bug That Debounce Doesn't Fix]]></title><description><![CDATA[Fix stale Android search results with Flow cancellation, correct delay placement, per-request error handling, and explicit retry.]]></description><link>https://prem1997.hashnode.dev/the-compose-search-bug-that-debounce-doesn-t-fix</link><guid isPermaLink="true">https://prem1997.hashnode.dev/the-compose-search-bug-that-debounce-doesn-t-fix</guid><category><![CDATA[Android]]></category><dc:creator><![CDATA[Prem Singh]]></dc:creator><pubDate>Sun, 18 Oct 2026 03:23:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad434485cee9186f4ea072e/0357edc0-a939-45e9-ada2-c0ee22bce877.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A user searches for <code>cat</code>, then changes the query to <code>car</code>. For a moment, results for cats appear under the new query.</p>
<p>You already added <code>debounce(300)</code> and <code>flatMapLatest</code>. So why can the old request still finish?</p>
<p>The answer is operator placement. Debouncing reduces request frequency. It does not automatically cancel an in-flight search the instant the user edits the text.</p>
<h2>The familiar pipeline has a timing window</h2>
<p>Consider this simplified chain:</p>
<pre><code class="language-kotlin">queries
    .debounce(300)
    .flatMapLatest { query -&gt; searchFlow(query) }
</code></pre>
<p>After the user types <code>car</code>, the new value waits inside <code>debounce</code>. Until that value reaches <code>flatMapLatest</code>, the previous inner flow can continue. If the <code>cat</code> response arrives during that gap, it can still be emitted.</p>
<p>The <a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/debounce.html">Kotlin debounce reference</a> describes suppression of rapidly followed values. The <a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/flat-map-latest.html">flatMapLatest reference</a> describes cancellation when a new upstream value reaches that operator. Those are different moments.</p>
<p>This behavior can be acceptable when the UI deliberately keeps old results visible and labels them correctly. If you want a new query to cancel old work promptly, move the waiting period inside the switched flow.</p>
<h2>Switch first, wait second</h2>
<p>The following integration sketch accepts a Flow of query strings and a cancellable suspend search function. It separates waiting, loading, empty results, and failure so the UI can communicate each state clearly.</p>
<pre><code class="language-kotlin">import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map

data class SearchHit(val id: String, val title: String)

sealed interface SearchState {
    data object Idle : SearchState
    data class Waiting(val query: String) : SearchState
    data class Loading(val query: String) : SearchState
    data class Results(
        val query: String,
        val items: List&lt;SearchHit&gt;,
    ) : SearchState
    data class Failed(val query: String) : SearchState
}

@OptIn(ExperimentalCoroutinesApi::class)
fun searchStates(
    queries: Flow&lt;String&gt;,
    search: suspend (String) -&gt; List&lt;SearchHit&gt;,
): Flow&lt;SearchState&gt; = queries
    .map { it.trim() }
    .distinctUntilChanged()
    .flatMapLatest { query -&gt;
        flow&lt;SearchState&gt; {
            if (query.length &lt; 2) {
                emit(SearchState.Idle)
            } else {
                emit(SearchState.Waiting(query))
                delay(300)
                emit(SearchState.Loading(query))
                try {
                    emit(SearchState.Results(query, search(query)))
                } catch (cancelled: CancellationException) {
                    throw cancelled
                } catch (_: Exception) {
                    emit(SearchState.Failed(query))
                }
            }
        }
    }
    .buffer(0)
</code></pre>
<p>This is a Flow-layer example, not a complete Compose app. Connect your repository and UI using dependencies compatible with your project. The experimental opt-in reflects the linked API reference; check the status for your coroutines version.</p>
<p>Now a new distinct, trimmed query reaches <code>flatMapLatest</code> before the delay. It cancels the previous inner flow, including its waiting period or cooperative in-flight request. The new flow then starts its own waiting period.</p>
<p><code>buffer(0)</code> avoids an extra queued output buffer after the switching operator. It cannot retract a value the UI has already received, so keep the query attached to every result and render it honestly.</p>
<h2>Keep text input responsive</h2>
<p>Debounce the search operation, not the text field's displayed value. Typing and cursor movement should update immediately.</p>
<p>For state-based Compose text fields, <code>TextFieldState</code> owns editable text, selection, and composition. You can observe its text with <code>snapshotFlow</code> and feed that into the search pipeline. Preserve the text field state rather than replacing it with a fresh instance on every recomposition. The <a href="https://developer.android.com/reference/kotlin/androidx/compose/foundation/text/input/TextFieldState">Android TextFieldState reference</a> includes examples.</p>
<p>Choose the lifetime of the collector deliberately. For screen state on Android, lifecycle-aware collection is generally appropriate; a search operation should not accidentally gain a lifetime longer than its owner.</p>
<h2>Three details worth getting right</h2>
<p><strong>A blank query must reach the switch.</strong> Filtering out short queries before <code>flatMapLatest</code> can leave an older search active when the user clears the field. This example emits <code>Idle</code> inside the switched flow instead.</p>
<p><strong>Catch errors per request.</strong> The failure handler sits inside the inner flow. A failed request becomes a state while the outer query stream remains available for later input. A single <code>catch</code> after the whole pipeline can instead complete that collection after an error.</p>
<p><strong>Cancellation is cooperative.</strong> A suspend function is not automatically well behaved. The repository must support cancellation and avoid blocking the calling thread. Cancelling a client request also doesn't guarantee the server stopped processing it. Never swallow <code>CancellationException</code> as an ordinary search error.</p>
<h2>What about retrying the same query?</h2>
<p><code>distinctUntilChanged</code> intentionally suppresses equal adjacent queries. Assigning the same string again won't implement retry.</p>
<p>Model retry as a separate action. For example, include an attempt counter in a request model and increment it on retry, so the request stream sees a new value even when the query is unchanged. Decide whether explicit retries should bypass the typing delay.</p>
<h2>Test the timing, not just the final list</h2>
<p>Use a fake search implementation and virtual time to verify these scenarios:</p>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>Expected behavior</th>
</tr>
</thead>
<tbody><tr>
<td>Rapid typing</td>
<td>Only the settled query starts a search</td>
</tr>
<tr>
<td>Edit during a request</td>
<td>Previous cooperative request is cancelled when the new query reaches the switch</td>
</tr>
<tr>
<td>Clear the field</td>
<td>State becomes Idle; previous work is cancelled</td>
</tr>
<tr>
<td>Request fails, then query changes</td>
<td>A new search can still run</td>
</tr>
<tr>
<td>Retry unchanged text</td>
<td>Explicit retry creates another attempt</td>
</tr>
</tbody></table>
<p>A small demo showing the query, current state, and cancellation logs makes this a useful portfolio feature. It demonstrates an engineering decision that a polished screenshot alone cannot show.</p>
<p>The next time you review a search pipeline, ask: does cancellation happen when the user changes intent, or only after the debounce window?</p>
<hr />
<p>Prem Singh · Lead Android Engineer · Kotlin, Jetpack Compose &amp; Android architecture</p>
<p><a href="https://github.com/premsinghsodha7">GitHub</a> · <a href="https://www.linkedin.com/in/premsinghsodha7/">LinkedIn</a></p>
]]></content:encoded></item><item><title><![CDATA[Your Compose Screen Is Gone. Why Is Your Flow Still Running?]]></title><description><![CDATA[Understand collectAsStateWithLifecycle, WhileSubscribed, cancellation, cached state, and retry with practical Kotlin examples for Android.]]></description><link>https://prem1997.hashnode.dev/your-compose-screen-is-gone-why-is-your-flow-still-running</link><guid isPermaLink="true">https://prem1997.hashnode.dev/your-compose-screen-is-gone-why-is-your-flow-still-running</guid><category><![CDATA[Android]]></category><dc:creator><![CDATA[Prem Singh]]></dc:creator><pubDate>Sun, 18 Oct 2026 03:11:16 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad434485cee9186f4ea072e/f763b515-c363-4ab9-bb7d-ac45cb0bee52.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Press Home on an Android app. The screen disappears, but that doesn't automatically mean every coroutine feeding it has stopped.</p>
<p>For a search screen, live dashboard, or AI response view, this distinction matters. A UI collector and the producer behind it have separate lifetimes. Reviewing only one can leave unnecessary work running—or stop work that the product actually needs to finish.</p>
<p>Here's a small pattern worth understanding before adding more architecture to a Compose screen.</p>
<h2>1. Collect screen state with the Android lifecycle</h2>
<p>For an Android Compose route backed by a ViewModel, use lifecycle-aware collection:</p>
<pre><code class="language-kotlin">import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun FeedRoute(viewModel: FeedViewModel) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    FeedScreen(state)
}
</code></pre>
<p><code>FeedScreen</code> is your rendering composable; <code>FeedViewModel</code> is supplied by your app's normal ViewModel creation mechanism. Add <code>androidx.lifecycle:lifecycle-runtime-compose</code> using a version aligned with your project's Lifecycle dependencies.</p>
<p>By default, collection runs when the lifecycle is at least <code>STARTED</code>. It stops below that state and starts again when the lifecycle becomes active. This is different from ordinary <code>collectAsState()</code>, whose collection follows the composition rather than applying this lifecycle threshold. See the <a href="https://developer.android.com/develop/ui/compose/state">Android Compose state guide</a>.</p>
<p>That fixes the consumer side. Now look upstream.</p>
<h2>2. Give the upstream an explicit sharing policy</h2>
<p>Here is an integration sketch for a repository that observes feed items. It assumes a cold Flow whose observation work is cancelled when its collector is cancelled. Adapt the model and repository to your app.</p>
<pre><code class="language-kotlin">import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

data class FeedItem(val id: String, val title: String)

sealed interface FeedUiState {
    data object Loading : FeedUiState
    data class Content(val items: List&lt;FeedItem&gt;) : FeedUiState
    data object Error : FeedUiState
}

interface FeedRepository {
    fun observeItems(): Flow&lt;List&lt;FeedItem&gt;&gt;
}

class FeedViewModel(repository: FeedRepository) : ViewModel() {
    val uiState: StateFlow&lt;FeedUiState&gt; = repository.observeItems()
        .map&lt;List&lt;FeedItem&gt;, FeedUiState&gt; { items -&gt;
            FeedUiState.Content(items)
        }
        .catch { cause -&gt;
            if (cause is CancellationException) throw cause
            emit(FeedUiState.Error)
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(
                stopTimeoutMillis = 5_000,
            ),
            initialValue = FeedUiState.Loading,
        )
}
</code></pre>
<p>The explicit <code>map</code> result type allows both <code>Content</code> and <code>Error</code> to flow through the same pipeline. The cancellation guard makes the intended behavior clear: cancelling observation should not become a visible failure.</p>
<p>These are focused integration snippets, not a complete sample app. The repository, UI rendering, and dependency setup remain application-specific.</p>
<h2>3. Read the timeline, not just the API name</h2>
<p>With the policy above, the expected sequence is:</p>
<pre><code class="language-text">UI starts collecting       -&gt; upstream observation starts
last subscriber disappears -&gt; five-second grace period begins
subscriber returns early   -&gt; existing observation continues
no subscriber after 5s     -&gt; sharing cancels upstream collection
subscriber returns later   -&gt; upstream collection starts again
</code></pre>
<p>The grace period can bridge short subscriber gaps such as configuration changes. Five seconds is a choice, not a universal performance rule.</p>
<p>The default replay expiration retains the last value: stopping the upstream doesn't automatically put <code>uiState</code> back into <code>Loading</code>. Returning users can see cached content while observation restarts. Read the <a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-sharing-started/-companion/-while-subscribed.html">Kotlin WhileSubscribed reference</a> before changing cache behavior.</p>
<h2>4. Know what this pattern does not stop</h2>
<p><code>WhileSubscribed</code> controls this sharing coroutine. It doesn't cancel every job in the ViewModel or shut down an independently running hot producer.</p>
<p>For example, this job has a different owner:</p>
<pre><code class="language-kotlin">init {
    viewModelScope.launch {
        repository.refreshOnce()
    }
}
</code></pre>
<p>Here <code>refreshOnce()</code> represents an application-specific suspend function. Its job can continue after the UI stops collecting because it was launched separately in <code>viewModelScope</code>.</p>
<p>This can be intentional. An explicit user action and an observational feed don't always need the same lifetime. Decide whether an operation should stop when the screen becomes inactive, survive a navigation change, or require durable execution. Then choose its owner.</p>
<h2>5. Handle failures and retry deliberately</h2>
<p>The <code>catch</code> above turns a failure into an error state, but it does not continuously retry. Once that upstream attempt completes, a retry action needs an explicit strategy, such as a refresh trigger that starts a new observation, or a bounded <code>retryWhen</code> policy before <code>catch</code> for suitable transient failures.</p>
<p>Don't rely on backgrounding and reopening the app as the user's retry mechanism. Also consider retaining existing content alongside a refresh error when that serves the screen better.</p>
<h2>A useful code review exercise</h2>
<p>For the next Flow-backed screen you review, trace four things:</p>
<ol>
<li><p>Which lifecycle controls the UI collector?</p>
</li>
<li><p>Which scope owns the producer?</p>
</li>
<li><p>Does cancellation actually reach the underlying work?</p>
</li>
<li><p>What happens after failure, and who triggers another attempt?</p>
</li>
</ol>
<p>For a portfolio demo, instrument a fake cold Flow with start and cancellation logs, then record navigating away and returning before and after the timeout. Show the logs beside the screen recording. That demonstrates the behavior far more clearly than a screenshot of an API call.</p>
<p>The key design decision is ownership: the screen's visibility, the observer's lifetime, and the operation's lifetime should each be intentional.</p>
<p>What do you usually find in reviews: missing lifecycle-aware collection, or upstream work with an unexpected lifetime?</p>
<hr />
<p>Prem Singh · Lead Android Engineer · Kotlin, Jetpack Compose &amp; Android architecture</p>
<p><a href="https://github.com/premsinghsodha7">GitHub</a> · <a href="https://www.linkedin.com/in/premsinghsodha7/">LinkedIn</a></p>
]]></content:encoded></item></channel></rss>