git.iko.soy/opencode-android
on 5d56632 help sign in
202 lines 10 kB history raw
1package soy.iko.opencode
3import android.Manifest
4import android.content.Intent
5import android.content.pm.PackageManager
6import android.net.Uri
7import android.os.Build
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)
45 enableEdgeToEdge()
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)
50 }
51 val container = (application as OpencodeApp).container
52 handleIntent(intent)
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 }
60 setContent {
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)
73 }
74 val themePair = theme
75 val locked = appLock
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
82 }
83 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
84 AppLockGate(enabled = locked) {
85 OpencodeAppUi(container = container)
86 }
87 }
88 }
89 }
90 }
92 override fun onNewIntent(intent: Intent) {
93 super.onNewIntent(intent)
94 setIntent(intent)
95 shareIntentHandled = false
96 openSessionHandled = false
97 newSessionHandled = false
98 handleIntent(intent)
99 }
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() {
106 super.onStart()
107 (application as OpencodeApp).container.setForeground(true)
108 }
110 override fun onStop() {
111 super.onStop()
112 (application as OpencodeApp).container.setForeground(false)
113 }
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
125 }
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)
131 if (uri != null) {
132 container.setPendingSharedMedia(listOf(uri.toString()))
133 shareIntentHandled = true
134 }
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)
138 .orEmpty()
139 if (uris.isNotEmpty()) {
140 container.setPendingSharedMedia(uris.map { it.toString() })
141 shareIntentHandled = true
142 }
143 } else {
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
147 if (text != null) {
148 container.setPendingShare(text)
149 shareIntentHandled = true
150 }
151 }
152 }
153 // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
154 val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
155 ?.data
156 ?.takeIf { it.host == "session" }
157 ?.lastPathSegment
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
169 }
170 }
171 }
173 companion object {
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"
186 }
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)
193 }
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)
201 }