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