package soy.iko.opencode import android.Manifest import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle import android.view.WindowManager import androidx.core.content.IntentCompat import soy.iko.opencode.data.network.NetworkConfig import soy.iko.opencode.notification.NotificationActionReceiver import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.res.stringResource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import androidx.core.content.ContextCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import soy.iko.opencode.data.repo.SettingsStore import soy.iko.opencode.data.repo.ThemeMode import soy.iko.opencode.ui.AppLockGate import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi import soy.iko.opencode.ui.theme.OpencodeTheme // FragmentActivity (not ComponentActivity) so androidx.biometric BiometricPrompt can attach // for the app-lock gate; FragmentActivity extends ComponentActivity, so Compose/edge-to-edge // /activity-result APIs are unaffected. class MainActivity : FragmentActivity() { private val requestNotificationPermission = registerForActivityResult( ActivityResultContracts.RequestPermission(), ) { /* Result is best-effort; notifications are silently skipped if denied. */ } private var shareIntentHandled = false private var openSessionHandled = false private var newSessionHandled = false private var openFileHandled = false // Set when a deep link was present but failed validation (malformed id/path). Surfaced // as a toast from onResume so the user knows the link was rejected, not silently ignored. private var showInvalidLinkToast = false // Cold-start prompts (crash report, notification rationale) rendered as Compose M3 // dialogs. Hoisted as Activity-level mutableState so the non-composable trigger logic // (onCreate/onResume) can flip them and the Compose tree (inside OpencodeTheme) reads // them and renders an M3 AlertDialog that picks up the app palette. The triggers // themselves are idempotent per instance (crashes are acknowledged on first fire; // notificationPermissionRequested is restored from saved state), so a plain // mutableStateOf (not saveable) preserves the original dismissal semantics across // config-change recreation: the prompt simply doesn't re-fire after a rotate. private var showCrashPrompt by mutableStateOf(false) private var showNotifRationale by mutableStateOf(false) // Track whether the notification-permission prompt has been shown for this Activity // instance. onCreate only requests when savedInstanceState == null (a fresh process), // but an Activity recreated by a config change NOT listed in the manifest's configChanges // (e.g. a locale change) has a non-null savedInstanceState and would otherwise skip the // prompt forever. onResume re-requests once per instance when still ungranted so the user // is never silently left without notifications after such a recreation. private var notificationPermissionRequested = false override fun onCreate(savedInstanceState: Bundle?) { val splash = installSplashScreen() super.onCreate(savedInstanceState) enableEdgeToEdge() savedInstanceState?.let { shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false) openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false) newSessionHandled = it.getBoolean(KEY_NEW_SESSION_HANDLED, false) openFileHandled = it.getBoolean(KEY_OPEN_FILE_HANDLED, false) } val container = (application as OpencodeApp).container handleIntent(intent) if (savedInstanceState == null) { maybeRequestNotificationPermission() } else { // Restored instance: the permission may have been requested pre-recreation, but // we can't know the result from here. onResume will re-check and request if still // ungranted (once per Activity instance). notificationPermissionRequested = savedInstanceState.getBoolean(KEY_NOTIF_PERM_REQUESTED, false) } // Hold the splash until the persisted theme and app-lock settings load, so we never // paint a frame with the defaults (dynamicColor=false / SYSTEM, app lock off) that then // snap to the user's real choice — including a brief unlocked, un-FLAG_SECURE'd frame // before app lock resolves. Mirrors the SettingsScreen's null-gate. var settingsLoaded = false splash.setKeepOnScreenCondition { !settingsLoaded } // Custom splash exit: a quick fade-out instead of the system default's slide, so the // hand-off to Compose reads as a branded dissolve. The listener must call // splashScreenViewProvider.remove() to release the splash view; otherwise the system // keeps it on screen for its default duration. Reduced-motion is respected via the // animator's duration scale (the system zeroes it under reduced motion). splash.setOnExitAnimationListener { splashScreenViewProvider -> val view = splashScreenViewProvider.view val fade = android.view.animation.AlphaAnimation(1f, 0f).apply { duration = 180 fillAfter = true } fade.setAnimationListener(object : android.view.animation.Animation.AnimationListener { override fun onAnimationStart(animation: android.view.animation.Animation?) {} override fun onAnimationEnd(animation: android.view.animation.Animation?) { splashScreenViewProvider.remove() } override fun onAnimationRepeat(animation: android.view.animation.Animation?) {} }) view.startAnimation(fade) } setContent { val theme by remember(container) { container.settingsStore.themeMode .combine(container.settingsStore.dynamicColor, ::Pair) }.collectAsStateWithLifecycle(initialValue = null) val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = null) val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS) LaunchedEffect(theme, appLock) { if (theme != null && appLock != null) settingsLoaded = true } // Hide the app's content from screenshots / the recents thumbnail while app lock is // enabled — and while its value is still unknown (null) at cold start, so a protected // launch can't flash into the recents thumbnail before the setting resolves. LaunchedEffect(appLock) { if (appLock != false) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } val themePair = theme val locked = appLock if (themePair != null && locked != null) { val (themeMode, dynamicColor) = themePair val amoled = themeMode == ThemeMode.AMOLED val dark = when (themeMode) { ThemeMode.SYSTEM -> isSystemInDarkTheme() ThemeMode.LIGHT -> false ThemeMode.DARK -> true ThemeMode.AMOLED -> true } OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor, amoled = amoled) { AppLockGate(enabled = locked, reLockDelaySeconds = appLockReLockSeconds) { OpencodeAppUi(container = container) } // First-launch prompts as Compose M3 dialogs (siblings of AppLockGate so they // still surface over the lock screen, matching the prior framework-dialog // behavior) and inside OpencodeTheme so they use the app palette. if (showCrashPrompt) { AlertDialog( onDismissRequest = { showCrashPrompt = false }, title = { Text(stringResource(R.string.crash_last_run_title)) }, text = { Text(stringResource(R.string.crash_last_run_text)) }, confirmButton = { TextButton(onClick = { showCrashPrompt = false container.requestDiagnostics() }) { Text(stringResource(R.string.crash_last_run_view)) } }, dismissButton = { TextButton(onClick = { showCrashPrompt = false }) { Text(stringResource(R.string.crash_last_run_dismiss)) } }, ) } if (showNotifRationale) { AlertDialog( // Mirrors the original setCancelable(false): not dismissible by // back/outside tap, only by the buttons below. onDismissRequest = { }, title = { Text(stringResource(R.string.notif_rationale_title)) }, text = { Text(stringResource(R.string.notif_rationale_text)) }, confirmButton = { TextButton(onClick = { showNotifRationale = false runCatching { requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) } }) { Text(stringResource(R.string.notif_rationale_allow)) } }, dismissButton = { TextButton(onClick = { showNotifRationale = false }) { Text(stringResource(R.string.notif_rationale_skip)) } }, ) } } } } // Surface a "crashed last time" prompt if a crash report from a previous run // hasn't been acknowledged. Acknowledges immediately so it doesn't re-fire on // every rotation/resume; the user can always find reports in Settings → Diagnostics. maybeShowCrashPrompt() } private fun maybeShowCrashPrompt() { // The check does a directory scan + per-file stat (listFiles + lastModified), which // is synchronous filesystem I/O. Run it off the main thread so the cold-start // critical path (before setContent's first frame) isn't blocked by up to ~20 stat // syscalls on slow storage. The prompt state flips on once the result arrives; the // splash screen has already been dismissed by then, so the dialog appears a frame // or two later than it would have synchronously — invisible to the user. val crashLogger = soy.iko.opencode.data.repo.CrashLogger.get(this) lifecycleScope.launch { val hasUnacknowledged = withContext(Dispatchers.IO) { crashLogger.hasUnacknowledgedCrash() } if (hasUnacknowledged) { crashLogger.acknowledgeCrashes() showCrashPrompt = true } } } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) shareIntentHandled = false openSessionHandled = false newSessionHandled = false openFileHandled = false handleIntent(intent) } // Track real foreground state so background notifications (permission requests, // completions, errors) still fire for a session the user "has open" but has walked // away from — the chat screen isn't disposed when the phone is locked, so // container.currentSession stays set and can't be used as a foreground signal. override fun onStart() { super.onStart() (application as OpencodeApp).container.setForeground(true) } override fun onStop() { super.onStop() (application as OpencodeApp).container.setForeground(false) } override fun onResume() { super.onResume() // Re-check on resume: an Activity recreated by a config change not in configChanges // (e.g. locale) has savedInstanceState != null, so onCreate skipped the request. This // ensures the user is prompted once per Activity instance when still ungranted rather // than silently losing all notifications. maybeRequestNotificationPermission() // Surface a rejected deep link once, so a malformed opencode:// link doesn't appear // to be silently ignored. Cleared after firing so a rotation doesn't re-toast. if (showInvalidLinkToast) { showInvalidLinkToast = false android.widget.Toast.makeText(this, R.string.deep_link_invalid, android.widget.Toast.LENGTH_SHORT).show() } } /** Capture text shared from another app so it can be prefilled into a session draft, * and session ids from notification taps / deep links so we can open them. */ private fun handleIntent(intent: Intent?) { val container = (application as OpencodeApp).container val action = intent?.action // "New session" from a launcher shortcut or the quick-settings tile. Guarded so a // process-death restore (which re-delivers the intent) doesn't spawn a second session. if (action == ACTION_NEW_SESSION && !newSessionHandled) { container.requestNewSession() newSessionHandled = true } if ((action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE) && !shareIntentHandled) { val type = intent.type.orEmpty() if (action == Intent.ACTION_SEND && type.startsWith("image/")) { // A single shared image → stage as an attachment in the next opened session. val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) if (uri != null) { container.setPendingSharedMedia(listOf(uri.toString())) shareIntentHandled = true } } else if (action == Intent.ACTION_SEND_MULTIPLE && type.startsWith("image/")) { val uris = IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) ?.take(NetworkConfig.maxAttachments) .orEmpty() if (uris.isNotEmpty()) { container.setPendingSharedMedia(uris.map { it.toString() }) shareIntentHandled = true } } else if (action == Intent.ACTION_SEND) { // A non-image file (e.g. a PDF or text file shared from another app): stage it // as an attachment too, so the user can send it to the agent. The attachment // pipeline handles non-image mimes with a generic file icon. Falls back to // EXTRA_TEXT when no stream is present (a plain text share). val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) if (uri != null) { container.setPendingSharedMedia(listOf(uri.toString())) shareIntentHandled = true } else { val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?.takeIf { it.isNotBlank() } ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares if (text != null) { container.setPendingShare(text) shareIntentHandled = true } } } else { val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?.takeIf { it.isNotBlank() } ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares if (text != null) { container.setPendingShare(text) shareIntentHandled = true } } } // Deep links under the opencode:// scheme. The manifest exposes several hosts: // session/{id} → open (and switch to) that conversation // file/{path} → open the file viewer at that workspace path // new → start a fresh session // The file path is percent-encoded by the link source; decode it here and validate the // segment to keep path-traversal out of the REST path (mirroring the session guard). val data = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data when (data?.host) { "session" -> { val seg = data.lastPathSegment if (!openSessionHandled) { seg?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let { val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID) ?.takeIf { it.isNotBlank() } container.requestOpenSession(it, profileId) openSessionHandled = true } ?: run { // The link carried a session host but no valid id (or a blank/traversal // attempt). Surface a toast so the user knows the link was rejected, // rather than silently no-op'ing (which reads as "the app did nothing"). if (!seg.isNullOrBlank()) showInvalidLinkToast = true // Mark the link handled even on rejection, so a process-death-and-restore // (which re-delivers the same intent) doesn't re-fire the toast on every // restore — the rejection is final, not a transient state to retry. openSessionHandled = true } } } "file" -> { // Reconstruct the path from the path segments after "file", preserving internal // slashes. Decoded + validated against traversal/control characters. val rawPath = data.path?.removePrefix("/file/")?.let { android.net.Uri.decode(it) } if (!openFileHandled) { rawPath?.takeIf { isValidFilePath(it) } ?.let { container.requestOpenFile(it); openFileHandled = true } ?: run { if (!rawPath.isNullOrBlank()) showInvalidLinkToast = true // Mark handled on rejection too, mirroring the session branch — // otherwise a process-death restore re-fires the invalid-link toast. openFileHandled = true } } } "new" -> { if (!newSessionHandled) { container.requestNewSession() newSessionHandled = true } } } // Fall back to the EXTRA_SESSION_ID extra (notifications / widget) when no deep link // was present at all. A deep link that matched a host but failed validation (e.g. a // malformed session id) does NOT fall through to the extra: the intent's primary // routing signal was the URI, and the extra may carry a different (valid) id that // would silently open the wrong session. Only when data?.host is null (a pure widget // / notification intent with no URI) does the extra carry the routing intent. if (data?.host == null && !openSessionHandled) { intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let { val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID) ?.takeIf { it.isNotBlank() } container.requestOpenSession(it, profileId) openSessionHandled = true } } } companion object { /** Intent extra carrying a session id to open (notifications / deep links / widget). */ const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID" /** Action for the "New session" launcher shortcut and quick-settings tile. */ const val ACTION_NEW_SESSION = "soy.iko.opencode.action.NEW_SESSION" /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */ private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+") /** A file path must not carry traversal segments or control characters. Looser than a * positive allowlist: real workspace paths commonly contain `~`, `:`, `+`, `=`, `,`, * parentheses, and non-ASCII unicode, all of which a tight `[A-Za-z0-9_\\-./ ]+` * regex would reject. The server validates the path against the workspace root, so * here we only reject what's unambiguously hostile (traversal) or non-printable. */ private fun isValidFilePath(path: String): Boolean { if (path.isBlank()) return false if (path.contains("..")) return false if (path.any { it.isISOControl() }) return false return true } private const val KEY_SHARE_HANDLED = "shareIntentHandled" private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled" private const val KEY_NEW_SESSION_HANDLED = "newSessionHandled" private const val KEY_OPEN_FILE_HANDLED = "openFileHandled" private const val KEY_NOTIF_PERM_REQUESTED = "notificationPermissionRequested" } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) val container = (application as OpencodeApp).container // Persist each intent-handled flag as "consumed", not merely "dispatched". The pending // signals live only in the in-memory AppContainer, which is recreated EMPTY on process // death. Persisting a plain "dispatched = true" would, after a kill *before* the UI // consumed the signal, restore the flag as true so the fresh container never receives it — // the deep-linked / new / shared session would silently never open. So AND each flag with // "the container no longer holds the signal": still-pending at save time → persist false so // the re-delivered intent re-dispatches it into the new container on restore; already // consumed → persist true so we don't re-open it unexpectedly on every later restore. val openConsumed = container.pendingOpenSession.value == null val newConsumed = container.pendingNewSession.value == 0 val shareConsumed = container.pendingShare.value == null && container.pendingSharedMedia.value.isEmpty() val fileConsumed = container.pendingOpenFile.value == null outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled && shareConsumed) outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled && openConsumed) outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled && newConsumed) outState.putBoolean(KEY_OPEN_FILE_HANDLED, openFileHandled && fileConsumed) outState.putBoolean(KEY_NOTIF_PERM_REQUESTED, notificationPermissionRequested) } /** Ask for POST_NOTIFICATIONS on Android 13+ so run/completion notifications show. * Idempotent per Activity instance via [notificationPermissionRequested]. * Shows a rationale dialog first (explaining what the user gains) before the system * permission prompt, so a user who denies understands what they're missing. */ private fun maybeRequestNotificationPermission() { if (notificationPermissionRequested) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED if (!granted) { notificationPermissionRequested = true showNotificationRationaleDialog() } } private fun showNotificationRationaleDialog() { showNotifRationale = true } }