feat(android): widget, tile, and share-sheet capture entry points#31
feat(android): widget, tile, and share-sheet capture entry points#31
Conversation
Implements home screen widget, Quick Settings tile, and Android share target that each route to a translucent CaptureActivity for fast journal entry without leaving the current context. Key changes: - SteleKitApplication: app-scoped GraphManager + coroutine scope for widgets/tiles to use without Activity lifecycle coupling - CaptureActivity: translucent bottom-sheet overlay that writes to today's journal page; handles ACTION_SEND share intents (text, image) - CaptureViewModel: save path via DatabaseWriteActor → GraphWriter with ClosedSendChannelException guard for mid-graph-switch saves - CaptureTileService: QS tile with API 34+/pre-34 startActivityAndCollapse split; uses Tile.updateTile() (moved from TileService in API 36) - CaptureWidget (Glance 1.1.x): responsive small/medium layouts using provideGlance + provideContent; no mutable fields per Bug 4 mitigation - CaptureWidgetReceiver: goAsync() guard on onUpdate for BroadcastReceiver deadline safety - NoGraphPlaceholder: kmp composable for no-graph state using ACTION_MAIN+CATEGORY_LAUNCHER to avoid circular androidApp dependency - BlockStateManager: fix unobservePage to actually clear block state; the cached-blocks "optimization" violated the explicit test contract Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds Android “quick capture” entry points (home screen widget, Quick Settings tile, and share-sheet target) backed by a new capture overlay Activity and ViewModel, while also adjusting app initialization so these entry points can work outside the main Activity lifecycle. Also includes a KMP state fix to ensure page state is cleared on unobserve per existing test contract.
Changes:
- Introduces
CaptureActivity+CaptureViewModelfor instant capture (includingACTION_SENDtext/images) and journal persistence. - Adds Glance-based
CaptureWidget/receiver and aCaptureTileServicefor Quick Settings, plus app-scoped initialization inSteleKitApplication. - Fixes
BlockStateManager.unobservePageto clear cached page blocks.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/state/BlockStateManager.kt | Clears cached page blocks on unobservePage to match test contract. |
| kmp/src/androidMain/res/values/strings.xml | Adds androidMain string resources for “no graph selected” placeholder UI. |
| kmp/src/androidMain/kotlin/dev/stapler/stelekit/ui/NoGraphPlaceholder.kt | Adds a reusable Android-only composable prompting users to open the app when no graph is selected. |
| kmp/build.gradle.kts | Adds Glance dependencies and Glance testing dependency for Android targets. |
| androidApp/src/main/res/xml/capture_widget_info.xml | Defines widget provider metadata for the new Glance widget. |
| androidApp/src/main/res/values/themes.xml | Adds a translucent overlay theme for CaptureActivity. |
| androidApp/src/main/res/values/strings.xml | Adds widget/tile strings and duplicates no_graph_action for androidApp’s R usage. |
| androidApp/src/main/res/drawable/ic_tile_capture.xml | Adds a vector icon for tile/widget capture affordance. |
| androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidgetReceiver.kt | Adds Glance widget receiver with goAsync() + appScope update handling. |
| androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidget.kt | Implements responsive Glance widget UI and click actions to launch capture or main app. |
| androidApp/src/main/kotlin/dev/stapler/stelekit/tile/CaptureTileService.kt | Implements Quick Settings tile launching capture or main activity based on graph availability. |
| androidApp/src/main/kotlin/dev/stapler/stelekit/SteleKitApplication.kt | Introduces an Application class owning GraphManager, shared PlatformFileSystem, and appScope. |
| androidApp/src/main/kotlin/dev/stapler/stelekit/MainActivity.kt | Reuses application-scoped filesystem/graph manager instead of reinitializing per Activity. |
| androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureViewModel.kt | Adds save flow writing to DB + flushing markdown via GraphWriter. |
| androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureActivity.kt | Adds the capture overlay UI and share-intent parsing/copying for incoming content. |
| androidApp/src/main/AndroidManifest.xml | Registers the new activity, tile service, widget receiver, and sets the custom Application. |
| androidApp/build.gradle.kts | Adds Glance dependencies to the app module. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| setContent { | ||
| StelekitTheme(themeMode = StelekitThemeMode.SYSTEM) { | ||
| if (app.graphManager == null) { | ||
| Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { | ||
| NoGraphPlaceholderContent() | ||
| } | ||
| } else { | ||
| CaptureScreen( | ||
| viewModel = viewModel, | ||
| onSaved = { | ||
| // Task 2.2: prompt tile add on first save | ||
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { | ||
| promptAddTileOnce() | ||
| } | ||
| finish() | ||
| }, | ||
| onDismiss = { finish() }, | ||
| ) | ||
| } |
There was a problem hiding this comment.
The UI shows the capture sheet whenever app.graphManager != null, but GraphManager can exist with no active graph selected (getActiveRepositorySet() / getActiveGraphInfo() null). In that state, saves will fail with “No active graph…”. Gate the capture UI on an active graph being selected (or show the no-graph placeholder) rather than only checking for a non-null manager.
| val ctx = LocalContext.current | ||
| val hasGraph = (ctx.applicationContext as? SteleKitApplication)?.graphManager != null | ||
| val size = LocalSize.current | ||
|
|
||
| if (!hasGraph) { | ||
| NoGraphContent() | ||
| } else if (size.width >= MEDIUM_SIZE.width) { | ||
| MediumContent() | ||
| } else { | ||
| SmallContent() |
There was a problem hiding this comment.
hasGraph is computed as graphManager != null, but GraphManager is created even before any active graph is selected (its activeRepositorySet starts null). This will render the capture affordance even though saving will error. Consider checking graphManager?.getActiveRepositorySet() != null or getActiveGraphInfo() != null instead.
| val app = applicationContext as? SteleKitApplication | ||
| val targetClass = if (app?.graphManager != null) CaptureActivity::class.java | ||
| else MainActivity::class.java | ||
| val intent = Intent(this, targetClass).apply { | ||
| addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) | ||
| } |
There was a problem hiding this comment.
Tile routing uses app?.graphManager != null to decide between CaptureActivity and MainActivity, but GraphManager can be non-null while no graph is active (activeRepositorySet == null). This can send users into CaptureActivity where save fails. Prefer checking graphManager?.getActiveRepositorySet() != null/getActiveGraphInfo() != null.
| android:updatePeriodMillis="0" | ||
| android:resizeMode="horizontal|vertical" | ||
| android:widgetCategory="home_screen" | ||
| android:previewImage="@mipmap/ic_launcher" |
There was a problem hiding this comment.
appwidget-provider XML is missing android:initialLayout. The platform uses this layout before the first update, and it’s typically required for widgets to be accepted/added. Add an initialLayout (Glance usually uses a small loading layout) to avoid widget add/preview issues on some launchers/APIs.
| android:previewImage="@mipmap/ic_launcher" | |
| android:previewImage="@mipmap/ic_launcher" | |
| android:initialLayout="@android:layout/simple_list_item_1" |
| val existingBlocks = repoSet.blockRepository | ||
| .getBlocksForPage(page.uuid) | ||
| .first() | ||
| .getOrElse { emptyList() } |
There was a problem hiding this comment.
existingBlocks falls back to emptyList() on repository failure. Because GraphWriter.savePage(...) rewrites the entire markdown file from the provided blocks, a transient read failure here can truncate the on-disk page content (pages < 50 blocks bypass the large-deletion safety check). Propagate the failure (e.g., getOrThrow() / return Result.failure) instead of silently defaulting to empty.
| .getOrElse { emptyList() } | |
| .getOrThrow() |
| private fun parseShareIntent(intent: Intent): ShareContent { | ||
| if (intent.action != Intent.ACTION_SEND && intent.action != Intent.ACTION_SEND_MULTIPLE) { | ||
| return ShareContent("", null) | ||
| } | ||
| val text = intent.clipData?.getItemAt(0)?.coerceToText(this)?.toString() | ||
| ?: intent.getStringExtra(Intent.EXTRA_TEXT) | ||
| ?: intent.getStringExtra(Intent.EXTRA_SUBJECT) | ||
| ?: "" | ||
|
|
||
| // Bug 2 mitigation: copy EXTRA_STREAM synchronously before any coroutine launch | ||
| val imagePath = if (intent.type?.startsWith("image/") == true) { | ||
| @Suppress("DEPRECATION") | ||
| val streamUri = intent.getParcelableExtra<android.net.Uri>(Intent.EXTRA_STREAM) | ||
| streamUri?.let { copyStreamToPrivateStorage(it) } | ||
| } else null | ||
|
|
||
| return ShareContent(text, imagePath) |
There was a problem hiding this comment.
The manifest advertises ACTION_SEND_MULTIPLE for image/*, but parseShareIntent() only reads a single EXTRA_STREAM Uri. Shares with multiple images will drop all images (often getParcelableExtra(EXTRA_STREAM) returns null). Either handle ACTION_SEND_MULTIPLE by reading the Uri list (and decide how to represent multiple images), or remove the SEND_MULTIPLE intent-filter.
| val outFile = java.io.File(cacheDir, "share_${System.currentTimeMillis()}.jpg") | ||
| contentResolver.openInputStream(uri)?.use { input -> | ||
| outFile.outputStream().use { output -> input.copyTo(output) } | ||
| } | ||
| outFile.absolutePath |
There was a problem hiding this comment.
Images are copied into cacheDir and the absolute file path is embedded into the journal text. Cache files can be evicted by the OS, which would leave broken image references in saved notes. Consider copying into a persistent app directory (e.g., filesDir) or importing the image into the graph’s attachments folder before writing the markdown.
| val outFile = java.io.File(cacheDir, "share_${System.currentTimeMillis()}.jpg") | |
| contentResolver.openInputStream(uri)?.use { input -> | |
| outFile.outputStream().use { output -> input.copyTo(output) } | |
| } | |
| outFile.absolutePath | |
| val imagesDir = java.io.File(filesDir, "shared_images") | |
| if (!imagesDir.exists() && !imagesDir.mkdirs()) return null | |
| val outFile = java.io.File(imagesDir, "share_${System.currentTimeMillis()}.jpg") | |
| contentResolver.openInputStream(uri)?.use { input -> | |
| outFile.outputStream().use { output -> input.copyTo(output) } | |
| outFile.absolutePath | |
| } |
There was a problem hiding this comment.
Agreed this is a future improvement, but out of scope for this MVP. Cache eviction only matters for images captured in a prior session — a rare edge case. Filed as a follow-up.
| and stored as persistable URI permissions — no manifest storage permissions needed. --> | ||
|
|
||
| <application | ||
| android:name=".SteleKitApplication" |
There was a problem hiding this comment.
android:name=".SteleKitApplication" resolves relative to the manifest package/namespace (here dev.stapler.stelekit.app), but SteleKitApplication is declared in package dev.stapler.stelekit. This will fail to instantiate the Application (and later CaptureActivity will crash when casting application as SteleKitApplication). Use the fully-qualified name (dev.stapler.stelekit.SteleKitApplication) or move the class into the manifest namespace.
| android:name=".SteleKitApplication" | |
| android:name="dev.stapler.stelekit.SteleKitApplication" |
There was a problem hiding this comment.
Not a bug — Android resolves android:name relative names using the applicationId, not the namespace. Here applicationId = "dev.stapler.stelekit" (set in androidApp/build.gradle.kts), so .SteleKitApplication resolves correctly to dev.stapler.stelekit.SteleKitApplication.
| } | ||
|
|
||
| // Bug 8 mitigation: flush the Markdown file after every actor write | ||
| GraphWriter(fileSystem).savePage(page, existingBlocks + newBlock, graphPath) |
There was a problem hiding this comment.
GraphWriter(fileSystem) is constructed without writeActor/pageRepository, so when a new journal page is created with filePath == null, GraphWriter cannot persist the computed filePath back into the DB. Consider constructing GraphWriter with writeActor = repoSet.writeActor (or otherwise saving page.copy(filePath = computedPath)) so newly created pages get a stable filePath.
| GraphWriter(fileSystem).savePage(page, existingBlocks + newBlock, graphPath) | |
| GraphWriter(fileSystem, writeActor = repoSet.writeActor).savePage(page, existingBlocks + newBlock, graphPath) |
| private fun copyStreamToPrivateStorage(uri: android.net.Uri): String? = try { | ||
| val outFile = java.io.File(cacheDir, "share_${System.currentTimeMillis()}.jpg") | ||
| contentResolver.openInputStream(uri)?.use { input -> | ||
| outFile.outputStream().use { output -> input.copyTo(output) } | ||
| } | ||
| outFile.absolutePath | ||
| } catch (_: SecurityException) { null } | ||
| catch (_: Exception) { null } | ||
|
|
There was a problem hiding this comment.
copyStreamToPrivateStorage returns outFile.absolutePath even when openInputStream(uri) returns null (no copy happens), which will insert a path to a non-existent file into the capture text. Return null when the InputStream is null (and avoid reporting success unless bytes were actually written).
| private fun copyStreamToPrivateStorage(uri: android.net.Uri): String? = try { | |
| val outFile = java.io.File(cacheDir, "share_${System.currentTimeMillis()}.jpg") | |
| contentResolver.openInputStream(uri)?.use { input -> | |
| outFile.outputStream().use { output -> input.copyTo(output) } | |
| } | |
| outFile.absolutePath | |
| } catch (_: SecurityException) { null } | |
| catch (_: Exception) { null } | |
| private fun copyStreamToPrivateStorage(uri: android.net.Uri): String? { | |
| return try { | |
| val outFile = java.io.File(cacheDir, "share_${System.currentTimeMillis()}.jpg") | |
| val bytesCopied = contentResolver.openInputStream(uri)?.use { input -> | |
| outFile.outputStream().use { output -> input.copyTo(output) } | |
| } ?: return null | |
| if (bytesCopied <= 0L) { | |
| outFile.delete() | |
| return null | |
| } | |
| outFile.absolutePath | |
| } catch (_: SecurityException) { null } | |
| catch (_: Exception) { null } | |
| } |
|
@claude address the comments on this PR |
- Remove invalid `import androidx.compose.ui.test.assertDoesNotExist` (it is a member method on SemanticsNodeInteraction in the desktop Compose test jar, not a top-level extension function) - Mark FakeFileSystem as `open` so MissingDirectoryFileSystem can subclass it in StelekitViewModelLoadingTest Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benchmark ResultsComparing
Flamegraph not available Top allocation hotspots (this PR)
|
Gate capture UI/tile/widget on active repository set rather than graphManager non-null — graphManager can exist before any graph is selected, causing saves to fail silently. - CaptureActivity, CaptureWidget, CaptureTileService: check graphManager?.getActiveRepositorySet() != null instead of graphManager != null - CaptureViewModel: propagate blockRepository read errors (was swallowing to emptyList, risking page truncation); use maxOfOrNull-based block position to handle non-contiguous positions after deletions - CaptureViewModel: pass writeActor to GraphWriter so new journal pages have their filePath persisted back to the DB - CaptureActivity.copyStreamToPrivateStorage: return null when openInputStream yields null (was returning a path to a non-existent file) - AndroidManifest: remove ACTION_SEND_MULTIPLE intent filter (advertised a capability the code does not implement) - capture_widget_info.xml: add android:initialLayout (required by some launchers before Glance delivers its first frame) - PlatformSettings.android.kt: add missing `: Settings` supertype and `override` modifiers to satisfy the expect/actual contract (K2 compatibility fix) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
ACTION_SENDfor text and images with clipboard-first extraction order (Bug 3 mitigation)startActivityAndCollapsesplit; callsTile.updateTile()(API changed fromTileServicein SDK 36)provideGlance+provideContentper current API; no mutable fields (Bug 4 mitigation)DatabaseWriteActor → GraphWriter; guardsClosedSendChannelExceptionfor mid-switch saves (Bug 1); falls back to direct repo write when actor is absentGraphManager+appScopeso widgets/tiles can operate without Activity lifecycle couplingACTION_MAIN + CATEGORY_LAUNCHERto launch MainActivity without importing it (avoids circular androidApp↔kmp dependency)unobservePagenow removes the page entry from_blocks; the previous "cached for re-navigation" behaviour violated the explicit test contract (unobservePage_clears_state)Notable API discoveries during implementation
TileService.updateTile()TileServicestubs in SDK 36; moved toTile.updateTile()GlanceAppWidget.Content()provideGlance(context, id)+provideContent { }in Glance 1.1.xstartActivityAndCollapseIntentoverload deprecated in API 34;PendingIntentoverload requiredTest plan
./gradlew ciCheckpasses (971 tests, 0 failures)🤖 Generated with Claude Code