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
40 private var newSessionHandled = false
42 override fun onCreate(savedInstanceState: Bundle?) {
43 val splash = installSplashScreen()
44 super.onCreate(savedInstanceState)
46 savedInstanceState?.let {
47 shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
48 openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
49 newSessionHandled = it.getBoolean(KEY_NEW_SESSION_HANDLED, false)
51 val container = (application as OpencodeApp).container
53 if (savedInstanceState == null) maybeRequestNotificationPermission()
54 // Hold the splash until the persisted theme and app-lock settings load, so we never
55 // paint a frame with the defaults (dynamicColor=true / SYSTEM, app lock off) that then
56 // snap to the user's real choice — including a brief unlocked, un-FLAG_SECURE'd frame
57 // before app lock resolves. Mirrors the SettingsScreen's null-gate.
58 var settingsLoaded = false
59 splash.setKeepOnScreenCondition { !settingsLoaded }
61 val theme by remember(container) {
62 container.settingsStore.themeMode
63 .combine(container.settingsStore.dynamicColor, ::Pair)
64 }.collectAsStateWithLifecycle(initialValue = null)
65 val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = null)
66 LaunchedEffect(theme, appLock) { if (theme != null && appLock != null) settingsLoaded = true }
67 // Hide the app's content from screenshots / the recents thumbnail while app lock is
68 // enabled — and while its value is still unknown (null) at cold start, so a protected
69 // launch can't flash into the recents thumbnail before the setting resolves.
70 LaunchedEffect(appLock) {
71 if (appLock != false) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
72 else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
76 if (themePair != null && locked != null) {
77 val (themeMode, dynamicColor) = themePair
78 val dark = when (themeMode) {
79 ThemeMode.SYSTEM -> isSystemInDarkTheme()
80 ThemeMode.LIGHT -> false
81 ThemeMode.DARK -> true
83 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
84 AppLockGate(enabled = locked) {
85 OpencodeAppUi(container = container)
92 override fun onNewIntent(intent: Intent) {
93 super.onNewIntent(intent)
95 shareIntentHandled = false
96 openSessionHandled = false
97 newSessionHandled = false
101 // Track real foreground state so background notifications (permission requests,
102 // completions, errors) still fire for a session the user "has open" but has walked
103 // away from — the chat screen isn't disposed when the phone is locked, so
104 // container.currentSession stays set and can't be used as a foreground signal.
105 override fun onStart() {
107 (application as OpencodeApp).container.setForeground(true)
110 override fun onStop() {
112 (application as OpencodeApp).container.setForeground(false)
115 /** Capture text shared from another app so it can be prefilled into a session draft,
116 * and session ids from notification taps / deep links so we can open them. */
117 private fun handleIntent(intent: Intent?) {
118 val container = (application as OpencodeApp).container
119 val action = intent?.action
120 // "New session" from a launcher shortcut or the quick-settings tile. Guarded so a
121 // process-death restore (which re-delivers the intent) doesn't spawn a second session.
122 if (action == ACTION_NEW_SESSION && !newSessionHandled) {
123 container.requestNewSession()
124 newSessionHandled = true
126 if ((action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE) && !shareIntentHandled) {
127 val type = intent.type.orEmpty()
128 if (action == Intent.ACTION_SEND && type.startsWith("image/")) {
129 // A single shared image → stage as an attachment in the next opened session.
130 val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
132 container.setPendingSharedMedia(listOf(uri.toString()))
133 shareIntentHandled = true
135 } else if (action == Intent.ACTION_SEND_MULTIPLE && type.startsWith("image/")) {
136 val uris = IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
137 ?.take(NetworkConfig.maxAttachments)
139 if (uris.isNotEmpty()) {
140 container.setPendingSharedMedia(uris.map { it.toString() })
141 shareIntentHandled = true
144 val text = intent.getStringExtra(Intent.EXTRA_TEXT)
145 ?.takeIf { it.isNotBlank() }
146 ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
148 container.setPendingShare(text)
149 shareIntentHandled = true
153 // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
154 val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
156 ?.takeIf { it.host == "session" }
158 val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
159 // Validate: session ids are opaque server-generated identifiers. Reject anything
160 // with path separators or other traversal/control characters so a malicious deep
161 // link can't inject path components into the REST URL path.
162 // Guard against re-firing the retained ACTION_VIEW intent after process-death
163 // restore: onCreate calls handleIntent again with the original intent, so without
164 // this flag (mirroring shareIntentHandled) the session would re-open unexpectedly.
165 if (!openSessionHandled) {
166 id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
167 container.requestOpenSession(it)
168 openSessionHandled = true
174 /** Intent extra carrying a session id to open (notifications / deep links / widget). */
175 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
177 /** Action for the "New session" launcher shortcut and quick-settings tile. */
178 const val ACTION_NEW_SESSION = "soy.iko.opencode.action.NEW_SESSION"
180 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
181 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
183 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
184 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
185 private const val KEY_NEW_SESSION_HANDLED = "newSessionHandled"
188 override fun onSaveInstanceState(outState: Bundle) {
189 super.onSaveInstanceState(outState)
190 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
191 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled)
192 outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled)
195 /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
196 private fun maybeRequestNotificationPermission() {
197 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
198 val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
199 PackageManager.PERMISSION_GRANTED
200 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