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.runtime.LaunchedEffect
18import androidx.compose.runtime.getValue
19import androidx.compose.runtime.remember
20import kotlinx.coroutines.flow.combine
21import androidx.core.content.ContextCompat
22import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
23import androidx.fragment.app.FragmentActivity
24import androidx.lifecycle.compose.collectAsStateWithLifecycle
25import soy.iko.opencode.data.repo.ThemeMode
26import soy.iko.opencode.ui.AppLockGate
27import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
28import soy.iko.opencode.ui.theme.OpencodeTheme
30// FragmentActivity (not ComponentActivity) so androidx.biometric BiometricPrompt can attach
31// for the app-lock gate; FragmentActivity extends ComponentActivity, so Compose/edge-to-edge
32// /activity-result APIs are unaffected.
33class MainActivity : FragmentActivity() {
35 private val requestNotificationPermission = registerForActivityResult(
36 ActivityResultContracts.RequestPermission(),
37 ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
39 private var shareIntentHandled = false
40 private var openSessionHandled = false
41 private var newSessionHandled = false
42 // Track whether the notification-permission prompt has been shown for this Activity
43 // instance. onCreate only requests when savedInstanceState == null (a fresh process),
44 // but an Activity recreated by a config change NOT listed in the manifest's configChanges
45 // (e.g. a locale change) has a non-null savedInstanceState and would otherwise skip the
46 // prompt forever. onResume re-requests once per instance when still ungranted so the user
47 // is never silently left without notifications after such a recreation.
48 private var notificationPermissionRequested = false
50 override fun onCreate(savedInstanceState: Bundle?) {
51 val splash = installSplashScreen()
52 super.onCreate(savedInstanceState)
54 savedInstanceState?.let {
55 shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
56 openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
57 newSessionHandled = it.getBoolean(KEY_NEW_SESSION_HANDLED, false)
59 val container = (application as OpencodeApp).container
61 if (savedInstanceState == null) {
62 maybeRequestNotificationPermission()
64 // Restored instance: the permission may have been requested pre-recreation, but
65 // we can't know the result from here. onResume will re-check and request if still
66 // ungranted (once per Activity instance).
67 notificationPermissionRequested = savedInstanceState.getBoolean(KEY_NOTIF_PERM_REQUESTED, false)
69 // Hold the splash until the persisted theme and app-lock settings load, so we never
70 // paint a frame with the defaults (dynamicColor=false / SYSTEM, app lock off) that then
71 // snap to the user's real choice — including a brief unlocked, un-FLAG_SECURE'd frame
72 // before app lock resolves. Mirrors the SettingsScreen's null-gate.
73 var settingsLoaded = false
74 splash.setKeepOnScreenCondition { !settingsLoaded }
76 val theme by remember(container) {
77 container.settingsStore.themeMode
78 .combine(container.settingsStore.dynamicColor, ::Pair)
79 }.collectAsStateWithLifecycle(initialValue = null)
80 val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = null)
81 LaunchedEffect(theme, appLock) { if (theme != null && appLock != null) settingsLoaded = true }
82 // Hide the app's content from screenshots / the recents thumbnail while app lock is
83 // enabled — and while its value is still unknown (null) at cold start, so a protected
84 // launch can't flash into the recents thumbnail before the setting resolves.
85 LaunchedEffect(appLock) {
86 if (appLock != false) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
87 else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
91 if (themePair != null && locked != null) {
92 val (themeMode, dynamicColor) = themePair
93 val dark = when (themeMode) {
94 ThemeMode.SYSTEM -> isSystemInDarkTheme()
95 ThemeMode.LIGHT -> false
96 ThemeMode.DARK -> true
98 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
99 AppLockGate(enabled = locked) {
100 OpencodeAppUi(container = container)
107 override fun onNewIntent(intent: Intent) {
108 super.onNewIntent(intent)
110 shareIntentHandled = false
111 openSessionHandled = false
112 newSessionHandled = false
116 // Track real foreground state so background notifications (permission requests,
117 // completions, errors) still fire for a session the user "has open" but has walked
118 // away from — the chat screen isn't disposed when the phone is locked, so
119 // container.currentSession stays set and can't be used as a foreground signal.
120 override fun onStart() {
122 (application as OpencodeApp).container.setForeground(true)
125 override fun onStop() {
127 (application as OpencodeApp).container.setForeground(false)
130 override fun onResume() {
132 // Re-check on resume: an Activity recreated by a config change not in configChanges
133 // (e.g. locale) has savedInstanceState != null, so onCreate skipped the request. This
134 // ensures the user is prompted once per Activity instance when still ungranted rather
135 // than silently losing all notifications.
136 maybeRequestNotificationPermission()
139 /** Capture text shared from another app so it can be prefilled into a session draft,
140 * and session ids from notification taps / deep links so we can open them. */
141 private fun handleIntent(intent: Intent?) {
142 val container = (application as OpencodeApp).container
143 val action = intent?.action
144 // "New session" from a launcher shortcut or the quick-settings tile. Guarded so a
145 // process-death restore (which re-delivers the intent) doesn't spawn a second session.
146 if (action == ACTION_NEW_SESSION && !newSessionHandled) {
147 container.requestNewSession()
148 newSessionHandled = true
150 if ((action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE) && !shareIntentHandled) {
151 val type = intent.type.orEmpty()
152 if (action == Intent.ACTION_SEND && type.startsWith("image/")) {
153 // A single shared image → stage as an attachment in the next opened session.
154 val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
156 container.setPendingSharedMedia(listOf(uri.toString()))
157 shareIntentHandled = true
159 } else if (action == Intent.ACTION_SEND_MULTIPLE && type.startsWith("image/")) {
160 val uris = IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
161 ?.take(NetworkConfig.maxAttachments)
163 if (uris.isNotEmpty()) {
164 container.setPendingSharedMedia(uris.map { it.toString() })
165 shareIntentHandled = true
168 val text = intent.getStringExtra(Intent.EXTRA_TEXT)
169 ?.takeIf { it.isNotBlank() }
170 ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
172 container.setPendingShare(text)
173 shareIntentHandled = true
177 // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
178 val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
180 ?.takeIf { it.host == "session" }
182 val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
183 // Validate: session ids are opaque server-generated identifiers. Reject anything
184 // with path separators or other traversal/control characters so a malicious deep
185 // link can't inject path components into the REST URL path.
186 // Guard against re-firing the retained ACTION_VIEW intent after process-death
187 // restore: onCreate calls handleIntent again with the original intent, so without
188 // this flag (mirroring shareIntentHandled) the session would re-open unexpectedly.
189 if (!openSessionHandled) {
190 id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
191 val profileId = intent?.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
192 ?.takeIf { it.isNotBlank() }
193 container.requestOpenSession(it, profileId)
194 openSessionHandled = true
200 /** Intent extra carrying a session id to open (notifications / deep links / widget). */
201 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
203 /** Action for the "New session" launcher shortcut and quick-settings tile. */
204 const val ACTION_NEW_SESSION = "soy.iko.opencode.action.NEW_SESSION"
206 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
207 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
209 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
210 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
211 private const val KEY_NEW_SESSION_HANDLED = "newSessionHandled"
212 private const val KEY_NOTIF_PERM_REQUESTED = "notificationPermissionRequested"
215 override fun onSaveInstanceState(outState: Bundle) {
216 super.onSaveInstanceState(outState)
217 val container = (application as OpencodeApp).container
218 // Persist each intent-handled flag as "consumed", not merely "dispatched". The pending
219 // signals live only in the in-memory AppContainer, which is recreated EMPTY on process
220 // death. Persisting a plain "dispatched = true" would, after a kill *before* the UI
221 // consumed the signal, restore the flag as true so the fresh container never receives it —
222 // the deep-linked / new / shared session would silently never open. So AND each flag with
223 // "the container no longer holds the signal": still-pending at save time → persist false so
224 // the re-delivered intent re-dispatches it into the new container on restore; already
225 // consumed → persist true so we don't re-open it unexpectedly on every later restore.
226 val openConsumed = container.pendingOpenSession.value == null
227 val newConsumed = container.pendingNewSession.value == 0
228 val shareConsumed = container.pendingShare.value == null && container.pendingSharedMedia.value.isEmpty()
229 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled && shareConsumed)
230 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled && openConsumed)
231 outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled && newConsumed)
232 outState.putBoolean(KEY_NOTIF_PERM_REQUESTED, notificationPermissionRequested)
235 /** Ask for POST_NOTIFICATIONS on Android 13+ so run/completion notifications show.
236 * Idempotent per Activity instance via [notificationPermissionRequested]. */
237 private fun maybeRequestNotificationPermission() {
238 if (notificationPermissionRequested) return
239 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
240 val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
241 PackageManager.PERMISSION_GRANTED
243 notificationPermissionRequested = true
244 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