git.iko.soy/opencode-android
on bc6b45e help sign in
133 lines 6.2 kB history raw
1package soy.iko.opencode
3import android.Manifest
4import android.content.Intent
5import android.content.pm.PackageManager
6import android.os.Build
7import android.os.Bundle
8import androidx.activity.ComponentActivity
9import androidx.activity.compose.setContent
10import androidx.activity.enableEdgeToEdge
11import androidx.activity.result.contract.ActivityResultContracts
12import androidx.compose.foundation.isSystemInDarkTheme
13import androidx.compose.runtime.LaunchedEffect
14import androidx.compose.runtime.getValue
15import androidx.compose.runtime.remember
16import kotlinx.coroutines.flow.combine
17import androidx.core.content.ContextCompat
18import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
19import androidx.lifecycle.compose.collectAsStateWithLifecycle
20import soy.iko.opencode.data.repo.ThemeMode
21import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
22import soy.iko.opencode.ui.theme.OpencodeTheme
24class MainActivity : ComponentActivity() {
26 private val requestNotificationPermission = registerForActivityResult(
27 ActivityResultContracts.RequestPermission(),
28 ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
30 private var shareIntentHandled = false
31 private var openSessionHandled = false
33 override fun onCreate(savedInstanceState: Bundle?) {
34 val splash = installSplashScreen()
35 super.onCreate(savedInstanceState)
36 enableEdgeToEdge()
37 savedInstanceState?.let {
38 shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
39 openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
40 }
41 val container = (application as OpencodeApp).container
42 handleIntent(intent)
43 if (savedInstanceState == null) maybeRequestNotificationPermission()
44 // Hold the splash until the persisted theme settings load, so we never paint a
45 // frame with the default (dynamicColor=true / SYSTEM) that then snaps to the
46 // user's real choice — the flash the SettingsScreen's null-gate already avoids.
47 var themeLoaded = false
48 splash.setKeepOnScreenCondition { !themeLoaded }
49 setContent {
50 val theme by remember(container) {
51 container.settingsStore.themeMode
52 .combine(container.settingsStore.dynamicColor, ::Pair)
53 }.collectAsStateWithLifecycle(initialValue = null)
54 LaunchedEffect(theme) { if (theme != null) themeLoaded = true }
55 theme?.let { (themeMode, dynamicColor) ->
56 val dark = when (themeMode) {
57 ThemeMode.SYSTEM -> isSystemInDarkTheme()
58 ThemeMode.LIGHT -> false
59 ThemeMode.DARK -> true
60 }
61 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
62 OpencodeAppUi(container = container)
63 }
64 }
65 }
66 }
68 override fun onNewIntent(intent: Intent) {
69 super.onNewIntent(intent)
70 setIntent(intent)
71 shareIntentHandled = false
72 openSessionHandled = false
73 handleIntent(intent)
74 }
76 /** Capture text shared from another app so it can be prefilled into a session draft,
77 * and session ids from notification taps / deep links so we can open them. */
78 private fun handleIntent(intent: Intent?) {
79 val container = (application as OpencodeApp).container
80 if (intent?.action == Intent.ACTION_SEND && !shareIntentHandled) {
81 val text = intent.getStringExtra(Intent.EXTRA_TEXT)
82 ?.takeIf { it.isNotBlank() }
83 ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
84 if (text != null) {
85 container.setPendingShare(text)
86 shareIntentHandled = true
87 }
88 }
89 // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
90 val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
91 ?.data
92 ?.takeIf { it.host == "session" }
93 ?.lastPathSegment
94 val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
95 // Validate: session ids are opaque server-generated identifiers. Reject anything
96 // with path separators or other traversal/control characters so a malicious deep
97 // link can't inject path components into the REST URL path.
98 // Guard against re-firing the retained ACTION_VIEW intent after process-death
99 // restore: onCreate calls handleIntent again with the original intent, so without
100 // this flag (mirroring shareIntentHandled) the session would re-open unexpectedly.
101 if (!openSessionHandled) {
102 id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
103 container.requestOpenSession(it)
104 openSessionHandled = true
105 }
106 }
107 }
109 companion object {
110 /** Intent extra carrying a session id to open (notifications / deep links). */
111 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
113 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
114 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
116 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
117 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
118 }
120 override fun onSaveInstanceState(outState: Bundle) {
121 super.onSaveInstanceState(outState)
122 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
123 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled)
124 }
126 /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
127 private fun maybeRequestNotificationPermission() {
128 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
129 val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
130 PackageManager.PERMISSION_GRANTED
131 if (!granted) requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
132 }