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.flow.combine
27import androidx.core.content.ContextCompat
28import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
29import androidx.fragment.app.FragmentActivity
30import androidx.lifecycle.compose.collectAsStateWithLifecycle
31import soy.iko.opencode.data.repo.SettingsStore
32import soy.iko.opencode.data.repo.ThemeMode
33import soy.iko.opencode.ui.AppLockGate
34import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
35import soy.iko.opencode.ui.theme.OpencodeTheme
37// FragmentActivity (not ComponentActivity) so androidx.biometric BiometricPrompt can attach
38// for the app-lock gate; FragmentActivity extends ComponentActivity, so Compose/edge-to-edge
39// /activity-result APIs are unaffected.
40class MainActivity : FragmentActivity() {
42 private val requestNotificationPermission = registerForActivityResult(
43 ActivityResultContracts.RequestPermission(),
44 ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
46 private var shareIntentHandled = false
47 private var openSessionHandled = false
48 private var newSessionHandled = false
49 private var openFileHandled = false
50 // Set when a deep link was present but failed validation (malformed id/path). Surfaced
51 // as a toast from onResume so the user knows the link was rejected, not silently ignored.
52 private var showInvalidLinkToast = false
53 // Cold-start prompts (crash report, notification rationale) rendered as Compose M3
54 // dialogs. Hoisted as Activity-level mutableState so the non-composable trigger logic
55 // (onCreate/onResume) can flip them and the Compose tree (inside OpencodeTheme) reads
56 // them and renders an M3 AlertDialog that picks up the app palette. The triggers
57 // themselves are idempotent per instance (crashes are acknowledged on first fire;
58 // notificationPermissionRequested is restored from saved state), so a plain
59 // mutableStateOf (not saveable) preserves the original dismissal semantics across
60 // config-change recreation: the prompt simply doesn't re-fire after a rotate.
61 private var showCrashPrompt by mutableStateOf(false)
62 private var showNotifRationale by mutableStateOf(false)
63 // Track whether the notification-permission prompt has been shown for this Activity
64 // instance. onCreate only requests when savedInstanceState == null (a fresh process),
65 // but an Activity recreated by a config change NOT listed in the manifest's configChanges
66 // (e.g. a locale change) has a non-null savedInstanceState and would otherwise skip the
67 // prompt forever. onResume re-requests once per instance when still ungranted so the user
68 // is never silently left without notifications after such a recreation.
69 private var notificationPermissionRequested = false
71 override fun onCreate(savedInstanceState: Bundle?) {
72 val splash = installSplashScreen()
73 super.onCreate(savedInstanceState)
75 savedInstanceState?.let {
76 shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
77 openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
78 newSessionHandled = it.getBoolean(KEY_NEW_SESSION_HANDLED, false)
79 openFileHandled = it.getBoolean(KEY_OPEN_FILE_HANDLED, false)
81 val container = (application as OpencodeApp).container
83 if (savedInstanceState == null) {
84 maybeRequestNotificationPermission()
86 // Restored instance: the permission may have been requested pre-recreation, but
87 // we can't know the result from here. onResume will re-check and request if still
88 // ungranted (once per Activity instance).
89 notificationPermissionRequested = savedInstanceState.getBoolean(KEY_NOTIF_PERM_REQUESTED, false)
91 // Hold the splash until the persisted theme and app-lock settings load, so we never
92 // paint a frame with the defaults (dynamicColor=false / SYSTEM, app lock off) that then
93 // snap to the user's real choice — including a brief unlocked, un-FLAG_SECURE'd frame
94 // before app lock resolves. Mirrors the SettingsScreen's null-gate.
95 var settingsLoaded = false
96 splash.setKeepOnScreenCondition { !settingsLoaded }
97 // Custom splash exit: a quick fade-out instead of the system default's slide, so the
98 // hand-off to Compose reads as a branded dissolve. The listener must call
99 // splashScreenViewProvider.remove() to release the splash view; otherwise the system
100 // keeps it on screen for its default duration. Reduced-motion is respected via the
101 // animator's duration scale (the system zeroes it under reduced motion).
102 splash.setOnExitAnimationListener { splashScreenViewProvider ->
103 val view = splashScreenViewProvider.view
104 val fade = android.view.animation.AlphaAnimation(1f, 0f).apply {
108 fade.setAnimationListener(object : android.view.animation.Animation.AnimationListener {
109 override fun onAnimationStart(animation: android.view.animation.Animation?) {}
110 override fun onAnimationEnd(animation: android.view.animation.Animation?) {
111 splashScreenViewProvider.remove()
113 override fun onAnimationRepeat(animation: android.view.animation.Animation?) {}
115 view.startAnimation(fade)
118 val theme by remember(container) {
119 container.settingsStore.themeMode
120 .combine(container.settingsStore.dynamicColor, ::Pair)
121 }.collectAsStateWithLifecycle(initialValue = null)
122 val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = null)
123 val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds
124 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS)
125 LaunchedEffect(theme, appLock) { if (theme != null && appLock != null) settingsLoaded = true }
126 // Hide the app's content from screenshots / the recents thumbnail while app lock is
127 // enabled — and while its value is still unknown (null) at cold start, so a protected
128 // launch can't flash into the recents thumbnail before the setting resolves.
129 LaunchedEffect(appLock) {
130 if (appLock != false) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
131 else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
133 val themePair = theme
135 if (themePair != null && locked != null) {
136 val (themeMode, dynamicColor) = themePair
137 val amoled = themeMode == ThemeMode.AMOLED
138 val dark = when (themeMode) {
139 ThemeMode.SYSTEM -> isSystemInDarkTheme()
140 ThemeMode.LIGHT -> false
141 ThemeMode.DARK -> true
142 ThemeMode.AMOLED -> true
144 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor, amoled = amoled) {
145 AppLockGate(enabled = locked, reLockDelaySeconds = appLockReLockSeconds) {
146 OpencodeAppUi(container = container)
148 // First-launch prompts as Compose M3 dialogs (siblings of AppLockGate so they
149 // still surface over the lock screen, matching the prior framework-dialog
150 // behavior) and inside OpencodeTheme so they use the app palette.
151 if (showCrashPrompt) {
153 onDismissRequest = { showCrashPrompt = false },
154 title = { Text(stringResource(R.string.crash_last_run_title)) },
155 text = { Text(stringResource(R.string.crash_last_run_text)) },
157 TextButton(onClick = {
158 showCrashPrompt = false
159 container.requestDiagnostics()
160 }) { Text(stringResource(R.string.crash_last_run_view)) }
163 TextButton(onClick = { showCrashPrompt = false }) {
164 Text(stringResource(R.string.crash_last_run_dismiss))
169 if (showNotifRationale) {
171 // Mirrors the original setCancelable(false): not dismissible by
172 // back/outside tap, only by the buttons below.
173 onDismissRequest = { },
174 title = { Text(stringResource(R.string.notif_rationale_title)) },
175 text = { Text(stringResource(R.string.notif_rationale_text)) },
177 TextButton(onClick = {
178 showNotifRationale = false
179 runCatching { requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) }
180 }) { Text(stringResource(R.string.notif_rationale_allow)) }
183 TextButton(onClick = { showNotifRationale = false }) {
184 Text(stringResource(R.string.notif_rationale_skip))
192 // Surface a "crashed last time" prompt if a crash report from a previous run
193 // hasn't been acknowledged. Acknowledges immediately so it doesn't re-fire on
194 // every rotation/resume; the user can always find reports in Settings → Diagnostics.
195 maybeShowCrashPrompt()
198 private fun maybeShowCrashPrompt() {
199 val crashLogger = soy.iko.opencode.data.repo.CrashLogger.get(this)
200 if (!crashLogger.hasUnacknowledgedCrash()) return
201 crashLogger.acknowledgeCrashes()
202 showCrashPrompt = true
205 override fun onNewIntent(intent: Intent) {
206 super.onNewIntent(intent)
208 shareIntentHandled = false
209 openSessionHandled = false
210 newSessionHandled = false
211 openFileHandled = false
215 // Track real foreground state so background notifications (permission requests,
216 // completions, errors) still fire for a session the user "has open" but has walked
217 // away from — the chat screen isn't disposed when the phone is locked, so
218 // container.currentSession stays set and can't be used as a foreground signal.
219 override fun onStart() {
221 (application as OpencodeApp).container.setForeground(true)
224 override fun onStop() {
226 (application as OpencodeApp).container.setForeground(false)
229 override fun onResume() {
231 // Re-check on resume: an Activity recreated by a config change not in configChanges
232 // (e.g. locale) has savedInstanceState != null, so onCreate skipped the request. This
233 // ensures the user is prompted once per Activity instance when still ungranted rather
234 // than silently losing all notifications.
235 maybeRequestNotificationPermission()
236 // Surface a rejected deep link once, so a malformed opencode:// link doesn't appear
237 // to be silently ignored. Cleared after firing so a rotation doesn't re-toast.
238 if (showInvalidLinkToast) {
239 showInvalidLinkToast = false
240 android.widget.Toast.makeText(this, R.string.deep_link_invalid, android.widget.Toast.LENGTH_SHORT).show()
244 /** Capture text shared from another app so it can be prefilled into a session draft,
245 * and session ids from notification taps / deep links so we can open them. */
246 private fun handleIntent(intent: Intent?) {
247 val container = (application as OpencodeApp).container
248 val action = intent?.action
249 // "New session" from a launcher shortcut or the quick-settings tile. Guarded so a
250 // process-death restore (which re-delivers the intent) doesn't spawn a second session.
251 if (action == ACTION_NEW_SESSION && !newSessionHandled) {
252 container.requestNewSession()
253 newSessionHandled = true
255 if ((action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE) && !shareIntentHandled) {
256 val type = intent.type.orEmpty()
257 if (action == Intent.ACTION_SEND && type.startsWith("image/")) {
258 // A single shared image → stage as an attachment in the next opened session.
259 val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
261 container.setPendingSharedMedia(listOf(uri.toString()))
262 shareIntentHandled = true
264 } else if (action == Intent.ACTION_SEND_MULTIPLE && type.startsWith("image/")) {
265 val uris = IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
266 ?.take(NetworkConfig.maxAttachments)
268 if (uris.isNotEmpty()) {
269 container.setPendingSharedMedia(uris.map { it.toString() })
270 shareIntentHandled = true
272 } else if (action == Intent.ACTION_SEND) {
273 // A non-image file (e.g. a PDF or text file shared from another app): stage it
274 // as an attachment too, so the user can send it to the agent. The attachment
275 // pipeline handles non-image mimes with a generic file icon. Falls back to
276 // EXTRA_TEXT when no stream is present (a plain text share).
277 val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
279 container.setPendingSharedMedia(listOf(uri.toString()))
280 shareIntentHandled = true
282 val text = intent.getStringExtra(Intent.EXTRA_TEXT)
283 ?.takeIf { it.isNotBlank() }
284 ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
286 container.setPendingShare(text)
287 shareIntentHandled = true
291 val text = intent.getStringExtra(Intent.EXTRA_TEXT)
292 ?.takeIf { it.isNotBlank() }
293 ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
295 container.setPendingShare(text)
296 shareIntentHandled = true
300 // Deep links under the opencode:// scheme. The manifest exposes several hosts:
301 // session/{id} → open (and switch to) that conversation
302 // file/{path} → open the file viewer at that workspace path
303 // new → start a fresh session
304 // The file path is percent-encoded by the link source; decode it here and validate the
305 // segment to keep path-traversal out of the REST path (mirroring the session guard).
306 val data = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data
309 val seg = data.lastPathSegment
310 if (!openSessionHandled) {
311 seg?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
312 val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
313 ?.takeIf { it.isNotBlank() }
314 container.requestOpenSession(it, profileId)
315 openSessionHandled = true
317 // The link carried a session host but no valid id (or a blank/traversal
318 // attempt). Surface a toast so the user knows the link was rejected,
319 // rather than silently no-op'ing (which reads as "the app did nothing").
320 if (!seg.isNullOrBlank()) showInvalidLinkToast = true
325 // Reconstruct the path from the path segments after "file", preserving internal
326 // slashes. Decoded + validated against traversal/control characters.
327 val rawPath = data.path?.removePrefix("/file/")?.let { android.net.Uri.decode(it) }
328 if (!openFileHandled) {
329 rawPath?.takeIf { it.isNotBlank() && VALID_FILE_PATH.matches(it) && !it.contains("..") }
330 ?.let { container.requestOpenFile(it); openFileHandled = true } ?: run {
331 if (!rawPath.isNullOrBlank()) showInvalidLinkToast = true
336 if (!newSessionHandled) {
337 container.requestNewSession()
338 newSessionHandled = true
342 // Fall back to the EXTRA_SESSION_ID extra (notifications / widget) when no deep link matched.
343 if (!openSessionHandled) {
344 intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
345 val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
346 ?.takeIf { it.isNotBlank() }
347 container.requestOpenSession(it, profileId)
348 openSessionHandled = true
354 /** Intent extra carrying a session id to open (notifications / deep links / widget). */
355 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
357 /** Action for the "New session" launcher shortcut and quick-settings tile. */
358 const val ACTION_NEW_SESSION = "soy.iko.opencode.action.NEW_SESSION"
360 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
361 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
363 /** A file path must be made of path-ish characters and not carry traversal. Used to
364 * sanity-check the file deep link before handing it to the viewer. */
365 private val VALID_FILE_PATH = Regex("[A-Za-z0-9_\\-./ ]+")
367 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
368 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
369 private const val KEY_NEW_SESSION_HANDLED = "newSessionHandled"
370 private const val KEY_OPEN_FILE_HANDLED = "openFileHandled"
371 private const val KEY_NOTIF_PERM_REQUESTED = "notificationPermissionRequested"
374 override fun onSaveInstanceState(outState: Bundle) {
375 super.onSaveInstanceState(outState)
376 val container = (application as OpencodeApp).container
377 // Persist each intent-handled flag as "consumed", not merely "dispatched". The pending
378 // signals live only in the in-memory AppContainer, which is recreated EMPTY on process
379 // death. Persisting a plain "dispatched = true" would, after a kill *before* the UI
380 // consumed the signal, restore the flag as true so the fresh container never receives it —
381 // the deep-linked / new / shared session would silently never open. So AND each flag with
382 // "the container no longer holds the signal": still-pending at save time → persist false so
383 // the re-delivered intent re-dispatches it into the new container on restore; already
384 // consumed → persist true so we don't re-open it unexpectedly on every later restore.
385 val openConsumed = container.pendingOpenSession.value == null
386 val newConsumed = container.pendingNewSession.value == 0
387 val shareConsumed = container.pendingShare.value == null && container.pendingSharedMedia.value.isEmpty()
388 val fileConsumed = container.pendingOpenFile.value == null
389 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled && shareConsumed)
390 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled && openConsumed)
391 outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled && newConsumed)
392 outState.putBoolean(KEY_OPEN_FILE_HANDLED, openFileHandled && fileConsumed)
393 outState.putBoolean(KEY_NOTIF_PERM_REQUESTED, notificationPermissionRequested)
396 /** Ask for POST_NOTIFICATIONS on Android 13+ so run/completion notifications show.
397 * Idempotent per Activity instance via [notificationPermissionRequested].
398 * Shows a rationale dialog first (explaining what the user gains) before the system
399 * permission prompt, so a user who denies understands what they're missing. */
400 private fun maybeRequestNotificationPermission() {
401 if (notificationPermissionRequested) return
402 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
403 val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
404 PackageManager.PERMISSION_GRANTED
406 notificationPermissionRequested = true
407 showNotificationRationaleDialog()
411 private fun showNotificationRationaleDialog() {
412 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