1package soy.iko.opencode
4import android.content.Intent
5import android.content.pm.PackageManager
7import android.os.Bundle
8import androidx.activity.ComponentActivity
9import androidx.activity.compose.setContent
10import androidx.activity.enableEdgeToEdge
11import androidx.activity.result.contract.ActivityResultContracts
12import androidx.compose.foundation.isSystemInDarkTheme
13import androidx.compose.runtime.LaunchedEffect
14import androidx.compose.runtime.getValue
15import androidx.compose.runtime.remember
16import kotlinx.coroutines.flow.combine
17import androidx.core.content.ContextCompat
18import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
19import androidx.lifecycle.compose.collectAsStateWithLifecycle
20import soy.iko.opencode.data.repo.ThemeMode
21import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
22import soy.iko.opencode.ui.theme.OpencodeTheme
24class MainActivity : ComponentActivity() {
26 private val requestNotificationPermission = registerForActivityResult(
27 ActivityResultContracts.RequestPermission(),
28 ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
30 private var shareIntentHandled = false
31 private var openSessionHandled = false
33 override fun onCreate(savedInstanceState: Bundle?) {
34 val splash = installSplashScreen()
35 super.onCreate(savedInstanceState)
37 savedInstanceState?.let {
38 shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
39 openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
41 val container = (application as OpencodeApp).container
43 if (savedInstanceState == null) maybeRequestNotificationPermission()
44 // Hold the splash until the persisted theme settings load, so we never paint a
45 // frame with the default (dynamicColor=true / SYSTEM) that then snaps to the
46 // user's real choice — the flash the SettingsScreen's null-gate already avoids.
47 var themeLoaded = false
48 splash.setKeepOnScreenCondition { !themeLoaded }
50 val theme by remember(container) {
51 container.settingsStore.themeMode
52 .combine(container.settingsStore.dynamicColor, ::Pair)
53 }.collectAsStateWithLifecycle(initialValue = null)
54 LaunchedEffect(theme) { if (theme != null) themeLoaded = true }
55 theme?.let { (themeMode, dynamicColor) ->
56 val dark = when (themeMode) {
57 ThemeMode.SYSTEM -> isSystemInDarkTheme()
58 ThemeMode.LIGHT -> false
59 ThemeMode.DARK -> true
61 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
62 OpencodeAppUi(container = container)
68 override fun onNewIntent(intent: Intent) {
69 super.onNewIntent(intent)
71 shareIntentHandled = false
72 openSessionHandled = false
76 /** Capture text shared from another app so it can be prefilled into a session draft,
77 * and session ids from notification taps / deep links so we can open them. */
78 private fun handleIntent(intent: Intent?) {
79 val container = (application as OpencodeApp).container
80 if (intent?.action == Intent.ACTION_SEND && !shareIntentHandled) {
81 val text = intent.getStringExtra(Intent.EXTRA_TEXT)
82 ?.takeIf { it.isNotBlank() }
83 ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
85 container.setPendingShare(text)
86 shareIntentHandled = true
89 // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
90 val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
92 ?.takeIf { it.host == "session" }
94 val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
95 // Validate: session ids are opaque server-generated identifiers. Reject anything
96 // with path separators or other traversal/control characters so a malicious deep
97 // link can't inject path components into the REST URL path.
98 // Guard against re-firing the retained ACTION_VIEW intent after process-death
99 // restore: onCreate calls handleIntent again with the original intent, so without
100 // this flag (mirroring shareIntentHandled) the session would re-open unexpectedly.
101 if (!openSessionHandled) {
102 id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
103 container.requestOpenSession(it)
104 openSessionHandled = true
110 /** Intent extra carrying a session id to open (notifications / deep links). */
111 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
113 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
114 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
116 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
117 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
120 override fun onSaveInstanceState(outState: Bundle) {
121 super.onSaveInstanceState(outState)
122 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
123 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled)
126 /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
127 private fun maybeRequestNotificationPermission() {
128 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
129 val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
130 PackageManager.PERMISSION_GRANTED
131 if (!granted) requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
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