1package soy.iko.opencode
4import android.content.Intent
5import android.content.pm.PackageManager
8import android.os.Bundle
9import android.view.WindowManager
10import androidx.core.content.IntentCompat
11import soy.iko.opencode.data.network.NetworkConfig
12import soy.iko.opencode.notification.NotificationActionReceiver
13import androidx.activity.compose.setContent
14import androidx.activity.enableEdgeToEdge
15import androidx.activity.result.contract.ActivityResultContracts
16import androidx.compose.foundation.isSystemInDarkTheme
17import androidx.compose.material3.AlertDialog
18import androidx.compose.material3.Text
19import androidx.compose.material3.TextButton
20import androidx.compose.runtime.LaunchedEffect
21import androidx.compose.runtime.getValue
22import androidx.compose.runtime.mutableStateOf
23import androidx.compose.runtime.remember
24import androidx.compose.runtime.setValue
25import androidx.compose.ui.res.stringResource
26import kotlinx.coroutines.Dispatchers
27import kotlinx.coroutines.flow.combine
28import kotlinx.coroutines.launch
29import kotlinx.coroutines.withContext
30import androidx.core.content.ContextCompat
31import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
32import androidx.fragment.app.FragmentActivity
33import androidx.lifecycle.compose.collectAsStateWithLifecycle
34import androidx.lifecycle.lifecycleScope
35import soy.iko.opencode.data.repo.SettingsStore
36import soy.iko.opencode.data.repo.ThemeMode
37import soy.iko.opencode.ui.AppLockGate
38import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
39import soy.iko.opencode.ui.theme.OpencodeTheme
41// FragmentActivity (not ComponentActivity) so androidx.biometric BiometricPrompt can attach
42// for the app-lock gate; FragmentActivity extends ComponentActivity, so Compose/edge-to-edge
43// /activity-result APIs are unaffected.
44class MainActivity : FragmentActivity() {
46 private val requestNotificationPermission = registerForActivityResult(
47 ActivityResultContracts.RequestPermission(),
48 ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
50 private var shareIntentHandled = false
51 private var openSessionHandled = false
52 private var newSessionHandled = false
53 private var openFileHandled = false
54 // Set when a deep link was present but failed validation (malformed id/path). Surfaced
55 // as a toast from onResume so the user knows the link was rejected, not silently ignored.
56 // On a warm start (onNewIntent while already resumed) the toast is shown immediately from
57 // handleIntent, since onResume is NOT re-called on an already-resumed activity — without
58 // that the toast would stay pending until the next pause/resume cycle (e.g. user
59 // backgrounds and returns), making the rejected link look silently ignored.
60 private var showInvalidLinkToast = false
61 // True between onResume and onPause. Used by handleIntent to decide whether to fire the
62 // invalid-link toast immediately (warm start) or defer it to onResume (cold start, where
63 // a toast from onCreate could flash before the first frame is drawn).
64 private var isResumed = false
65 // Cold-start prompts (crash report, notification rationale) rendered as Compose M3
66 // dialogs. Hoisted as Activity-level mutableState so the non-composable trigger logic
67 // (onCreate/onResume) can flip them and the Compose tree (inside OpencodeTheme) reads
68 // them and renders an M3 AlertDialog that picks up the app palette. The triggers
69 // themselves are idempotent per instance (crashes are acknowledged on first fire;
70 // notificationPermissionRequested is restored from saved state), so a plain
71 // mutableStateOf (not saveable) preserves the original dismissal semantics across
72 // config-change recreation: the prompt simply doesn't re-fire after a rotate.
73 private var showCrashPrompt by mutableStateOf(false)
74 private var showNotifRationale by mutableStateOf(false)
75 // Track whether the notification-permission prompt has been shown for this Activity
76 // instance. onCreate only requests when savedInstanceState == null (a fresh process),
77 // but an Activity recreated by a config change NOT listed in the manifest's configChanges
78 // (e.g. a locale change) has a non-null savedInstanceState and would otherwise skip the
79 // prompt forever. onResume re-requests once per instance when still ungranted so the user
80 // is never silently left without notifications after such a recreation.
81 private var notificationPermissionRequested = false
83 override fun onCreate(savedInstanceState: Bundle?) {
84 val splash = installSplashScreen()
85 super.onCreate(savedInstanceState)
87 savedInstanceState?.let {
88 shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
89 openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
90 newSessionHandled = it.getBoolean(KEY_NEW_SESSION_HANDLED, false)
91 openFileHandled = it.getBoolean(KEY_OPEN_FILE_HANDLED, false)
93 val container = (application as OpencodeApp).container
95 if (savedInstanceState == null) {
96 maybeRequestNotificationPermission()
98 // Restored instance: the permission may have been requested pre-recreation, but
99 // we can't know the result from here. onResume will re-check and request if still
100 // ungranted (once per Activity instance).
101 notificationPermissionRequested = savedInstanceState.getBoolean(KEY_NOTIF_PERM_REQUESTED, false)
103 // Hold the splash until the persisted theme and app-lock settings load, so we never
104 // paint a frame with the defaults (dynamicColor=false / SYSTEM, app lock off) that then
105 // snap to the user's real choice — including a brief unlocked, un-FLAG_SECURE'd frame
106 // before app lock resolves. Mirrors the SettingsScreen's null-gate.
107 var settingsLoaded = false
108 splash.setKeepOnScreenCondition { !settingsLoaded }
109 // Custom splash exit: a quick fade-out instead of the system default's slide, so the
110 // hand-off to Compose reads as a branded dissolve. The listener must call
111 // splashScreenViewProvider.remove() to release the splash view; otherwise the system
112 // keeps it on screen for its default duration. Reduced-motion is respected via the
113 // animator's duration scale (the system zeroes it under reduced motion).
114 splash.setOnExitAnimationListener { splashScreenViewProvider ->
115 val view = splashScreenViewProvider.view
116 val fade = android.view.animation.AlphaAnimation(1f, 0f).apply {
120 fade.setAnimationListener(object : android.view.animation.Animation.AnimationListener {
121 override fun onAnimationStart(animation: android.view.animation.Animation?) {}
122 override fun onAnimationEnd(animation: android.view.animation.Animation?) {
123 splashScreenViewProvider.remove()
125 override fun onAnimationRepeat(animation: android.view.animation.Animation?) {}
127 view.startAnimation(fade)
130 val theme by remember(container) {
131 container.settingsStore.themeMode
132 .combine(container.settingsStore.dynamicColor, ::Pair)
133 }.collectAsStateWithLifecycle(initialValue = null)
134 val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = null)
135 val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds
136 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS)
137 LaunchedEffect(theme, appLock) { if (theme != null && appLock != null) settingsLoaded = true }
138 // Hide the app's content from screenshots / the recents thumbnail while app lock is
139 // enabled — and while its value is still unknown (null) at cold start, so a protected
140 // launch can't flash into the recents thumbnail before the setting resolves.
141 LaunchedEffect(appLock) {
142 if (appLock != false) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
143 else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
145 val themePair = theme
147 if (themePair != null && locked != null) {
148 val (themeMode, dynamicColor) = themePair
149 val amoled = themeMode == ThemeMode.AMOLED
150 val dark = when (themeMode) {
151 ThemeMode.SYSTEM -> isSystemInDarkTheme()
152 ThemeMode.LIGHT -> false
153 ThemeMode.DARK -> true
154 ThemeMode.AMOLED -> true
156 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor, amoled = amoled) {
157 AppLockGate(enabled = locked, reLockDelaySeconds = appLockReLockSeconds) {
158 OpencodeAppUi(container = container)
160 // First-launch prompts as Compose M3 dialogs (siblings of AppLockGate so they
161 // still surface over the lock screen, matching the prior framework-dialog
162 // behavior) and inside OpencodeTheme so they use the app palette.
163 if (showCrashPrompt) {
165 onDismissRequest = { showCrashPrompt = false },
166 title = { Text(stringResource(R.string.crash_last_run_title)) },
167 text = { Text(stringResource(R.string.crash_last_run_text)) },
169 TextButton(onClick = {
170 showCrashPrompt = false
171 container.requestDiagnostics()
172 }) { Text(stringResource(R.string.crash_last_run_view)) }
175 TextButton(onClick = { showCrashPrompt = false }) {
176 Text(stringResource(R.string.crash_last_run_dismiss))
181 if (showNotifRationale) {
183 // Mirrors the original setCancelable(false): not dismissible by
184 // back/outside tap, only by the buttons below.
185 onDismissRequest = { },
186 title = { Text(stringResource(R.string.notif_rationale_title)) },
187 text = { Text(stringResource(R.string.notif_rationale_text)) },
189 TextButton(onClick = {
190 showNotifRationale = false
191 runCatching { requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) }
192 }) { Text(stringResource(R.string.notif_rationale_allow)) }
195 TextButton(onClick = { showNotifRationale = false }) {
196 Text(stringResource(R.string.notif_rationale_skip))
204 // Surface a "crashed last time" prompt if a crash report from a previous run
205 // hasn't been acknowledged. Acknowledges immediately so it doesn't re-fire on
206 // every rotation/resume; the user can always find reports in Settings → Diagnostics.
207 maybeShowCrashPrompt()
210 private fun maybeShowCrashPrompt() {
211 // The check does a directory scan + per-file stat (listFiles + lastModified), which
212 // is synchronous filesystem I/O. Run it off the main thread so the cold-start
213 // critical path (before setContent's first frame) isn't blocked by up to ~20 stat
214 // syscalls on slow storage. The prompt state flips on once the result arrives; the
215 // splash screen has already been dismissed by then, so the dialog appears a frame
216 // or two later than it would have synchronously — invisible to the user.
217 val crashLogger = soy.iko.opencode.data.repo.CrashLogger.get(this)
218 lifecycleScope.launch {
219 val hasUnacknowledged = withContext(Dispatchers.IO) {
220 crashLogger.hasUnacknowledgedCrash()
222 if (hasUnacknowledged) {
223 crashLogger.acknowledgeCrashes()
224 showCrashPrompt = true
229 override fun onNewIntent(intent: Intent) {
230 super.onNewIntent(intent)
232 shareIntentHandled = false
233 openSessionHandled = false
234 newSessionHandled = false
235 openFileHandled = false
239 // Track real foreground state so background notifications (permission requests,
240 // completions, errors) still fire for a session the user "has open" but has walked
241 // away from — the chat screen isn't disposed when the phone is locked, so
242 // container.currentSession stays set and can't be used as a foreground signal.
243 override fun onStart() {
245 (application as OpencodeApp).container.setForeground(true)
248 override fun onStop() {
250 (application as OpencodeApp).container.setForeground(false)
253 override fun onResume() {
256 // Re-check on resume: an Activity recreated by a config change not in configChanges
257 // (e.g. locale) has savedInstanceState != null, so onCreate skipped the request. This
258 // ensures the user is prompted once per Activity instance when still ungranted rather
259 // than silently losing all notifications.
260 maybeRequestNotificationPermission()
261 // Surface a rejected deep link once, so a malformed opencode:// link doesn't appear
262 // to be silently ignored. Cleared after firing so a rotation doesn't re-toast. This
263 // covers the cold-start path (handleIntent ran from onCreate, before the activity was
264 // resumed, so it deferred the toast here). Warm-start rejections fire immediately from
265 // handleIntent and never set this flag.
266 if (showInvalidLinkToast) {
267 showInvalidLinkToast = false
268 showInvalidLinkToastNow()
272 override fun onPause() {
277 /** Fire the invalid-deep-link toast. Extracted so the cold-start (onResume) and warm-start
278 * (handleIntent) paths share a single implementation. */
279 private fun showInvalidLinkToastNow() {
280 android.widget.Toast.makeText(this, R.string.deep_link_invalid, android.widget.Toast.LENGTH_SHORT).show()
283 /** Capture text shared from another app so it can be prefilled into a session draft,
284 * and session ids from notification taps / deep links so we can open them. */
285 private fun handleIntent(intent: Intent?) {
286 val container = (application as OpencodeApp).container
287 val action = intent?.action
288 // "New session" from a launcher shortcut or the quick-settings tile. Guarded so a
289 // process-death restore (which re-delivers the intent) doesn't spawn a second session.
290 if (action == ACTION_NEW_SESSION && !newSessionHandled) {
291 container.requestNewSession()
292 newSessionHandled = true
294 if ((action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE) && !shareIntentHandled) {
295 val type = intent.type.orEmpty()
296 if (action == Intent.ACTION_SEND && type.startsWith("image/")) {
297 // A single shared image → stage as an attachment in the next opened session.
298 val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
300 container.setPendingSharedMedia(listOf(uri.toString()))
301 shareIntentHandled = true
303 } else if (action == Intent.ACTION_SEND_MULTIPLE && type.startsWith("image/")) {
304 val uris = IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
305 ?.take(NetworkConfig.maxAttachments)
307 if (uris.isNotEmpty()) {
308 container.setPendingSharedMedia(uris.map { it.toString() })
309 shareIntentHandled = true
311 } else if (action == Intent.ACTION_SEND) {
312 // A non-image file (e.g. a PDF or text file shared from another app): stage it
313 // as an attachment too, so the user can send it to the agent. The attachment
314 // pipeline handles non-image mimes with a generic file icon. Falls back to
315 // EXTRA_TEXT when no stream is present (a plain text share).
316 val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
318 container.setPendingSharedMedia(listOf(uri.toString()))
319 shareIntentHandled = true
321 val text = intent.getStringExtra(Intent.EXTRA_TEXT)
322 ?.takeIf { it.isNotBlank() }
323 ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
325 container.setPendingShare(text)
326 shareIntentHandled = true
330 val text = intent.getStringExtra(Intent.EXTRA_TEXT)
331 ?.takeIf { it.isNotBlank() }
332 ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
334 container.setPendingShare(text)
335 shareIntentHandled = true
339 // Deep links under the opencode:// scheme. The manifest exposes several hosts:
340 // session/{id} → open (and switch to) that conversation
341 // file/{path} → open the file viewer at that workspace path
342 // new → start a fresh session
343 // The file path is percent-encoded by the link source; decode it here and validate the
344 // segment to keep path-traversal out of the REST path (mirroring the session guard).
345 val data = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data
348 val seg = data.lastPathSegment
349 if (!openSessionHandled) {
350 seg?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
351 val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
352 ?.takeIf { it.isNotBlank() }
353 container.requestOpenSession(it, profileId)
354 openSessionHandled = true
356 // The link carried a session host but no valid id (or a blank/traversal
357 // attempt). Surface a toast so the user knows the link was rejected,
358 // rather than silently no-op'ing (which reads as "the app did nothing").
359 // On a warm start (already resumed) fire immediately; on a cold start
360 // defer to onResume so the toast doesn't race the first frame.
361 if (!seg.isNullOrBlank()) {
362 if (isResumed) showInvalidLinkToastNow() else showInvalidLinkToast = true
364 // Mark the link handled even on rejection, so a process-death-and-restore
365 // (which re-delivers the same intent) doesn't re-fire the toast on every
366 // restore — the rejection is final, not a transient state to retry.
367 openSessionHandled = true
372 // Reconstruct the path from the path segments after "file", preserving internal
373 // slashes. Decoded + validated against traversal/control characters.
374 val rawPath = data.path?.removePrefix("/file/")?.let { android.net.Uri.decode(it) }
375 if (!openFileHandled) {
376 rawPath?.takeIf { isValidFilePath(it) }
377 ?.let { container.requestOpenFile(it); openFileHandled = true } ?: run {
378 if (!rawPath.isNullOrBlank()) {
379 // Same warm-vs-cold logic as the session branch above.
380 if (isResumed) showInvalidLinkToastNow() else showInvalidLinkToast = true
382 // Mark handled on rejection too, mirroring the session branch —
383 // otherwise a process-death restore re-fires the invalid-link toast.
384 openFileHandled = true
389 if (!newSessionHandled) {
390 container.requestNewSession()
391 newSessionHandled = true
395 // Fall back to the EXTRA_SESSION_ID extra (notifications / widget) when no deep link
396 // was present at all. A deep link that matched a host but failed validation (e.g. a
397 // malformed session id) does NOT fall through to the extra: the intent's primary
398 // routing signal was the URI, and the extra may carry a different (valid) id that
399 // would silently open the wrong session. Only when data?.host is null (a pure widget
400 // / notification intent with no URI) does the extra carry the routing intent.
401 if (data?.host == null && !openSessionHandled) {
402 intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
403 val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
404 ?.takeIf { it.isNotBlank() }
405 container.requestOpenSession(it, profileId)
406 openSessionHandled = true
412 /** Intent extra carrying a session id to open (notifications / deep links / widget). */
413 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
415 /** Action for the "New session" launcher shortcut and quick-settings tile. */
416 const val ACTION_NEW_SESSION = "soy.iko.opencode.action.NEW_SESSION"
418 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
419 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
421 /** A file path must not carry traversal segments or control characters. Looser than a
422 * positive allowlist: real workspace paths commonly contain `~`, `:`, `+`, `=`, `,`,
423 * parentheses, and non-ASCII unicode, all of which a tight `[A-Za-z0-9_\\-./ ]+`
424 * regex would reject. The server validates the path against the workspace root, so
425 * here we only reject what's unambiguously hostile (traversal) or non-printable. */
426 private fun isValidFilePath(path: String): Boolean {
427 if (path.isBlank()) return false
428 if (path.contains("..")) return false
429 if (path.any { it.isISOControl() }) return false
433 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
434 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
435 private const val KEY_NEW_SESSION_HANDLED = "newSessionHandled"
436 private const val KEY_OPEN_FILE_HANDLED = "openFileHandled"
437 private const val KEY_NOTIF_PERM_REQUESTED = "notificationPermissionRequested"
440 override fun onSaveInstanceState(outState: Bundle) {
441 super.onSaveInstanceState(outState)
442 val container = (application as OpencodeApp).container
443 // Persist each intent-handled flag as "consumed", not merely "dispatched". The pending
444 // signals live only in the in-memory AppContainer, which is recreated EMPTY on process
445 // death. Persisting a plain "dispatched = true" would, after a kill *before* the UI
446 // consumed the signal, restore the flag as true so the fresh container never receives it —
447 // the deep-linked / new / shared session would silently never open. So AND each flag with
448 // "the container no longer holds the signal": still-pending at save time → persist false so
449 // the re-delivered intent re-dispatches it into the new container on restore; already
450 // consumed → persist true so we don't re-open it unexpectedly on every later restore.
451 val openConsumed = container.pendingOpenSession.value == null
452 val newConsumed = container.pendingNewSession.value == 0
453 val shareConsumed = container.pendingShare.value == null && container.pendingSharedMedia.value.isEmpty()
454 val fileConsumed = container.pendingOpenFile.value == null
455 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled && shareConsumed)
456 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled && openConsumed)
457 outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled && newConsumed)
458 outState.putBoolean(KEY_OPEN_FILE_HANDLED, openFileHandled && fileConsumed)
459 outState.putBoolean(KEY_NOTIF_PERM_REQUESTED, notificationPermissionRequested)
462 /** Ask for POST_NOTIFICATIONS on Android 13+ so run/completion notifications show.
463 * Idempotent per Activity instance via [notificationPermissionRequested].
464 * Shows a rationale dialog first (explaining what the user gains) before the system
465 * permission prompt, so a user who denies understands what they're missing. */
466 private fun maybeRequestNotificationPermission() {
467 if (notificationPermissionRequested) return
468 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
469 val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
470 PackageManager.PERMISSION_GRANTED
472 notificationPermissionRequested = true
473 showNotificationRationaleDialog()
477 private fun showNotificationRationaleDialog() {
478 showNotifRationale = true
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list