git.iko.soy/opencode-android
on d518ef7 help sign in
450 lines 26 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.material3.AlertDialog
18import androidx.compose.material3.Text
19import androidx.compose.material3.TextButton
20import androidx.compose.runtime.LaunchedEffect
21import androidx.compose.runtime.getValue
22import androidx.compose.runtime.mutableStateOf
23import androidx.compose.runtime.remember
24import androidx.compose.runtime.setValue
25import androidx.compose.ui.res.stringResource
26import kotlinx.coroutines.Dispatchers
27import kotlinx.coroutines.flow.combine
28import kotlinx.coroutines.launch
29import kotlinx.coroutines.withContext
30import androidx.core.content.ContextCompat
31import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
32import androidx.fragment.app.FragmentActivity
33import androidx.lifecycle.compose.collectAsStateWithLifecycle
34import androidx.lifecycle.lifecycleScope
35import soy.iko.opencode.data.repo.SettingsStore
36import soy.iko.opencode.data.repo.ThemeMode
37import soy.iko.opencode.ui.AppLockGate
38import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
39import soy.iko.opencode.ui.theme.OpencodeTheme
41// FragmentActivity (not ComponentActivity) so androidx.biometric BiometricPrompt can attach
42// for the app-lock gate; FragmentActivity extends ComponentActivity, so Compose/edge-to-edge
43// /activity-result APIs are unaffected.
44class MainActivity : FragmentActivity() {
46 private val requestNotificationPermission = registerForActivityResult(
47 ActivityResultContracts.RequestPermission(),
48 ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
50 private var shareIntentHandled = false
51 private var openSessionHandled = false
52 private var newSessionHandled = false
53 private var openFileHandled = false
54 // Set when a deep link was present but failed validation (malformed id/path). Surfaced
55 // as a toast from onResume so the user knows the link was rejected, not silently ignored.
56 private var showInvalidLinkToast = false
57 // Cold-start prompts (crash report, notification rationale) rendered as Compose M3
58 // dialogs. Hoisted as Activity-level mutableState so the non-composable trigger logic
59 // (onCreate/onResume) can flip them and the Compose tree (inside OpencodeTheme) reads
60 // them and renders an M3 AlertDialog that picks up the app palette. The triggers
61 // themselves are idempotent per instance (crashes are acknowledged on first fire;
62 // notificationPermissionRequested is restored from saved state), so a plain
63 // mutableStateOf (not saveable) preserves the original dismissal semantics across
64 // config-change recreation: the prompt simply doesn't re-fire after a rotate.
65 private var showCrashPrompt by mutableStateOf(false)
66 private var showNotifRationale by mutableStateOf(false)
67 // Track whether the notification-permission prompt has been shown for this Activity
68 // instance. onCreate only requests when savedInstanceState == null (a fresh process),
69 // but an Activity recreated by a config change NOT listed in the manifest's configChanges
70 // (e.g. a locale change) has a non-null savedInstanceState and would otherwise skip the
71 // prompt forever. onResume re-requests once per instance when still ungranted so the user
72 // is never silently left without notifications after such a recreation.
73 private var notificationPermissionRequested = false
75 override fun onCreate(savedInstanceState: Bundle?) {
76 val splash = installSplashScreen()
77 super.onCreate(savedInstanceState)
78 enableEdgeToEdge()
79 savedInstanceState?.let {
80 shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
81 openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
82 newSessionHandled = it.getBoolean(KEY_NEW_SESSION_HANDLED, false)
83 openFileHandled = it.getBoolean(KEY_OPEN_FILE_HANDLED, false)
84 }
85 val container = (application as OpencodeApp).container
86 handleIntent(intent)
87 if (savedInstanceState == null) {
88 maybeRequestNotificationPermission()
89 } else {
90 // Restored instance: the permission may have been requested pre-recreation, but
91 // we can't know the result from here. onResume will re-check and request if still
92 // ungranted (once per Activity instance).
93 notificationPermissionRequested = savedInstanceState.getBoolean(KEY_NOTIF_PERM_REQUESTED, false)
94 }
95 // Hold the splash until the persisted theme and app-lock settings load, so we never
96 // paint a frame with the defaults (dynamicColor=false / SYSTEM, app lock off) that then
97 // snap to the user's real choice — including a brief unlocked, un-FLAG_SECURE'd frame
98 // before app lock resolves. Mirrors the SettingsScreen's null-gate.
99 var settingsLoaded = false
100 splash.setKeepOnScreenCondition { !settingsLoaded }
101 // Custom splash exit: a quick fade-out instead of the system default's slide, so the
102 // hand-off to Compose reads as a branded dissolve. The listener must call
103 // splashScreenViewProvider.remove() to release the splash view; otherwise the system
104 // keeps it on screen for its default duration. Reduced-motion is respected via the
105 // animator's duration scale (the system zeroes it under reduced motion).
106 splash.setOnExitAnimationListener { splashScreenViewProvider ->
107 val view = splashScreenViewProvider.view
108 val fade = android.view.animation.AlphaAnimation(1f, 0f).apply {
109 duration = 180
110 fillAfter = true
111 }
112 fade.setAnimationListener(object : android.view.animation.Animation.AnimationListener {
113 override fun onAnimationStart(animation: android.view.animation.Animation?) {}
114 override fun onAnimationEnd(animation: android.view.animation.Animation?) {
115 splashScreenViewProvider.remove()
116 }
117 override fun onAnimationRepeat(animation: android.view.animation.Animation?) {}
118 })
119 view.startAnimation(fade)
120 }
121 setContent {
122 val theme by remember(container) {
123 container.settingsStore.themeMode
124 .combine(container.settingsStore.dynamicColor, ::Pair)
125 }.collectAsStateWithLifecycle(initialValue = null)
126 val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = null)
127 val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds
128 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS)
129 LaunchedEffect(theme, appLock) { if (theme != null && appLock != null) settingsLoaded = true }
130 // Hide the app's content from screenshots / the recents thumbnail while app lock is
131 // enabled — and while its value is still unknown (null) at cold start, so a protected
132 // launch can't flash into the recents thumbnail before the setting resolves.
133 LaunchedEffect(appLock) {
134 if (appLock != false) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
135 else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
136 }
137 val themePair = theme
138 val locked = appLock
139 if (themePair != null && locked != null) {
140 val (themeMode, dynamicColor) = themePair
141 val amoled = themeMode == ThemeMode.AMOLED
142 val dark = when (themeMode) {
143 ThemeMode.SYSTEM -> isSystemInDarkTheme()
144 ThemeMode.LIGHT -> false
145 ThemeMode.DARK -> true
146 ThemeMode.AMOLED -> true
147 }
148 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor, amoled = amoled) {
149 AppLockGate(enabled = locked, reLockDelaySeconds = appLockReLockSeconds) {
150 OpencodeAppUi(container = container)
151 }
152 // First-launch prompts as Compose M3 dialogs (siblings of AppLockGate so they
153 // still surface over the lock screen, matching the prior framework-dialog
154 // behavior) and inside OpencodeTheme so they use the app palette.
155 if (showCrashPrompt) {
156 AlertDialog(
157 onDismissRequest = { showCrashPrompt = false },
158 title = { Text(stringResource(R.string.crash_last_run_title)) },
159 text = { Text(stringResource(R.string.crash_last_run_text)) },
160 confirmButton = {
161 TextButton(onClick = {
162 showCrashPrompt = false
163 container.requestDiagnostics()
164 }) { Text(stringResource(R.string.crash_last_run_view)) }
165 },
166 dismissButton = {
167 TextButton(onClick = { showCrashPrompt = false }) {
168 Text(stringResource(R.string.crash_last_run_dismiss))
169 }
170 },
171 )
172 }
173 if (showNotifRationale) {
174 AlertDialog(
175 // Mirrors the original setCancelable(false): not dismissible by
176 // back/outside tap, only by the buttons below.
177 onDismissRequest = { },
178 title = { Text(stringResource(R.string.notif_rationale_title)) },
179 text = { Text(stringResource(R.string.notif_rationale_text)) },
180 confirmButton = {
181 TextButton(onClick = {
182 showNotifRationale = false
183 runCatching { requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) }
184 }) { Text(stringResource(R.string.notif_rationale_allow)) }
185 },
186 dismissButton = {
187 TextButton(onClick = { showNotifRationale = false }) {
188 Text(stringResource(R.string.notif_rationale_skip))
189 }
190 },
191 )
192 }
193 }
194 }
195 }
196 // Surface a "crashed last time" prompt if a crash report from a previous run
197 // hasn't been acknowledged. Acknowledges immediately so it doesn't re-fire on
198 // every rotation/resume; the user can always find reports in Settings → Diagnostics.
199 maybeShowCrashPrompt()
200 }
202 private fun maybeShowCrashPrompt() {
203 // The check does a directory scan + per-file stat (listFiles + lastModified), which
204 // is synchronous filesystem I/O. Run it off the main thread so the cold-start
205 // critical path (before setContent's first frame) isn't blocked by up to ~20 stat
206 // syscalls on slow storage. The prompt state flips on once the result arrives; the
207 // splash screen has already been dismissed by then, so the dialog appears a frame
208 // or two later than it would have synchronously — invisible to the user.
209 val crashLogger = soy.iko.opencode.data.repo.CrashLogger.get(this)
210 lifecycleScope.launch {
211 val hasUnacknowledged = withContext(Dispatchers.IO) {
212 crashLogger.hasUnacknowledgedCrash()
213 }
214 if (hasUnacknowledged) {
215 crashLogger.acknowledgeCrashes()
216 showCrashPrompt = true
217 }
218 }
219 }
221 override fun onNewIntent(intent: Intent) {
222 super.onNewIntent(intent)
223 setIntent(intent)
224 shareIntentHandled = false
225 openSessionHandled = false
226 newSessionHandled = false
227 openFileHandled = false
228 handleIntent(intent)
229 }
231 // Track real foreground state so background notifications (permission requests,
232 // completions, errors) still fire for a session the user "has open" but has walked
233 // away from — the chat screen isn't disposed when the phone is locked, so
234 // container.currentSession stays set and can't be used as a foreground signal.
235 override fun onStart() {
236 super.onStart()
237 (application as OpencodeApp).container.setForeground(true)
238 }
240 override fun onStop() {
241 super.onStop()
242 (application as OpencodeApp).container.setForeground(false)
243 }
245 override fun onResume() {
246 super.onResume()
247 // Re-check on resume: an Activity recreated by a config change not in configChanges
248 // (e.g. locale) has savedInstanceState != null, so onCreate skipped the request. This
249 // ensures the user is prompted once per Activity instance when still ungranted rather
250 // than silently losing all notifications.
251 maybeRequestNotificationPermission()
252 // Surface a rejected deep link once, so a malformed opencode:// link doesn't appear
253 // to be silently ignored. Cleared after firing so a rotation doesn't re-toast.
254 if (showInvalidLinkToast) {
255 showInvalidLinkToast = false
256 android.widget.Toast.makeText(this, R.string.deep_link_invalid, android.widget.Toast.LENGTH_SHORT).show()
257 }
258 }
260 /** Capture text shared from another app so it can be prefilled into a session draft,
261 * and session ids from notification taps / deep links so we can open them. */
262 private fun handleIntent(intent: Intent?) {
263 val container = (application as OpencodeApp).container
264 val action = intent?.action
265 // "New session" from a launcher shortcut or the quick-settings tile. Guarded so a
266 // process-death restore (which re-delivers the intent) doesn't spawn a second session.
267 if (action == ACTION_NEW_SESSION && !newSessionHandled) {
268 container.requestNewSession()
269 newSessionHandled = true
270 }
271 if ((action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE) && !shareIntentHandled) {
272 val type = intent.type.orEmpty()
273 if (action == Intent.ACTION_SEND && type.startsWith("image/")) {
274 // A single shared image → stage as an attachment in the next opened session.
275 val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
276 if (uri != null) {
277 container.setPendingSharedMedia(listOf(uri.toString()))
278 shareIntentHandled = true
279 }
280 } else if (action == Intent.ACTION_SEND_MULTIPLE && type.startsWith("image/")) {
281 val uris = IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
282 ?.take(NetworkConfig.maxAttachments)
283 .orEmpty()
284 if (uris.isNotEmpty()) {
285 container.setPendingSharedMedia(uris.map { it.toString() })
286 shareIntentHandled = true
287 }
288 } else if (action == Intent.ACTION_SEND) {
289 // A non-image file (e.g. a PDF or text file shared from another app): stage it
290 // as an attachment too, so the user can send it to the agent. The attachment
291 // pipeline handles non-image mimes with a generic file icon. Falls back to
292 // EXTRA_TEXT when no stream is present (a plain text share).
293 val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
294 if (uri != null) {
295 container.setPendingSharedMedia(listOf(uri.toString()))
296 shareIntentHandled = true
297 } else {
298 val text = intent.getStringExtra(Intent.EXTRA_TEXT)
299 ?.takeIf { it.isNotBlank() }
300 ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
301 if (text != null) {
302 container.setPendingShare(text)
303 shareIntentHandled = true
304 }
305 }
306 } else {
307 val text = intent.getStringExtra(Intent.EXTRA_TEXT)
308 ?.takeIf { it.isNotBlank() }
309 ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
310 if (text != null) {
311 container.setPendingShare(text)
312 shareIntentHandled = true
313 }
314 }
315 }
316 // Deep links under the opencode:// scheme. The manifest exposes several hosts:
317 // session/{id} → open (and switch to) that conversation
318 // file/{path} → open the file viewer at that workspace path
319 // new → start a fresh session
320 // The file path is percent-encoded by the link source; decode it here and validate the
321 // segment to keep path-traversal out of the REST path (mirroring the session guard).
322 val data = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data
323 when (data?.host) {
324 "session" -> {
325 val seg = data.lastPathSegment
326 if (!openSessionHandled) {
327 seg?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
328 val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
329 ?.takeIf { it.isNotBlank() }
330 container.requestOpenSession(it, profileId)
331 openSessionHandled = true
332 } ?: run {
333 // The link carried a session host but no valid id (or a blank/traversal
334 // attempt). Surface a toast so the user knows the link was rejected,
335 // rather than silently no-op'ing (which reads as "the app did nothing").
336 if (!seg.isNullOrBlank()) showInvalidLinkToast = true
337 // Mark the link handled even on rejection, so a process-death-and-restore
338 // (which re-delivers the same intent) doesn't re-fire the toast on every
339 // restore — the rejection is final, not a transient state to retry.
340 openSessionHandled = true
341 }
342 }
343 }
344 "file" -> {
345 // Reconstruct the path from the path segments after "file", preserving internal
346 // slashes. Decoded + validated against traversal/control characters.
347 val rawPath = data.path?.removePrefix("/file/")?.let { android.net.Uri.decode(it) }
348 if (!openFileHandled) {
349 rawPath?.takeIf { isValidFilePath(it) }
350 ?.let { container.requestOpenFile(it); openFileHandled = true } ?: run {
351 if (!rawPath.isNullOrBlank()) showInvalidLinkToast = true
352 // Mark handled on rejection too, mirroring the session branch —
353 // otherwise a process-death restore re-fires the invalid-link toast.
354 openFileHandled = true
355 }
356 }
357 }
358 "new" -> {
359 if (!newSessionHandled) {
360 container.requestNewSession()
361 newSessionHandled = true
362 }
363 }
364 }
365 // Fall back to the EXTRA_SESSION_ID extra (notifications / widget) when no deep link
366 // was present at all. A deep link that matched a host but failed validation (e.g. a
367 // malformed session id) does NOT fall through to the extra: the intent's primary
368 // routing signal was the URI, and the extra may carry a different (valid) id that
369 // would silently open the wrong session. Only when data?.host is null (a pure widget
370 // / notification intent with no URI) does the extra carry the routing intent.
371 if (data?.host == null && !openSessionHandled) {
372 intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
373 val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
374 ?.takeIf { it.isNotBlank() }
375 container.requestOpenSession(it, profileId)
376 openSessionHandled = true
377 }
378 }
379 }
381 companion object {
382 /** Intent extra carrying a session id to open (notifications / deep links / widget). */
383 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
385 /** Action for the "New session" launcher shortcut and quick-settings tile. */
386 const val ACTION_NEW_SESSION = "soy.iko.opencode.action.NEW_SESSION"
388 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
389 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
391 /** A file path must not carry traversal segments or control characters. Looser than a
392 * positive allowlist: real workspace paths commonly contain `~`, `:`, `+`, `=`, `,`,
393 * parentheses, and non-ASCII unicode, all of which a tight `[A-Za-z0-9_\\-./ ]+`
394 * regex would reject. The server validates the path against the workspace root, so
395 * here we only reject what's unambiguously hostile (traversal) or non-printable. */
396 private fun isValidFilePath(path: String): Boolean {
397 if (path.isBlank()) return false
398 if (path.contains("..")) return false
399 if (path.any { it.isISOControl() }) return false
400 return true
401 }
403 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
404 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
405 private const val KEY_NEW_SESSION_HANDLED = "newSessionHandled"
406 private const val KEY_OPEN_FILE_HANDLED = "openFileHandled"
407 private const val KEY_NOTIF_PERM_REQUESTED = "notificationPermissionRequested"
408 }
410 override fun onSaveInstanceState(outState: Bundle) {
411 super.onSaveInstanceState(outState)
412 val container = (application as OpencodeApp).container
413 // Persist each intent-handled flag as "consumed", not merely "dispatched". The pending
414 // signals live only in the in-memory AppContainer, which is recreated EMPTY on process
415 // death. Persisting a plain "dispatched = true" would, after a kill *before* the UI
416 // consumed the signal, restore the flag as true so the fresh container never receives it —
417 // the deep-linked / new / shared session would silently never open. So AND each flag with
418 // "the container no longer holds the signal": still-pending at save time → persist false so
419 // the re-delivered intent re-dispatches it into the new container on restore; already
420 // consumed → persist true so we don't re-open it unexpectedly on every later restore.
421 val openConsumed = container.pendingOpenSession.value == null
422 val newConsumed = container.pendingNewSession.value == 0
423 val shareConsumed = container.pendingShare.value == null && container.pendingSharedMedia.value.isEmpty()
424 val fileConsumed = container.pendingOpenFile.value == null
425 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled && shareConsumed)
426 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled && openConsumed)
427 outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled && newConsumed)
428 outState.putBoolean(KEY_OPEN_FILE_HANDLED, openFileHandled && fileConsumed)
429 outState.putBoolean(KEY_NOTIF_PERM_REQUESTED, notificationPermissionRequested)
430 }
432 /** Ask for POST_NOTIFICATIONS on Android 13+ so run/completion notifications show.
433 * Idempotent per Activity instance via [notificationPermissionRequested].
434 * Shows a rationale dialog first (explaining what the user gains) before the system
435 * permission prompt, so a user who denies understands what they're missing. */
436 private fun maybeRequestNotificationPermission() {
437 if (notificationPermissionRequested) return
438 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
439 val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
440 PackageManager.PERMISSION_GRANTED
441 if (!granted) {
442 notificationPermissionRequested = true
443 showNotificationRationaleDialog()
444 }
445 }
447 private fun showNotificationRationaleDialog() {
448 showNotifRationale = true
449 }