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 androidx.activity.compose.setContent
13import androidx.activity.enableEdgeToEdge
14import androidx.activity.result.contract.ActivityResultContracts
15import androidx.compose.foundation.isSystemInDarkTheme
16import androidx.compose.runtime.LaunchedEffect
17import androidx.compose.runtime.getValue
18import androidx.compose.runtime.remember
19import kotlinx.coroutines.flow.combine
20import androidx.core.content.ContextCompat
21import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
22import androidx.fragment.app.FragmentActivity
23import androidx.lifecycle.compose.collectAsStateWithLifecycle
24import soy.iko.opencode.data.repo.ThemeMode
25import soy.iko.opencode.ui.AppLockGate
26import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
27import soy.iko.opencode.ui.theme.OpencodeTheme
29// FragmentActivity (not ComponentActivity) so androidx.biometric BiometricPrompt can attach
30// for the app-lock gate; FragmentActivity extends ComponentActivity, so Compose/edge-to-edge
31// /activity-result APIs are unaffected.
32class MainActivity : FragmentActivity() {
34 private val requestNotificationPermission = registerForActivityResult(
35 ActivityResultContracts.RequestPermission(),
36 ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
38 private var shareIntentHandled = false
39 private var openSessionHandled = false
41 override fun onCreate(savedInstanceState: Bundle?) {
42 val splash = installSplashScreen()
43 super.onCreate(savedInstanceState)
45 savedInstanceState?.let {
46 shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
47 openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
49 val container = (application as OpencodeApp).container
51 if (savedInstanceState == null) maybeRequestNotificationPermission()
52 // Hold the splash until the persisted theme settings load, so we never paint a
53 // frame with the default (dynamicColor=true / SYSTEM) that then snaps to the
54 // user's real choice — the flash the SettingsScreen's null-gate already avoids.
55 var themeLoaded = false
56 splash.setKeepOnScreenCondition { !themeLoaded }
58 val theme by remember(container) {
59 container.settingsStore.themeMode
60 .combine(container.settingsStore.dynamicColor, ::Pair)
61 }.collectAsStateWithLifecycle(initialValue = null)
62 val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = false)
63 LaunchedEffect(theme) { if (theme != null) themeLoaded = true }
64 // Hide the app's content from screenshots / the recents thumbnail while app lock
65 // is enabled, so protected server details don't leak there.
66 LaunchedEffect(appLock) {
67 if (appLock) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
68 else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
70 theme?.let { (themeMode, dynamicColor) ->
71 val dark = when (themeMode) {
72 ThemeMode.SYSTEM -> isSystemInDarkTheme()
73 ThemeMode.LIGHT -> false
74 ThemeMode.DARK -> true
76 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
77 AppLockGate(enabled = appLock) {
78 OpencodeAppUi(container = container)
85 override fun onNewIntent(intent: Intent) {
86 super.onNewIntent(intent)
88 shareIntentHandled = false
89 openSessionHandled = false
93 /** Capture text shared from another app so it can be prefilled into a session draft,
94 * and session ids from notification taps / deep links so we can open them. */
95 private fun handleIntent(intent: Intent?) {
96 val container = (application as OpencodeApp).container
97 val action = intent?.action
98 if ((action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE) && !shareIntentHandled) {
99 val type = intent.type.orEmpty()
100 if (action == Intent.ACTION_SEND && type.startsWith("image/")) {
101 // A single shared image → stage as an attachment in the next opened session.
102 val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
104 container.setPendingSharedMedia(listOf(uri.toString()))
105 shareIntentHandled = true
107 } else if (action == Intent.ACTION_SEND_MULTIPLE && type.startsWith("image/")) {
108 val uris = IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
109 ?.take(NetworkConfig.maxAttachments)
111 if (uris.isNotEmpty()) {
112 container.setPendingSharedMedia(uris.map { it.toString() })
113 shareIntentHandled = true
116 val text = intent.getStringExtra(Intent.EXTRA_TEXT)
117 ?.takeIf { it.isNotBlank() }
118 ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
120 container.setPendingShare(text)
121 shareIntentHandled = true
125 // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
126 val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
128 ?.takeIf { it.host == "session" }
130 val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
131 // Validate: session ids are opaque server-generated identifiers. Reject anything
132 // with path separators or other traversal/control characters so a malicious deep
133 // link can't inject path components into the REST URL path.
134 // Guard against re-firing the retained ACTION_VIEW intent after process-death
135 // restore: onCreate calls handleIntent again with the original intent, so without
136 // this flag (mirroring shareIntentHandled) the session would re-open unexpectedly.
137 if (!openSessionHandled) {
138 id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
139 container.requestOpenSession(it)
140 openSessionHandled = true
146 /** Intent extra carrying a session id to open (notifications / deep links). */
147 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
149 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
150 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
152 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
153 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
156 override fun onSaveInstanceState(outState: Bundle) {
157 super.onSaveInstanceState(outState)
158 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
159 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled)
162 /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
163 private fun maybeRequestNotificationPermission() {
164 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
165 val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
166 PackageManager.PERMISSION_GRANTED
167 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