From d4f87e3387fdf7961ce585db7921e99081bea8da Mon Sep 17 00:00:00 2001 From: Abhishek Bansal Date: Sun, 25 Oct 2026 06:32:32 +0530 Subject: [PATCH 1/2] Fix overlay crash on edge-to-edge Compose activities; add endless feed screen to the sample --- CHANGELOG.md | 1 + devdeck/src/main/kotlin/io/devdeck/DevDeck.kt | 8 ++ sample/README.md | 4 +- sample/src/main/AndroidManifest.xml | 3 + .../io/devdeck/sample/EndlessFeedActivity.kt | 128 ++++++++++++++++++ .../kotlin/io/devdeck/sample/MainActivity.kt | 1 + .../kotlin/io/devdeck/sample/SampleApp.kt | 2 +- .../io/devdeck/sample/ui/SampleScreen.kt | 5 +- 8 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 sample/src/main/kotlin/io/devdeck/sample/EndlessFeedActivity.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f3941b..e522d4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl - README states that DevDeck stays out of release builds only when added with `debugImplementation`. ### Fixed +- Apps no longer crash when `DevDeck.install` adds the drawer to an edge-to-edge Compose activity on recent Android ("Cannot locate windowRecomposer"); the overlay is now added once the window is attached. - Search placeholders and the network count row stay on one line on small phones. - The permissions list refreshes after a permission dialog or settings screen closes, and shows when a revoke only applies after the app restarts. - Tap breadcrumbs no longer record what was typed into text fields, so passwords can't end up in crash files or bug reports. diff --git a/devdeck/src/main/kotlin/io/devdeck/DevDeck.kt b/devdeck/src/main/kotlin/io/devdeck/DevDeck.kt index 2fedb8b..aea4dcb 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DevDeck.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DevDeck.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.view.doOnAttach import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeViewModelStoreOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner @@ -118,6 +119,13 @@ public object DevDeck { if (DeckExclusions.isExcluded(activity, current.excludedActivityPrefixes) || !current.includeActivity(activity)) return val decorView = activity.window.decorView as? ViewGroup ?: return if (decorView.findViewWithTag(DECK_OVERLAY_TAG) != null || HostedActivities.contains(activity)) return + // A ComposeView can't be measured before it's attached, and some windows (edge-to-edge activities on recent + // Android) measure views added in onStart without attaching them first. So the overlay is added after the + // window is attached, posted so it isn't added in the middle of the attach pass (which would skip it). + if (!decorView.isAttachedToWindow) { + decorView.doOnAttach { decorView.post { attach(activity) } } + return + } val overlay = ComposeView(activity).apply { tag = DECK_OVERLAY_TAG diff --git a/sample/README.md b/sample/README.md index eb56b1b..ce32276 100644 --- a/sample/README.md +++ b/sample/README.md @@ -8,8 +8,9 @@ The sample shows the two ways to attach the drawer: |---|---|---| | **Main screen** (Compose) | `DevDeckHost(plugins = …)` wraps the screen's content | `ui/SampleScreen.kt` | | **View-based screen** (plain Views, no Compose) | `DevDeck.install(...)` in `Application.onCreate` adds it to the activity automatically | `SampleApp.kt`, `LegacyViewActivity.kt` | +| **Endless feed** (Compose, edge-to-edge) | Same as the View-based screen, through `includeActivity` | `EndlessFeedActivity.kt` | -Both screens share one plugin list, so their drawers show the same menu. +All screens share one plugin list, so their drawers show the same menu. The endless feed loads 20 more items whenever you reach the bottom; use it to try *Take a long screenshot* on content that never ends. ## Project layout @@ -18,6 +19,7 @@ sample/src/main/kotlin/io/devdeck/sample/ ├── SampleApp.kt Creates the flag store and plugin list, and installs DevDeck app-wide ├── MainActivity.kt Hosts the Compose screen ├── LegacyViewActivity.kt View-only screen; also the target of the devdeck://sample/legacy deep link +├── EndlessFeedActivity.kt Compose list that keeps loading pages, for trying long screenshots ├── debugmenu/ Everything that defines what's inside the drawer │ ├── SampleDeckStyle.kt The drawer's title and subtitle, shared by both screens │ ├── SamplePlugins.kt The plugin list and network highlight rules: the whole debug menu in one place diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 8ec9a48..9420b0b 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -18,6 +18,9 @@ + diff --git a/sample/src/main/kotlin/io/devdeck/sample/EndlessFeedActivity.kt b/sample/src/main/kotlin/io/devdeck/sample/EndlessFeedActivity.kt new file mode 100644 index 0000000..aa8d2ee --- /dev/null +++ b/sample/src/main/kotlin/io/devdeck/sample/EndlessFeedActivity.kt @@ -0,0 +1,128 @@ +package io.devdeck.sample + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.devdeck.logs.DeckLogger +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filter + +/** + * A paged feed that never ends: reaching the bottom loads 20 more items after a short delay, like a real + * "load more" list. Use it to try *Take a long screenshot* on endless content. + */ +class EndlessFeedActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Surface(Modifier.fillMaxSize()) { EndlessFeed() } + } + } + } +} + +@Composable +private fun EndlessFeed() { + var count by remember { mutableIntStateOf(PAGE_SIZE) } + var pages by remember { mutableIntStateOf(1) } + var loading by remember { mutableStateOf(false) } + val listState = rememberLazyListState() + val nearEnd by remember { + derivedStateOf { + val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + last >= listState.layoutInfo.totalItemsCount - LOAD_AHEAD + } + } + LaunchedEffect(listState) { + snapshotFlow { nearEnd }.filter { it }.collect { + if (loading) return@collect + loading = true + delay(LOAD_DELAY_MILLIS) + count += PAGE_SIZE + pages++ + loading = false + DeckLogger.log("Feed page $pages loaded", tag = "Feed", payload = "$count items") + } + } + Column(Modifier.fillMaxSize().safeDrawingPadding()) { + Text( + "Endless feed", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + LazyColumn( + state = listState, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f), + ) { + items((1..count).toList(), key = { it }) { number -> FeedItem(number) } + item(key = "loading") { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.size(28.dp)) + } + } + } + } +} + +@Composable +private fun FeedItem(number: Int) { + // Each item gets its own colour and number, so seams or repeated pieces in a long screenshot are easy to spot. + val hue = (number * 37 % 360).toFloat() + Row( + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(12.dp)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(Modifier.size(48.dp).background(Color.hsv(hue, 0.55f, 0.85f), RoundedCornerShape(8.dp))) + Column { + Text("Item $number", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text("Page ${(number - 1) / PAGE_SIZE + 1} · loaded as you scroll", style = MaterialTheme.typography.bodyMedium) + } + } +} + +private const val PAGE_SIZE = 20 +private const val LOAD_AHEAD = 3 +private const val LOAD_DELAY_MILLIS = 800L diff --git a/sample/src/main/kotlin/io/devdeck/sample/MainActivity.kt b/sample/src/main/kotlin/io/devdeck/sample/MainActivity.kt index 383658d..bf690a9 100644 --- a/sample/src/main/kotlin/io/devdeck/sample/MainActivity.kt +++ b/sample/src/main/kotlin/io/devdeck/sample/MainActivity.kt @@ -23,6 +23,7 @@ class MainActivity : ComponentActivity() { plugins = app.plugins, flagStore = app.flagStore, onOpenLegacy = { startActivity(Intent(this, LegacyViewActivity::class.java)) }, + onOpenFeed = { startActivity(Intent(this, EndlessFeedActivity::class.java)) }, ) } } diff --git a/sample/src/main/kotlin/io/devdeck/sample/SampleApp.kt b/sample/src/main/kotlin/io/devdeck/sample/SampleApp.kt index 000181a..98b07eb 100644 --- a/sample/src/main/kotlin/io/devdeck/sample/SampleApp.kt +++ b/sample/src/main/kotlin/io/devdeck/sample/SampleApp.kt @@ -28,7 +28,7 @@ class SampleApp : Application() { application = this, plugins = plugins, style = sampleDeckStyle, - includeActivity = { it is LegacyViewActivity }, + includeActivity = { it is LegacyViewActivity || it is EndlessFeedActivity }, ) DeckLogger.log("App started") Timber.i("Sample app started") diff --git a/sample/src/main/kotlin/io/devdeck/sample/ui/SampleScreen.kt b/sample/src/main/kotlin/io/devdeck/sample/ui/SampleScreen.kt index 8f08d74..be4b229 100644 --- a/sample/src/main/kotlin/io/devdeck/sample/ui/SampleScreen.kt +++ b/sample/src/main/kotlin/io/devdeck/sample/ui/SampleScreen.kt @@ -23,7 +23,7 @@ import io.devdeck.rememberDeckState import io.devdeck.sample.debugmenu.sampleDeckStyle @Composable -fun SampleScreen(plugins: List, flagStore: FeatureFlagStore, onOpenLegacy: () -> Unit) { +fun SampleScreen(plugins: List, flagStore: FeatureFlagStore, onOpenLegacy: () -> Unit, onOpenFeed: () -> Unit) { val drawerState = rememberDeckState() val options = rememberDrawerOptions() @@ -71,6 +71,9 @@ fun SampleScreen(plugins: List, flagStore: FeatureFlagStore, onOpenL OutlinedButton(onClick = onOpenLegacy, modifier = Modifier.fillMaxWidth()) { Text("Open View-based screen (global install)") } + OutlinedButton(onClick = onOpenFeed, modifier = Modifier.fillMaxWidth()) { + Text("Open endless feed (try a long screenshot)") + } } } } From b151870fe6a1a94b7f13f2a5a745e0a15265775a Mon Sep 17 00:00:00 2001 From: Abhishek Bansal Date: Sun, 25 Oct 2026 06:35:28 +0530 Subject: [PATCH 2/2] Skip attaching to finished activities; feed waits for layout before loading more --- devdeck/src/main/kotlin/io/devdeck/DevDeck.kt | 2 +- .../main/kotlin/io/devdeck/sample/EndlessFeedActivity.kt | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/devdeck/src/main/kotlin/io/devdeck/DevDeck.kt b/devdeck/src/main/kotlin/io/devdeck/DevDeck.kt index aea4dcb..ba061ce 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DevDeck.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DevDeck.kt @@ -115,7 +115,7 @@ public object DevDeck { private fun attach(activity: Activity) { val current = config ?: return - if (activity !is ComponentActivity) return + if (activity !is ComponentActivity || activity.isFinishing || activity.isDestroyed) return if (DeckExclusions.isExcluded(activity, current.excludedActivityPrefixes) || !current.includeActivity(activity)) return val decorView = activity.window.decorView as? ViewGroup ?: return if (decorView.findViewWithTag(DECK_OVERLAY_TAG) != null || HostedActivities.contains(activity)) return diff --git a/sample/src/main/kotlin/io/devdeck/sample/EndlessFeedActivity.kt b/sample/src/main/kotlin/io/devdeck/sample/EndlessFeedActivity.kt index aa8d2ee..4ff4f3e 100644 --- a/sample/src/main/kotlin/io/devdeck/sample/EndlessFeedActivity.kt +++ b/sample/src/main/kotlin/io/devdeck/sample/EndlessFeedActivity.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator @@ -66,8 +65,9 @@ private fun EndlessFeed() { val listState = rememberLazyListState() val nearEnd by remember { derivedStateOf { - val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - last >= listState.layoutInfo.totalItemsCount - LOAD_AHEAD + val info = listState.layoutInfo + val last = info.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false + last >= info.totalItemsCount - LOAD_AHEAD } } LaunchedEffect(listState) { @@ -93,7 +93,7 @@ private fun EndlessFeed() { verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f), ) { - items((1..count).toList(), key = { it }) { number -> FeedItem(number) } + items(count, key = { it }) { index -> FeedItem(index + 1) } item(key = "loading") { Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(28.dp))