Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
52 changes: 52 additions & 0 deletions androidApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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.

android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
Expand All @@ -28,5 +29,56 @@
</intent-filter>
</activity>

<!-- Task 1.1 + 1.3: Translucent capture overlay; share target intent-filters added below -->
<activity
android:name="dev.stapler.stelekit.CaptureActivity"
android:exported="true"
android:launchMode="singleTop"
android:excludeFromRecents="true"
android:windowSoftInputMode="stateAlwaysVisible|adjustResize"
android:theme="@style/Theme.SteleKit.Translucent">

<!-- Task 1.3: Share target for text content -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/html" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>

<!-- Task 2.1: Quick Settings Tile (API 24+; PendingIntent overload used on API 34+) -->
<service
android:name="dev.stapler.stelekit.tile.CaptureTileService"
android:exported="true"
android:label="@string/tile_label_capture"
android:icon="@drawable/ic_tile_capture"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>

<!-- Task 3.2: Home screen widget -->
<receiver
android:name="dev.stapler.stelekit.widget.CaptureWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/capture_widget_info" />
</receiver>

</application>
</manifest>
303 changes: 303 additions & 0 deletions androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureActivity.kt
Original file line number Diff line number Diff line change
@@ -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?.getActiveRepositorySet() == 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() },
)
}
Comment on lines +87 to +105
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.
}
}
}

// 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<android.net.Uri>(Intent.EXTRA_STREAM)
streamUri?.let { copyStreamToPrivateStorage(it) }
} else null

return ShareContent(text, imagePath)
Comment on lines +123 to +139
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.
}

private fun copyStreamToPrivateStorage(uri: android.net.Uri): String? = try {
val outFile = java.io.File(cacheDir, "share_${System.currentTimeMillis()}.jpg")
val copied = contentResolver.openInputStream(uri)?.use { input ->
outFile.outputStream().use { output -> input.copyTo(output) }
}
if (copied != null) outFile.absolutePath else null
} catch (_: SecurityException) { null }
catch (_: Exception) { null }

Comment on lines +142 to +150
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.
// 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),
)
}
}
Loading
Loading