git.iko.soy/opencode-android
on 066cded help sign in
247 lines 13 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 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)
53 enableEdgeToEdge()
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)
58 }
59 val container = (application as OpencodeApp).container
60 handleIntent(intent)
61 if (savedInstanceState == null) {
62 maybeRequestNotificationPermission()
63 } else {
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)
68 }
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 }
75 setContent {
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)
88 }
89 val themePair = theme
90 val locked = appLock
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
97 }
98 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
99 AppLockGate(enabled = locked) {
100 OpencodeAppUi(container = container)
101 }
102 }
103 }
104 }
105 }
107 override fun onNewIntent(intent: Intent) {
108 super.onNewIntent(intent)
109 setIntent(intent)
110 shareIntentHandled = false
111 openSessionHandled = false
112 newSessionHandled = false
113 handleIntent(intent)
114 }
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() {
121 super.onStart()
122 (application as OpencodeApp).container.setForeground(true)
123 }
125 override fun onStop() {
126 super.onStop()
127 (application as OpencodeApp).container.setForeground(false)
128 }
130 override fun onResume() {
131 super.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()
137 }
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
149 }
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)
155 if (uri != null) {
156 container.setPendingSharedMedia(listOf(uri.toString()))
157 shareIntentHandled = true
158 }
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)
162 .orEmpty()
163 if (uris.isNotEmpty()) {
164 container.setPendingSharedMedia(uris.map { it.toString() })
165 shareIntentHandled = true
166 }
167 } else {
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
171 if (text != null) {
172 container.setPendingShare(text)
173 shareIntentHandled = true
174 }
175 }
176 }
177 // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
178 val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
179 ?.data
180 ?.takeIf { it.host == "session" }
181 ?.lastPathSegment
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
195 }
196 }
197 }
199 companion object {
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"
213 }
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)
233 }
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
242 if (!granted) {
243 notificationPermissionRequested = true
244 requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
245 }
246 }