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 // Cold-start prompts (crash report, notification rationale) rendered as Compose M3
51 // dialogs. Hoisted as Activity-level mutableState so the non-composable trigger logic
52 // (onCreate/onResume) can flip them and the Compose tree (inside OpencodeTheme) reads
53 // them and renders an M3 AlertDialog that picks up the app palette. The triggers
54 // themselves are idempotent per instance (crashes are acknowledged on first fire;
55 // notificationPermissionRequested is restored from saved state), so a plain
56 // mutableStateOf (not saveable) preserves the original dismissal semantics across
57 // config-change recreation: the prompt simply doesn't re-fire after a rotate.
58 private var showCrashPrompt by mutableStateOf(false)
59 private var showNotifRationale by mutableStateOf(false)
60 // Track whether the notification-permission prompt has been shown for this Activity
61 // instance. onCreate only requests when savedInstanceState == null (a fresh process),
62 // but an Activity recreated by a config change NOT listed in the manifest's configChanges
63 // (e.g. a locale change) has a non-null savedInstanceState and would otherwise skip the
64 // prompt forever. onResume re-requests once per instance when still ungranted so the user
65 // is never silently left without notifications after such a recreation.
66 private var notificationPermissionRequested = false
68 override fun onCreate(savedInstanceState: Bundle?) {
69 val splash = installSplashScreen()
70 super.onCreate(savedInstanceState)
72 savedInstanceState?.let {
73 shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
74 openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
75 newSessionHandled = it.getBoolean(KEY_NEW_SESSION_HANDLED, false)
76 openFileHandled = it.getBoolean(KEY_OPEN_FILE_HANDLED, false)
78 val container = (application as OpencodeApp).container
80 if (savedInstanceState == null) {
81 maybeRequestNotificationPermission()
83 // Restored instance: the permission may have been requested pre-recreation, but
84 // we can't know the result from here. onResume will re-check and request if still
85 // ungranted (once per Activity instance).
86 notificationPermissionRequested = savedInstanceState.getBoolean(KEY_NOTIF_PERM_REQUESTED, false)
88 // Hold the splash until the persisted theme and app-lock settings load, so we never
89 // paint a frame with the defaults (dynamicColor=false / SYSTEM, app lock off) that then
90 // snap to the user's real choice — including a brief unlocked, un-FLAG_SECURE'd frame
91 // before app lock resolves. Mirrors the SettingsScreen's null-gate.
92 var settingsLoaded = false
93 splash.setKeepOnScreenCondition { !settingsLoaded }
95 val theme by remember(container) {
96 container.settingsStore.themeMode
97 .combine(container.settingsStore.dynamicColor, ::Pair)
98 }.collectAsStateWithLifecycle(initialValue = null)
99 val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = null)
100 val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds
101 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS)
102 LaunchedEffect(theme, appLock) { if (theme != null && appLock != null) settingsLoaded = true }
103 // Hide the app's content from screenshots / the recents thumbnail while app lock is
104 // enabled — and while its value is still unknown (null) at cold start, so a protected
105 // launch can't flash into the recents thumbnail before the setting resolves.
106 LaunchedEffect(appLock) {
107 if (appLock != false) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
108 else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
110 val themePair = theme
112 if (themePair != null && locked != null) {
113 val (themeMode, dynamicColor) = themePair
114 val amoled = themeMode == ThemeMode.AMOLED
115 val dark = when (themeMode) {
116 ThemeMode.SYSTEM -> isSystemInDarkTheme()
117 ThemeMode.LIGHT -> false
118 ThemeMode.DARK -> true
119 ThemeMode.AMOLED -> true
121 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor, amoled = amoled) {
122 AppLockGate(enabled = locked, reLockDelaySeconds = appLockReLockSeconds) {
123 OpencodeAppUi(container = container)
125 // First-launch prompts as Compose M3 dialogs (siblings of AppLockGate so they
126 // still surface over the lock screen, matching the prior framework-dialog
127 // behavior) and inside OpencodeTheme so they use the app palette.
128 if (showCrashPrompt) {
130 onDismissRequest = { showCrashPrompt = false },
131 title = { Text(stringResource(R.string.crash_last_run_title)) },
132 text = { Text(stringResource(R.string.crash_last_run_text)) },
134 TextButton(onClick = {
135 showCrashPrompt = false
136 container.requestDiagnostics()
137 }) { Text(stringResource(R.string.crash_last_run_view)) }
140 TextButton(onClick = { showCrashPrompt = false }) {
141 Text(stringResource(R.string.crash_last_run_dismiss))
146 if (showNotifRationale) {
148 // Mirrors the original setCancelable(false): not dismissible by
149 // back/outside tap, only by the buttons below.
150 onDismissRequest = { },
151 title = { Text(stringResource(R.string.notif_rationale_title)) },
152 text = { Text(stringResource(R.string.notif_rationale_text)) },
154 TextButton(onClick = {
155 showNotifRationale = false
156 runCatching { requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) }
157 }) { Text(stringResource(R.string.notif_rationale_allow)) }
160 TextButton(onClick = { showNotifRationale = false }) {
161 Text(stringResource(R.string.notif_rationale_skip))
169 // Surface a "crashed last time" prompt if a crash report from a previous run
170 // hasn't been acknowledged. Acknowledges immediately so it doesn't re-fire on
171 // every rotation/resume; the user can always find reports in Settings → Diagnostics.
172 maybeShowCrashPrompt()
175 private fun maybeShowCrashPrompt() {
176 val crashLogger = soy.iko.opencode.data.repo.CrashLogger.get(this)
177 if (!crashLogger.hasUnacknowledgedCrash()) return
178 crashLogger.acknowledgeCrashes()
179 showCrashPrompt = true
182 override fun onNewIntent(intent: Intent) {
183 super.onNewIntent(intent)
185 shareIntentHandled = false
186 openSessionHandled = false
187 newSessionHandled = false
188 openFileHandled = false
192 // Track real foreground state so background notifications (permission requests,
193 // completions, errors) still fire for a session the user "has open" but has walked
194 // away from — the chat screen isn't disposed when the phone is locked, so
195 // container.currentSession stays set and can't be used as a foreground signal.
196 override fun onStart() {
198 (application as OpencodeApp).container.setForeground(true)
201 override fun onStop() {
203 (application as OpencodeApp).container.setForeground(false)
206 override fun onResume() {
208 // Re-check on resume: an Activity recreated by a config change not in configChanges
209 // (e.g. locale) has savedInstanceState != null, so onCreate skipped the request. This
210 // ensures the user is prompted once per Activity instance when still ungranted rather
211 // than silently losing all notifications.
212 maybeRequestNotificationPermission()
215 /** Capture text shared from another app so it can be prefilled into a session draft,
216 * and session ids from notification taps / deep links so we can open them. */
217 private fun handleIntent(intent: Intent?) {
218 val container = (application as OpencodeApp).container
219 val action = intent?.action
220 // "New session" from a launcher shortcut or the quick-settings tile. Guarded so a
221 // process-death restore (which re-delivers the intent) doesn't spawn a second session.
222 if (action == ACTION_NEW_SESSION && !newSessionHandled) {
223 container.requestNewSession()
224 newSessionHandled = true
226 if ((action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE) && !shareIntentHandled) {
227 val type = intent.type.orEmpty()
228 if (action == Intent.ACTION_SEND && type.startsWith("image/")) {
229 // A single shared image → stage as an attachment in the next opened session.
230 val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
232 container.setPendingSharedMedia(listOf(uri.toString()))
233 shareIntentHandled = true
235 } else if (action == Intent.ACTION_SEND_MULTIPLE && type.startsWith("image/")) {
236 val uris = IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
237 ?.take(NetworkConfig.maxAttachments)
239 if (uris.isNotEmpty()) {
240 container.setPendingSharedMedia(uris.map { it.toString() })
241 shareIntentHandled = true
244 val text = intent.getStringExtra(Intent.EXTRA_TEXT)
245 ?.takeIf { it.isNotBlank() }
246 ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
248 container.setPendingShare(text)
249 shareIntentHandled = true
253 // Deep links under the opencode:// scheme. The manifest exposes several hosts:
254 // session/{id} → open (and switch to) that conversation
255 // file/{path} → open the file viewer at that workspace path
256 // new → start a fresh session
257 // The file path is percent-encoded by the link source; decode it here and validate the
258 // segment to keep path-traversal out of the REST path (mirroring the session guard).
259 val data = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data
262 val seg = data.lastPathSegment
263 if (!openSessionHandled) {
264 seg?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
265 val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
266 ?.takeIf { it.isNotBlank() }
267 container.requestOpenSession(it, profileId)
268 openSessionHandled = true
273 // Reconstruct the path from the path segments after "file", preserving internal
274 // slashes. Decoded + validated against traversal/control characters.
275 val rawPath = data.path?.removePrefix("/file/")?.let { android.net.Uri.decode(it) }
276 if (!openFileHandled) {
277 rawPath?.takeIf { it.isNotBlank() && VALID_FILE_PATH.containsMatchIn(it) && !it.contains("..") }
278 ?.let { container.requestOpenFile(it); openFileHandled = true }
282 if (!newSessionHandled) {
283 container.requestNewSession()
284 newSessionHandled = true
288 // Fall back to the EXTRA_SESSION_ID extra (notifications / widget) when no deep link matched.
289 if (!openSessionHandled) {
290 intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
291 val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
292 ?.takeIf { it.isNotBlank() }
293 container.requestOpenSession(it, profileId)
294 openSessionHandled = true
300 /** Intent extra carrying a session id to open (notifications / deep links / widget). */
301 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
303 /** Action for the "New session" launcher shortcut and quick-settings tile. */
304 const val ACTION_NEW_SESSION = "soy.iko.opencode.action.NEW_SESSION"
306 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
307 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
309 /** A file path must be made of path-ish characters and not carry traversal. Used to
310 * sanity-check the file deep link before handing it to the viewer. */
311 private val VALID_FILE_PATH = Regex("[A-Za-z0-9_\\-./ ]+")
313 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
314 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
315 private const val KEY_NEW_SESSION_HANDLED = "newSessionHandled"
316 private const val KEY_OPEN_FILE_HANDLED = "openFileHandled"
317 private const val KEY_NOTIF_PERM_REQUESTED = "notificationPermissionRequested"
320 override fun onSaveInstanceState(outState: Bundle) {
321 super.onSaveInstanceState(outState)
322 val container = (application as OpencodeApp).container
323 // Persist each intent-handled flag as "consumed", not merely "dispatched". The pending
324 // signals live only in the in-memory AppContainer, which is recreated EMPTY on process
325 // death. Persisting a plain "dispatched = true" would, after a kill *before* the UI
326 // consumed the signal, restore the flag as true so the fresh container never receives it —
327 // the deep-linked / new / shared session would silently never open. So AND each flag with
328 // "the container no longer holds the signal": still-pending at save time → persist false so
329 // the re-delivered intent re-dispatches it into the new container on restore; already
330 // consumed → persist true so we don't re-open it unexpectedly on every later restore.
331 val openConsumed = container.pendingOpenSession.value == null
332 val newConsumed = container.pendingNewSession.value == 0
333 val shareConsumed = container.pendingShare.value == null && container.pendingSharedMedia.value.isEmpty()
334 val fileConsumed = container.pendingOpenFile.value == null
335 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled && shareConsumed)
336 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled && openConsumed)
337 outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled && newConsumed)
338 outState.putBoolean(KEY_OPEN_FILE_HANDLED, openFileHandled && fileConsumed)
339 outState.putBoolean(KEY_NOTIF_PERM_REQUESTED, notificationPermissionRequested)
342 /** Ask for POST_NOTIFICATIONS on Android 13+ so run/completion notifications show.
343 * Idempotent per Activity instance via [notificationPermissionRequested].
344 * Shows a rationale dialog first (explaining what the user gains) before the system
345 * permission prompt, so a user who denies understands what they're missing. */
346 private fun maybeRequestNotificationPermission() {
347 if (notificationPermissionRequested) return
348 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
349 val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
350 PackageManager.PERMISSION_GRANTED
352 notificationPermissionRequested = true
353 showNotificationRationaleDialog()
357 private fun showNotificationRationaleDialog() {
358 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