From ac937a64c44d6af8ee5b53e6a1b8fcaaa8e8e752 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Fri, 24 Apr 2026 12:36:26 -0700 Subject: [PATCH 1/4] feat(android): add widget, tile, and share-sheet capture entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- androidApp/build.gradle.kts | 2 + androidApp/src/main/AndroidManifest.xml | 57 ++++ .../dev/stapler/stelekit/CaptureActivity.kt | 303 ++++++++++++++++++ .../dev/stapler/stelekit/CaptureViewModel.kt | 111 +++++++ .../dev/stapler/stelekit/MainActivity.kt | 31 +- .../stapler/stelekit/SteleKitApplication.kt | 57 ++++ .../stelekit/tile/CaptureTileService.kt | 57 ++++ .../stapler/stelekit/widget/CaptureWidget.kt | 124 +++++++ .../stelekit/widget/CaptureWidgetReceiver.kt | 33 ++ .../src/main/res/drawable/ic_tile_capture.xml | 10 + androidApp/src/main/res/values/strings.xml | 6 + androidApp/src/main/res/values/themes.xml | 8 + .../src/main/res/xml/capture_widget_info.xml | 11 + kmp/build.gradle.kts | 6 + .../stapler/stelekit/ui/NoGraphPlaceholder.kt | 59 ++++ kmp/src/androidMain/res/values/strings.xml | 7 + .../stelekit/ui/state/BlockStateManager.kt | 2 +- 17 files changed, 866 insertions(+), 18 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureActivity.kt create mode 100644 androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureViewModel.kt create mode 100644 androidApp/src/main/kotlin/dev/stapler/stelekit/SteleKitApplication.kt create mode 100644 androidApp/src/main/kotlin/dev/stapler/stelekit/tile/CaptureTileService.kt create mode 100644 androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidget.kt create mode 100644 androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidgetReceiver.kt create mode 100644 androidApp/src/main/res/drawable/ic_tile_capture.xml create mode 100644 androidApp/src/main/res/xml/capture_widget_info.xml create mode 100644 kmp/src/androidMain/kotlin/dev/stapler/stelekit/ui/NoGraphPlaceholder.kt create mode 100644 kmp/src/androidMain/res/values/strings.xml diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index ead54dc..2eb4d6e 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -66,4 +66,6 @@ dependencies { implementation(platform("androidx.compose:compose-bom:2024.09.02")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.material3:material3") + implementation("androidx.glance:glance-appwidget:1.1.1") + implementation("androidx.glance:glance-material3:1.1.1") } diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 855c8cd..ef4e550 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ and stored as persistable URI permissions — no manifest storage permissions needed. --> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureActivity.kt b/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureActivity.kt new file mode 100644 index 0000000..d6207c4 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureActivity.kt @@ -0,0 +1,303 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 +// https://www.elastic.co/licensing/elastic-license + +package dev.stapler.stelekit + +import android.content.ComponentName +import android.content.Intent +import android.graphics.drawable.Icon +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.annotation.RequiresApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import dev.stapler.stelekit.app.R +import dev.stapler.stelekit.tile.CaptureTileService +import dev.stapler.stelekit.ui.NoGraphPlaceholderContent +import dev.stapler.stelekit.ui.theme.StelekitTheme +import dev.stapler.stelekit.ui.theme.StelekitThemeMode + +/** + * Lightweight translucent overlay for quick note capture. + * Launched from the home screen widget, Quick Settings Tile, and Android share sheet. + * Writes to today's journal page via DatabaseWriteActor + GraphWriter. + */ +class CaptureActivity : ComponentActivity() { + + private val viewModel: CaptureViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val app = application as SteleKitApplication + + // Task 1.3: parse share intent before setContent (EXTRA_STREAM copy is synchronous) + if (savedInstanceState == null) { + val shareContent = parseShareIntent(intent) + if (shareContent.imageLocalPath != null) { + viewModel.initializeText("[image: ${shareContent.imageLocalPath}]\n${shareContent.text}".trim()) + } else { + viewModel.initializeText(shareContent.text) + } + } + + 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() }, + ) + } + } + } + } + + // Task 1.3: re-parse share extras when singleTop brings this Activity to front + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + val shareContent = parseShareIntent(intent) + if (shareContent.imageLocalPath != null) { + viewModel.initializeText("[image: ${shareContent.imageLocalPath}]\n${shareContent.text}".trim()) + } else { + viewModel.initializeText(shareContent.text) + } + } + + // Task 1.3: Bug 3 mitigation — read in mandated null-safe order: clipData → EXTRA_TEXT → EXTRA_SUBJECT + 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(Intent.EXTRA_STREAM) + streamUri?.let { copyStreamToPrivateStorage(it) } + } else null + + return ShareContent(text, imagePath) + } + + 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 } + + // Task 2.2: prompt at most once after first successful save (API 33+) + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun promptAddTileOnce() { + val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + if (prefs.getBoolean(KEY_TILE_PROMPTED, false)) return + prefs.edit().putBoolean(KEY_TILE_PROMPTED, true).apply() + try { + val sbm = getSystemService(android.app.StatusBarManager::class.java) + sbm.requestAddTileService( + ComponentName(this, CaptureTileService::class.java), + getString(R.string.tile_label_capture), + Icon.createWithResource(this, R.drawable.ic_tile_capture), + mainExecutor, + ) { /* result callback — ignored */ } + } catch (_: Exception) { /* OS may reject if tile already added or quota exceeded */ } + } + + private data class ShareContent(val text: String, val imageLocalPath: String?) + + companion object { + private const val PREFS_NAME = "stelekit_capture_prefs" + private const val KEY_TILE_PROMPTED = "pref_tile_prompt_shown" + } +} + +@Composable +private fun CaptureScreen( + viewModel: CaptureViewModel, + onSaved: () -> Unit, + onDismiss: () -> Unit, +) { + val captureText by viewModel.captureText.collectAsState() + val saveState by viewModel.saveState.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(saveState) { + when (val state = saveState) { + is CaptureViewModel.SaveState.Saved -> onSaved() + is CaptureViewModel.SaveState.Error -> { + snackbarHostState.showSnackbar( + "Save failed — ${state.throwable?.message ?: "unknown error"}" + ) + } + else -> {} + } + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + // Auto-save on back if there is unsaved text + BackHandler( + enabled = captureText.isNotBlank() && saveState == CaptureViewModel.SaveState.Idle, + ) { + viewModel.save() + } + + Box(modifier = Modifier.fillMaxSize()) { + // Translucent dim layer — tapping it dismisses (or saves if text is non-empty) + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.4f)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + if (captureText.isBlank()) onDismiss() else viewModel.save() + }, + ) + + // Bottom-anchored capture sheet + Surface( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .imePadding() + // Consume clicks so they don't propagate to the dim layer + .clickable(enabled = false, indication = null, interactionSource = remember { MutableInteractionSource() }) {}, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + // Drag handle + Box( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .size(width = 40.dp, height = 4.dp) + .background( + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + RoundedCornerShape(2.dp), + ), + ) + Spacer(Modifier.height(12.dp)) + + Text( + text = "Today's Journal", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = captureText, + onValueChange = viewModel::updateText, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + placeholder = { Text("Capture a note…") }, + minLines = 3, + maxLines = 8, + ) + Spacer(Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton( + onClick = onDismiss, + enabled = saveState != CaptureViewModel.SaveState.Saving, + ) { Text("Dismiss") } + Spacer(Modifier.width(8.dp)) + Button( + onClick = viewModel::save, + enabled = saveState == CaptureViewModel.SaveState.Idle && captureText.isNotBlank(), + ) { + if (saveState == CaptureViewModel.SaveState.Saving) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text("Save") + } + } + } + Spacer(Modifier.height(8.dp)) + } + } + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } +} diff --git a/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureViewModel.kt b/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureViewModel.kt new file mode 100644 index 0000000..d03b361 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureViewModel.kt @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 +// https://www.elastic.co/licensing/elastic-license + +package dev.stapler.stelekit + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import dev.stapler.stelekit.db.GraphManager +import dev.stapler.stelekit.db.GraphWriter +import dev.stapler.stelekit.model.Block +import dev.stapler.stelekit.platform.PlatformFileSystem +import dev.stapler.stelekit.repository.DirectRepositoryWrite +import dev.stapler.stelekit.util.UuidGenerator +import kotlinx.coroutines.channels.ClosedSendChannelException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.time.Clock + +class CaptureViewModel(app: Application) : AndroidViewModel(app) { + + private val _captureText = MutableStateFlow("") + val captureText: StateFlow = _captureText.asStateFlow() + + private val _saveState = MutableStateFlow(SaveState.Idle) + val saveState: StateFlow = _saveState.asStateFlow() + + sealed class SaveState { + data object Idle : SaveState() + data object Saving : SaveState() + data object Saved : SaveState() + data class Error(val throwable: Throwable?) : SaveState() + } + + fun updateText(text: String) { + _captureText.value = text + } + + /** Sets the initial text only if the field is still empty (idempotent for singleTop re-launch). */ + fun initializeText(text: String) { + if (_captureText.value.isEmpty() && text.isNotEmpty()) { + _captureText.value = text + } + } + + fun save() { + val text = _captureText.value.trim() + if (text.isEmpty()) return + + val steleApp = getApplication() + val graphManager = steleApp.graphManager ?: run { + _saveState.value = SaveState.Error(IllegalStateException("No graph configured")) + return + } + + viewModelScope.launch { + _saveState.value = SaveState.Saving + val result = performSave(graphManager, steleApp.fileSystem, text) + _saveState.value = if (result.isSuccess) SaveState.Saved + else SaveState.Error(result.exceptionOrNull()) + } + } + + private suspend fun performSave( + graphManager: GraphManager, + fileSystem: PlatformFileSystem, + text: String, + ): Result = runCatching { + val repoSet = graphManager.getActiveRepositorySet() + ?: error("No active graph — open SteleKit to set up your graph") + + val page = repoSet.journalService.ensureTodayJournal() + val graphPath = graphManager.getActiveGraphInfo()?.path + ?: error("No active graph path") + + val existingBlocks = repoSet.blockRepository + .getBlocksForPage(page.uuid) + .first() + .getOrElse { emptyList() } + + val now = Clock.System.now() + val newBlock = Block( + uuid = UuidGenerator.generateV7(), + pageUuid = page.uuid, + content = text, + position = existingBlocks.size, + createdAt = now, + updatedAt = now, + ) + + // Bug 1 mitigation: catch ClosedSendChannelException from a graph-switch race + val writeActor = repoSet.writeActor + if (writeActor != null) { + try { + writeActor.saveBlock(newBlock).getOrThrow() + } catch (e: ClosedSendChannelException) { + throw IllegalStateException("Graph switched during save — please retry", e) + } + } else { + @OptIn(DirectRepositoryWrite::class) + repoSet.blockRepository.saveBlock(newBlock).getOrThrow() + } + + // Bug 8 mitigation: flush the Markdown file after every actor write + GraphWriter(fileSystem).savePage(page, existingBlocks + newBlock, graphPath) + } +} diff --git a/androidApp/src/main/kotlin/dev/stapler/stelekit/MainActivity.kt b/androidApp/src/main/kotlin/dev/stapler/stelekit/MainActivity.kt index babf894..d708a9b 100644 --- a/androidApp/src/main/kotlin/dev/stapler/stelekit/MainActivity.kt +++ b/androidApp/src/main/kotlin/dev/stapler/stelekit/MainActivity.kt @@ -12,15 +12,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import dev.stapler.stelekit.db.DriverFactory import dev.stapler.stelekit.domain.UrlFetcherAndroid -import dev.stapler.stelekit.platform.SteleKitContext import dev.stapler.stelekit.platform.PlatformFileSystem +import dev.stapler.stelekit.platform.PlatformSettings import dev.stapler.stelekit.ui.StelekitApp import dev.stapler.stelekit.voice.AndroidAudioRecorder import dev.stapler.stelekit.voice.VoiceSettings import dev.stapler.stelekit.voice.buildVoicePipeline -import dev.stapler.stelekit.platform.PlatformSettings import kotlinx.coroutines.CompletableDeferred class MainActivity : ComponentActivity() { @@ -90,28 +88,27 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() - SteleKitContext.init(this) - DriverFactory.setContext(this) + // Upgrade the Application's shared fileSystem with the folder-picker callback. + // SteleKitApplication already called init(applicationContext, null) — we add the + // picker so the main UI can launch ACTION_OPEN_DOCUMENT_TREE. + val app = application as SteleKitApplication + val fileSystem = app.fileSystem + fileSystem.init(this) { + val deferred = CompletableDeferred() + pendingFolderPick = deferred + val hintUri = fileSystem.getStoredTreeUri() + runOnUiThread { folderPickerLauncher.launch(hintUri) } + deferred.await() + } setContent { - val fileSystem = remember { - PlatformFileSystem().apply { - init(this@MainActivity) { - val deferred = CompletableDeferred() - pendingFolderPick = deferred - // Pre-fill the picker with the last known folder so "Reconnect" UX is smooth - val hintUri = getStoredTreeUri() - runOnUiThread { folderPickerLauncher.launch(hintUri) } - deferred.await() - } - } - } val audioRecorder = remember { AndroidAudioRecorder(this@MainActivity.applicationContext) } val voiceSettings = remember { VoiceSettings(PlatformSettings()) } var voicePipeline by remember { mutableStateOf(buildVoicePipeline(audioRecorder, voiceSettings)) } StelekitApp( fileSystem = fileSystem, graphPath = fileSystem.getDefaultGraphPath(), + graphManager = app.graphManager, urlFetcher = UrlFetcherAndroid(), voicePipeline = voicePipeline, voiceSettings = voiceSettings, diff --git a/androidApp/src/main/kotlin/dev/stapler/stelekit/SteleKitApplication.kt b/androidApp/src/main/kotlin/dev/stapler/stelekit/SteleKitApplication.kt new file mode 100644 index 0000000..8b0008b --- /dev/null +++ b/androidApp/src/main/kotlin/dev/stapler/stelekit/SteleKitApplication.kt @@ -0,0 +1,57 @@ +package dev.stapler.stelekit + +import android.app.Application +import android.util.Log +import dev.stapler.stelekit.db.DriverFactory +import dev.stapler.stelekit.db.GraphManager +import dev.stapler.stelekit.platform.PlatformFileSystem +import dev.stapler.stelekit.platform.PlatformSettings +import dev.stapler.stelekit.platform.SteleKitContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +class SteleKitApplication : Application() { + + /** Process-scoped scope for widget/tile background work (goAsync pattern). */ + val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + /** + * Shared [PlatformFileSystem] for the process. Initialized with [applicationContext] + * (no folder-picker callback). [MainActivity] re-inits this with its picker callback + * so the UI can launch the document tree picker. + */ + lateinit var fileSystem: PlatformFileSystem + private set + + /** + * Process-scoped [GraphManager]. Null if initialization failed (e.g., corrupt keystore). + * All Android components (widget, tile, share target) must null-check before use and + * show a "No graph — open SteleKit" placeholder. + */ + var graphManager: GraphManager? = null + private set + + override fun onCreate() { + super.onCreate() + try { + SteleKitContext.init(this) + DriverFactory.setContext(this) + fileSystem = PlatformFileSystem().apply { init(applicationContext) } + graphManager = GraphManager( + platformSettings = PlatformSettings(), + driverFactory = DriverFactory(), + fileSystem = fileSystem, + ) + } catch (e: Exception) { + Log.e(TAG, "Application init failed — widget/tile/share will show placeholder", e) + if (!::fileSystem.isInitialized) { + fileSystem = PlatformFileSystem() + } + } + } + + companion object { + private const val TAG = "SteleKitApplication" + } +} diff --git a/androidApp/src/main/kotlin/dev/stapler/stelekit/tile/CaptureTileService.kt b/androidApp/src/main/kotlin/dev/stapler/stelekit/tile/CaptureTileService.kt new file mode 100644 index 0000000..a1e6964 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/stapler/stelekit/tile/CaptureTileService.kt @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 +// https://www.elastic.co/licensing/elastic-license + +package dev.stapler.stelekit.tile + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Intent +import android.graphics.drawable.Icon +import android.os.Build +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import androidx.annotation.RequiresApi +import dev.stapler.stelekit.CaptureActivity +import dev.stapler.stelekit.MainActivity +import dev.stapler.stelekit.SteleKitApplication +import dev.stapler.stelekit.app.R + +@RequiresApi(Build.VERSION_CODES.N) +class CaptureTileService : TileService() { + + override fun onStartListening() { + super.onStartListening() + try { + qsTile?.apply { + state = Tile.STATE_ACTIVE + label = getString(R.string.tile_label_capture) + icon = Icon.createWithResource(applicationContext, R.drawable.ic_tile_capture) + updateTile() + } + } catch (_: Exception) { /* tile may not be bound */ } + } + + @SuppressLint("NewApi") // version-guarded inline below + override fun onClick() { + super.onClick() + 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) + } + + // Bug 6 mitigation: use PendingIntent overload on API 34+, Intent overload on older + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val pendingIntent = PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + startActivityAndCollapse(pendingIntent) + } else { + @Suppress("DEPRECATION") + startActivityAndCollapse(intent) + } + } +} diff --git a/androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidget.kt b/androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidget.kt new file mode 100644 index 0000000..cbd5cf1 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidget.kt @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 +// https://www.elastic.co/licensing/elastic-license + +package dev.stapler.stelekit.widget + +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.provideContent +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.text.Text +import dev.stapler.stelekit.CaptureActivity +import dev.stapler.stelekit.MainActivity +import dev.stapler.stelekit.SteleKitApplication +import dev.stapler.stelekit.app.R + +// Bug 4 mitigation: no var fields — all state derived from context at render time +class CaptureWidget : GlanceAppWidget() { + + override val sizeMode = SizeMode.Responsive(setOf(SMALL_SIZE, MEDIUM_SIZE)) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + 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() + } + } + } + + @Composable + private fun SmallContent() { + val context = LocalContext.current + val captureIntent = Intent(context, CaptureActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + Box( + modifier = GlanceModifier + .fillMaxSize() + .clickable(actionStartActivity(captureIntent)) + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + Image( + provider = ImageProvider(R.drawable.ic_tile_capture), + contentDescription = context.getString(R.string.widget_capture_button), + modifier = GlanceModifier.size(32.dp), + ) + } + } + + @Composable + private fun MediumContent() { + val context = LocalContext.current + val captureIntent = Intent(context, CaptureActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + Box( + modifier = GlanceModifier + .fillMaxSize() + .clickable(actionStartActivity(captureIntent)), + contentAlignment = Alignment.Center, + ) { + Row( + modifier = GlanceModifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + provider = ImageProvider(R.drawable.ic_tile_capture), + contentDescription = null, + modifier = GlanceModifier.size(24.dp), + ) + Spacer(modifier = GlanceModifier.width(8.dp)) + Text(text = context.getString(R.string.widget_capture_button)) + } + } + } + + @Composable + private fun NoGraphContent() { + val context = LocalContext.current + val mainIntent = Intent(context, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + Box( + modifier = GlanceModifier + .fillMaxSize() + .clickable(actionStartActivity(mainIntent)) + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + Text(text = context.getString(R.string.no_graph_action)) + } + } + + companion object { + private val SMALL_SIZE = DpSize(40.dp, 40.dp) + private val MEDIUM_SIZE = DpSize(110.dp, 40.dp) + } +} diff --git a/androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidgetReceiver.kt b/androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidgetReceiver.kt new file mode 100644 index 0000000..f75c739 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidgetReceiver.kt @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 +// https://www.elastic.co/licensing/elastic-license + +package dev.stapler.stelekit.widget + +import android.appwidget.AppWidgetManager +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import dev.stapler.stelekit.SteleKitApplication +import kotlinx.coroutines.launch + +class CaptureWidgetReceiver : GlanceAppWidgetReceiver() { + + override val glanceAppWidget: GlanceAppWidget = CaptureWidget() + + // Bug 7 mitigation: use goAsync() + appScope to avoid GlobalScope and process-kill data loss + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + val pendingResult = goAsync() + (context.applicationContext as SteleKitApplication).appScope.launch { + try { + super.onUpdate(context, appWidgetManager, appWidgetIds) + } finally { + pendingResult.finish() + } + } + } +} diff --git a/androidApp/src/main/res/drawable/ic_tile_capture.xml b/androidApp/src/main/res/drawable/ic_tile_capture.xml new file mode 100644 index 0000000..db4ad93 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_tile_capture.xml @@ -0,0 +1,10 @@ + + + + diff --git a/androidApp/src/main/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml index c923000..9adbcf0 100644 --- a/androidApp/src/main/res/values/strings.xml +++ b/androidApp/src/main/res/values/strings.xml @@ -1,4 +1,10 @@ SteleKit + + + Open SteleKit + SteleKit Capture + Capture note + Quick note capture to today\'s journal diff --git a/androidApp/src/main/res/values/themes.xml b/androidApp/src/main/res/values/themes.xml index 7e1674a..4ab8b4a 100644 --- a/androidApp/src/main/res/values/themes.xml +++ b/androidApp/src/main/res/values/themes.xml @@ -1,4 +1,12 @@ diff --git a/androidApp/src/main/res/xml/capture_widget_info.xml b/androidApp/src/main/res/xml/capture_widget_info.xml new file mode 100644 index 0000000..e4a8d2e --- /dev/null +++ b/androidApp/src/main/res/xml/capture_widget_info.xml @@ -0,0 +1,11 @@ + + diff --git a/kmp/build.gradle.kts b/kmp/build.gradle.kts index e2f57f6..64112d4 100644 --- a/kmp/build.gradle.kts +++ b/kmp/build.gradle.kts @@ -175,6 +175,11 @@ kotlin { // Encrypted SharedPreferences for API key storage implementation("androidx.security:security-crypto:1.1.0-alpha06") + + // Jetpack Glance — Compose-based home screen widget API + // Use 1.1.1 (not 1.1.0) to pick up a protobuf security fix. + implementation("androidx.glance:glance-appwidget:1.1.1") + implementation("androidx.glance:glance-material3:1.1.1") } } @@ -187,6 +192,7 @@ kotlin { implementation("androidx.test.ext:junit:1.2.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") implementation("androidx.arch.core:core-testing:2.2.0") + implementation("androidx.glance:glance-appwidget-testing:1.1.1") } } diff --git a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/ui/NoGraphPlaceholder.kt b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/ui/NoGraphPlaceholder.kt new file mode 100644 index 0000000..fa8e89b --- /dev/null +++ b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/ui/NoGraphPlaceholder.kt @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 +// https://www.elastic.co/licensing/elastic-license + +package dev.stapler.stelekit.ui + +import android.content.Intent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.stapler.stelekit.R + +@Composable +fun NoGraphPlaceholderContent(modifier: Modifier = Modifier) { + val context = LocalContext.current + Column( + modifier = modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.no_graph_title), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.no_graph_body), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + // Launch the main app's launcher activity without importing the class directly + // (kmp module cannot depend on androidApp's MainActivity class) + Button(onClick = { + val intent = Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_LAUNCHER) + setPackage(context.packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + context.startActivity(intent) + }) { + Text(text = stringResource(R.string.no_graph_action)) + } + } +} diff --git a/kmp/src/androidMain/res/values/strings.xml b/kmp/src/androidMain/res/values/strings.xml new file mode 100644 index 0000000..604f183 --- /dev/null +++ b/kmp/src/androidMain/res/values/strings.xml @@ -0,0 +1,7 @@ + + + + No graph selected + Open SteleKit to set up your graph + Open SteleKit + diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/state/BlockStateManager.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/state/BlockStateManager.kt index 93a193e..a5e94f3 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/state/BlockStateManager.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/state/BlockStateManager.kt @@ -365,9 +365,9 @@ class BlockStateManager( */ fun unobservePage(pageUuid: String) { observationJobs.remove(pageUuid)?.cancel() - // Clear dirty entries for this page (blocks stay cached so re-navigation is instant) val blockUuids = _blocks.value[pageUuid]?.map { it.uuid } ?: emptyList() blockUuids.forEach { dirtyBlocks.remove(it) } + _blocks.update { it - pageUuid } } /** From c1cad080c05e1d7a6defb2a8b289d558ea279708 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Fri, 24 Apr 2026 21:26:01 -0700 Subject: [PATCH 2/4] fix(test): resolve compile errors from main merge - 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 --- .../kotlin/dev/stapler/stelekit/ui/MigrationReadyLoadingTest.kt | 1 - .../kotlin/dev/stapler/stelekit/ui/fixtures/FakeRepositories.kt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/ui/MigrationReadyLoadingTest.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/ui/MigrationReadyLoadingTest.kt index 5df3de9..97c1179 100644 --- a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/ui/MigrationReadyLoadingTest.kt +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/ui/MigrationReadyLoadingTest.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertDoesNotExist import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/ui/fixtures/FakeRepositories.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/ui/fixtures/FakeRepositories.kt index d0bd12e..aad6db2 100644 --- a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/ui/fixtures/FakeRepositories.kt +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/ui/fixtures/FakeRepositories.kt @@ -37,7 +37,7 @@ class InMemorySettings : Settings { } } -class FakeFileSystem : FileSystem { +open class FakeFileSystem : FileSystem { override fun getDefaultGraphPath(): String = "/tmp/graph" override fun expandTilde(path: String) = path override fun readFile(path: String): String? = "" From 7ac3b72f63062b3edbef2683e428166544af42cd Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Fri, 24 Apr 2026 21:43:44 -0700 Subject: [PATCH 3/4] fix(android): address Copilot review items and PlatformSettings mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- androidApp/src/main/AndroidManifest.xml | 5 ----- .../kotlin/dev/stapler/stelekit/CaptureActivity.kt | 6 +++--- .../kotlin/dev/stapler/stelekit/CaptureViewModel.kt | 11 +++++++---- .../dev/stapler/stelekit/tile/CaptureTileService.kt | 2 +- .../dev/stapler/stelekit/widget/CaptureWidget.kt | 2 +- .../src/main/res/layout/widget_initial_layout.xml | 5 +++++ androidApp/src/main/res/xml/capture_widget_info.xml | 1 + .../stelekit/platform/PlatformSettings.android.kt | 10 +++++----- 8 files changed, 23 insertions(+), 19 deletions(-) create mode 100644 androidApp/src/main/res/layout/widget_initial_layout.xml diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index ef4e550..923ead8 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -54,11 +54,6 @@ - - - - - diff --git a/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureActivity.kt b/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureActivity.kt index d6207c4..9f724f0 100644 --- a/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureActivity.kt +++ b/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureActivity.kt @@ -86,7 +86,7 @@ class CaptureActivity : ComponentActivity() { setContent { StelekitTheme(themeMode = StelekitThemeMode.SYSTEM) { - if (app.graphManager == null) { + if (app.graphManager?.getActiveRepositorySet() == null) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { NoGraphPlaceholderContent() } @@ -141,10 +141,10 @@ class CaptureActivity : ComponentActivity() { private fun copyStreamToPrivateStorage(uri: android.net.Uri): String? = try { val outFile = java.io.File(cacheDir, "share_${System.currentTimeMillis()}.jpg") - contentResolver.openInputStream(uri)?.use { input -> + val copied = contentResolver.openInputStream(uri)?.use { input -> outFile.outputStream().use { output -> input.copyTo(output) } } - outFile.absolutePath + if (copied != null) outFile.absolutePath else null } catch (_: SecurityException) { null } catch (_: Exception) { null } diff --git a/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureViewModel.kt b/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureViewModel.kt index d03b361..4a71a79 100644 --- a/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureViewModel.kt +++ b/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureViewModel.kt @@ -80,14 +80,14 @@ class CaptureViewModel(app: Application) : AndroidViewModel(app) { val existingBlocks = repoSet.blockRepository .getBlocksForPage(page.uuid) .first() - .getOrElse { emptyList() } + .getOrThrow() val now = Clock.System.now() val newBlock = Block( uuid = UuidGenerator.generateV7(), pageUuid = page.uuid, content = text, - position = existingBlocks.size, + position = (existingBlocks.maxOfOrNull { it.position } ?: -1) + 1, createdAt = now, updatedAt = now, ) @@ -105,7 +105,10 @@ class CaptureViewModel(app: Application) : AndroidViewModel(app) { repoSet.blockRepository.saveBlock(newBlock).getOrThrow() } - // Bug 8 mitigation: flush the Markdown file after every actor write - GraphWriter(fileSystem).savePage(page, existingBlocks + newBlock, graphPath) + // Bug 8 mitigation: flush the Markdown file after every actor write. + // Pass writeActor so GraphWriter can persist filePath for newly created journal pages. + val writer = GraphWriter(fileSystem, writeActor = repoSet.writeActor) + writer.startAutoSave(viewModelScope) + writer.savePage(page, existingBlocks + newBlock, graphPath) } } diff --git a/androidApp/src/main/kotlin/dev/stapler/stelekit/tile/CaptureTileService.kt b/androidApp/src/main/kotlin/dev/stapler/stelekit/tile/CaptureTileService.kt index a1e6964..95b275b 100644 --- a/androidApp/src/main/kotlin/dev/stapler/stelekit/tile/CaptureTileService.kt +++ b/androidApp/src/main/kotlin/dev/stapler/stelekit/tile/CaptureTileService.kt @@ -36,7 +36,7 @@ class CaptureTileService : TileService() { override fun onClick() { super.onClick() val app = applicationContext as? SteleKitApplication - val targetClass = if (app?.graphManager != null) CaptureActivity::class.java + val targetClass = if (app?.graphManager?.getActiveRepositorySet() != null) CaptureActivity::class.java else MainActivity::class.java val intent = Intent(this, targetClass).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) diff --git a/androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidget.kt b/androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidget.kt index cbd5cf1..f769f90 100644 --- a/androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidget.kt +++ b/androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidget.kt @@ -42,7 +42,7 @@ class CaptureWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { provideContent { val ctx = LocalContext.current - val hasGraph = (ctx.applicationContext as? SteleKitApplication)?.graphManager != null + val hasGraph = (ctx.applicationContext as? SteleKitApplication)?.graphManager?.getActiveRepositorySet() != null val size = LocalSize.current if (!hasGraph) { diff --git a/androidApp/src/main/res/layout/widget_initial_layout.xml b/androidApp/src/main/res/layout/widget_initial_layout.xml new file mode 100644 index 0000000..8d5d827 --- /dev/null +++ b/androidApp/src/main/res/layout/widget_initial_layout.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/xml/capture_widget_info.xml b/androidApp/src/main/res/xml/capture_widget_info.xml index e4a8d2e..1d77914 100644 --- a/androidApp/src/main/res/xml/capture_widget_info.xml +++ b/androidApp/src/main/res/xml/capture_widget_info.xml @@ -4,6 +4,7 @@ android:minHeight="40dp" android:targetCellWidth="2" android:targetCellHeight="1" + android:initialLayout="@layout/widget_initial_layout" android:updatePeriodMillis="0" android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" diff --git a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/platform/PlatformSettings.android.kt b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/platform/PlatformSettings.android.kt index 88aa319..a72167c 100644 --- a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/platform/PlatformSettings.android.kt +++ b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/platform/PlatformSettings.android.kt @@ -16,7 +16,7 @@ object SteleKitContext { } } -actual class PlatformSettings actual constructor() { +actual class PlatformSettings actual constructor() : Settings { private val prefs: SharedPreferences by lazy { try { val masterKey = MasterKey.Builder(SteleKitContext.context) @@ -36,25 +36,25 @@ actual class PlatformSettings actual constructor() { } } - actual fun getBoolean(key: String, defaultValue: Boolean): Boolean { + actual override fun getBoolean(key: String, defaultValue: Boolean): Boolean { return try { prefs.getBoolean(key, defaultValue) } catch (e: Exception) { defaultValue } } - actual fun putBoolean(key: String, value: Boolean) { + actual override fun putBoolean(key: String, value: Boolean) { try { prefs.edit().putBoolean(key, value).apply() } catch (e: Exception) { } } - actual fun getString(key: String, defaultValue: String): String { + actual override fun getString(key: String, defaultValue: String): String { return try { prefs.getString(key, defaultValue) ?: defaultValue } catch (e: Exception) { defaultValue } } - actual fun putString(key: String, value: String) { + actual override fun putString(key: String, value: String) { try { prefs.edit().putString(key, value).apply() } catch (e: Exception) { } From 810f2ed453ee182c0b19feb14f12b1c040e72327 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Sun, 26 Apr 2026 15:40:09 -0700 Subject: [PATCH 4/4] fix(android): restore MainActivity.kt deleted by bad rerere merge resolution Co-Authored-By: Claude Sonnet 4.6 --- .../dev/stapler/stelekit/MainActivity.kt | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 androidApp/src/main/kotlin/dev/stapler/stelekit/MainActivity.kt diff --git a/androidApp/src/main/kotlin/dev/stapler/stelekit/MainActivity.kt b/androidApp/src/main/kotlin/dev/stapler/stelekit/MainActivity.kt new file mode 100644 index 0000000..57e1685 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/stapler/stelekit/MainActivity.kt @@ -0,0 +1,182 @@ +package dev.stapler.stelekit + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dev.stapler.stelekit.domain.UrlFetcherAndroid +import dev.stapler.stelekit.platform.PlatformFileSystem +import dev.stapler.stelekit.platform.PlatformSettings +import dev.stapler.stelekit.ui.StelekitApp +import android.speech.SpeechRecognizer +import androidx.compose.runtime.LaunchedEffect +import dev.stapler.stelekit.voice.AndroidAudioRecorder +import dev.stapler.stelekit.voice.AndroidSpeechRecognizerProvider +import dev.stapler.stelekit.voice.MlKitLlmFormatterProvider +import dev.stapler.stelekit.voice.VoiceSettings +import dev.stapler.stelekit.voice.buildVoicePipeline +import kotlinx.coroutines.CompletableDeferred + +class MainActivity : ComponentActivity() { + + private var pendingFolderPick: CompletableDeferred? = null + private var pendingMicPermission: CompletableDeferred? = null + + private val micPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + pendingMicPermission?.complete(granted) + pendingMicPermission = null + } + + private val folderPickerLauncher = registerForActivityResult( + ActivityResultContracts.OpenDocumentTree() + ) { treeUri: Uri? -> + Log.d(TAG, "folderPicker: result treeUri=$treeUri") + if (treeUri == null) { + Log.d(TAG, "folderPicker: user cancelled or no URI returned") + pendingFolderPick?.complete(null) + pendingFolderPick = null + return@registerForActivityResult + } + + // Reject cloud provider URIs — only local ExternalStorageProvider is supported in v1 + val authority = treeUri.authority ?: "" + Log.d(TAG, "folderPicker: authority=$authority") + if (authority != EXTERNAL_STORAGE_AUTHORITY) { + Log.w(TAG, "folderPicker: rejected non-local authority '$authority' (expected $EXTERNAL_STORAGE_AUTHORITY)") + pendingFolderPick?.complete(null) + pendingFolderPick = null + return@registerForActivityResult + } + + // 1. Take persistable permission FIRST (before completing the deferred) + try { + contentResolver.takePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + Log.d(TAG, "folderPicker: persistable permission taken for $treeUri") + } catch (e: SecurityException) { + Log.e(TAG, "folderPicker: failed to take persistable permission", e) + pendingFolderPick?.complete(null) + pendingFolderPick = null + return@registerForActivityResult + } + + // 2. Release old grant if switching libraries (avoids exhausting the 128-grant cap on API < 30) + val prefs = getSharedPreferences(PlatformFileSystem.PREFS_NAME, MODE_PRIVATE) + val oldUriStr = prefs.getString(PlatformFileSystem.KEY_SAF_TREE_URI, null) + if (oldUriStr != null && oldUriStr != treeUri.toString()) { + try { + contentResolver.releasePersistableUriPermission( + Uri.parse(oldUriStr), + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + Log.d(TAG, "folderPicker: released old grant for $oldUriStr") + } catch (_: Exception) { /* best effort */ } + } + + // 3. Persist the new URI + prefs.edit().putString(PlatformFileSystem.KEY_SAF_TREE_URI, treeUri.toString()).apply() + Log.d(TAG, "folderPicker: URI persisted to SharedPreferences") + + // 4. Complete the deferred with the saf:// path + val safPath = PlatformFileSystem.toSafRoot(treeUri) + Log.d(TAG, "folderPicker: completing with safPath=$safPath") + pendingFolderPick?.complete(safPath) + pendingFolderPick = null + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Upgrade the Application's shared fileSystem with the folder-picker callback. + // SteleKitApplication already called init(applicationContext, null) — we add the + // picker so the main UI can launch ACTION_OPEN_DOCUMENT_TREE. + val app = application as SteleKitApplication + val fileSystem = app.fileSystem + fileSystem.init(this) { + val deferred = CompletableDeferred() + pendingFolderPick = deferred + val hintUri = fileSystem.getStoredTreeUri() + runOnUiThread { folderPickerLauncher.launch(hintUri) } + deferred.await() + } + + setContent { + val fileSystem = remember { + PlatformFileSystem().apply { + init(this@MainActivity) { + val deferred = CompletableDeferred() + pendingFolderPick = deferred + // Pre-fill the picker with the last known folder so "Reconnect" UX is smooth + val hintUri = getStoredTreeUri() + runOnUiThread { folderPickerLauncher.launch(hintUri) } + deferred.await() + } + } + } + val audioRecorder = remember { AndroidAudioRecorder(this@MainActivity.applicationContext, this@MainActivity::requestMicrophonePermission) } + val voiceSettings = remember { VoiceSettings(PlatformSettings()) } + val deviceSttAvailable = remember { AndroidSpeechRecognizerProvider.isAvailable(this@MainActivity.applicationContext) } + val deviceSttProvider = remember { + if (deviceSttAvailable) AndroidSpeechRecognizerProvider(this@MainActivity.applicationContext) else null + } + val mlKitProvider = remember { MlKitLlmFormatterProvider.create() } + var deviceLlmAvailable by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + deviceLlmAvailable = mlKitProvider?.checkEligible() ?: false + } + fun buildPipeline() = buildVoicePipeline( + audioRecorder, + voiceSettings, + if (deviceSttAvailable && voiceSettings.getUseDeviceStt()) deviceSttProvider else null, + if (deviceLlmAvailable && voiceSettings.getUseDeviceLlm()) mlKitProvider else null, + ) + var voicePipeline by remember { mutableStateOf(buildPipeline()) } + LaunchedEffect(deviceLlmAvailable) { + voicePipeline = buildPipeline() + } + StelekitApp( + fileSystem = fileSystem, + graphPath = fileSystem.getDefaultGraphPath(), + graphManager = app.graphManager, + urlFetcher = UrlFetcherAndroid(), + voicePipeline = voicePipeline, + voiceSettings = voiceSettings, + onRebuildVoicePipeline = { voicePipeline = buildPipeline() }, + deviceSttAvailable = deviceSttAvailable, + deviceLlmAvailable = deviceLlmAvailable, + ) + } + } + + private suspend fun requestMicrophonePermission(): Boolean { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED + ) return true + val deferred = CompletableDeferred() + pendingMicPermission = deferred + runOnUiThread { micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } + return deferred.await() + } + + companion object { + private const val TAG = "MainActivity" + /** Authority for AOSP ExternalStorageProvider — the only provider supported in v1. */ + const val EXTERNAL_STORAGE_AUTHORITY = "com.android.externalstorage.documents" + } +}