Skip to content

feat(android): widget, tile, and share-sheet capture entry points#31

Open
tstapler wants to merge 4 commits intomainfrom
stelekit-share
Open

feat(android): widget, tile, and share-sheet capture entry points#31
tstapler wants to merge 4 commits intomainfrom
stelekit-share

Conversation

@tstapler
Copy link
Copy Markdown
Owner

Summary

  • CaptureActivity — translucent bottom-sheet overlay for instant journal capture, launched from widget, tile, and Android share sheet; handles ACTION_SEND for text and images with clipboard-first extraction order (Bug 3 mitigation)
  • CaptureTileService — Quick Settings tile with API 34+/pre-34 startActivityAndCollapse split; calls Tile.updateTile() (API changed from TileService in SDK 36)
  • CaptureWidget — Glance 1.1.x responsive widget (small icon / medium icon+label); uses provideGlance + provideContent per current API; no mutable fields (Bug 4 mitigation)
  • CaptureViewModel — writes via DatabaseWriteActor → GraphWriter; guards ClosedSendChannelException for mid-switch saves (Bug 1); falls back to direct repo write when actor is absent
  • SteleKitApplication — app-scoped GraphManager + appScope so widgets/tiles can operate without Activity lifecycle coupling
  • NoGraphPlaceholder — kmp composable using ACTION_MAIN + CATEGORY_LAUNCHER to launch MainActivity without importing it (avoids circular androidApp↔kmp dependency)
  • BlockStateManager fixunobservePage now 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

API Finding
TileService.updateTile() Removed from TileService stubs in SDK 36; moved to Tile.updateTile()
GlanceAppWidget.Content() Replaced by provideGlance(context, id) + provideContent { } in Glance 1.1.x
kmp library R class KMP library resources do not merge into consuming app's compile-time R class; strings needed in both modules
startActivityAndCollapse Intent overload deprecated in API 34; PendingIntent overload required

Test plan

  • ./gradlew ciCheck passes (971 tests, 0 failures)
  • Install on physical device: widget appears on home screen, tile in QS panel
  • Widget (small): tap → CaptureActivity opens, type text, Save → block appended to today's journal
  • Widget (medium): same path, icon+label layout visible
  • Tile: tap → CaptureActivity opens; first save after fresh install → tile-add prompt appears
  • Share text from browser → SteleKit appears in share sheet → CaptureActivity pre-fills text
  • Share image → image path prefix inserted in capture field
  • No-graph state: widget shows "Open SteleKit" text; tile routes to MainActivity

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings April 24, 2026 19:46
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + CaptureViewModel for instant capture (including ACTION_SEND text/images) and journal persistence.
  • Adds Glance-based CaptureWidget/receiver and a CaptureTileService for Quick Settings, plus app-scoped initialization in SteleKitApplication.
  • Fixes BlockStateManager.unobservePage to 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.

Comment on lines +87 to +105
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() },
)
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +53
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()
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +38 to +43
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)
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
android:updatePeriodMillis="0"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:previewImage="@mipmap/ic_launcher"
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
android:previewImage="@mipmap/ic_launcher"
android:previewImage="@mipmap/ic_launcher"
android:initialLayout="@android:layout/simple_list_item_1"

Copilot uses AI. Check for mistakes.
val existingBlocks = repoSet.blockRepository
.getBlocksForPage(page.uuid)
.first()
.getOrElse { emptyList() }
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
.getOrElse { emptyList() }
.getOrThrow()

Copilot uses AI. Check for mistakes.
Comment on lines +123 to +139
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)
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +143 to +147
val outFile = java.io.File(cacheDir, "share_${System.currentTimeMillis()}.jpg")
contentResolver.openInputStream(uri)?.use { input ->
outFile.outputStream().use { output -> input.copyTo(output) }
}
outFile.absolutePath
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
android:name=".SteleKitApplication"
android:name="dev.stapler.stelekit.SteleKitApplication"

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
GraphWriter(fileSystem).savePage(page, existingBlocks + newBlock, graphPath)
GraphWriter(fileSystem, writeActor = repoSet.writeActor).savePage(page, existingBlocks + newBlock, graphPath)

Copilot uses AI. Check for mistakes.
Comment on lines +142 to +150
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 }

Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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 }
}

Copilot uses AI. Check for mistakes.
@tstapler
Copy link
Copy Markdown
Owner Author

@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>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 25, 2026

Benchmark Results

Comparing 17b1948 (this PR) vs 283753a (baseline)
Graph config: small — ? pages

Metric This PR Baseline Delta
Phase 1 TTI 9ms 10ms (-1ms)
Phase 2 3ms 3ms (0ms)
Phase 3 index 9ms 12ms (-3ms)
Total 20ms 24ms (-4ms)
Write p95 (baseline) 29ms 34ms -
Write p95 (under load) -1ms -1ms -
Jank factor -0.03x -0.03x -

Flamegraph not available

Top allocation hotspots (this PR)

63.5% byte[][k]
10.4% java.lang.Object[]
[k]
5.2% java.lang.String_[k]
4.7% jdk.internal.org.objectweb.asm.SymbolTable$Entry_[k]
1.6% java.lang.StringBuilder_[k]

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants