git.iko.soy/opencode-android
on c0621ff help sign in
169 lines 8.3 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
41 override fun onCreate(savedInstanceState: Bundle?) {
42 val splash = installSplashScreen()
43 super.onCreate(savedInstanceState)
44 enableEdgeToEdge()
45 savedInstanceState?.let {
46 shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
47 openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
48 }
49 val container = (application as OpencodeApp).container
50 handleIntent(intent)
51 if (savedInstanceState == null) maybeRequestNotificationPermission()
52 // Hold the splash until the persisted theme settings load, so we never paint a
53 // frame with the default (dynamicColor=true / SYSTEM) that then snaps to the
54 // user's real choice — the flash the SettingsScreen's null-gate already avoids.
55 var themeLoaded = false
56 splash.setKeepOnScreenCondition { !themeLoaded }
57 setContent {
58 val theme by remember(container) {
59 container.settingsStore.themeMode
60 .combine(container.settingsStore.dynamicColor, ::Pair)
61 }.collectAsStateWithLifecycle(initialValue = null)
62 val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = false)
63 LaunchedEffect(theme) { if (theme != null) themeLoaded = true }
64 // Hide the app's content from screenshots / the recents thumbnail while app lock
65 // is enabled, so protected server details don't leak there.
66 LaunchedEffect(appLock) {
67 if (appLock) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
68 else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
69 }
70 theme?.let { (themeMode, dynamicColor) ->
71 val dark = when (themeMode) {
72 ThemeMode.SYSTEM -> isSystemInDarkTheme()
73 ThemeMode.LIGHT -> false
74 ThemeMode.DARK -> true
75 }
76 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
77 AppLockGate(enabled = appLock) {
78 OpencodeAppUi(container = container)
79 }
80 }
81 }
82 }
83 }
85 override fun onNewIntent(intent: Intent) {
86 super.onNewIntent(intent)
87 setIntent(intent)
88 shareIntentHandled = false
89 openSessionHandled = false
90 handleIntent(intent)
91 }
93 /** Capture text shared from another app so it can be prefilled into a session draft,
94 * and session ids from notification taps / deep links so we can open them. */
95 private fun handleIntent(intent: Intent?) {
96 val container = (application as OpencodeApp).container
97 val action = intent?.action
98 if ((action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE) && !shareIntentHandled) {
99 val type = intent.type.orEmpty()
100 if (action == Intent.ACTION_SEND && type.startsWith("image/")) {
101 // A single shared image → stage as an attachment in the next opened session.
102 val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
103 if (uri != null) {
104 container.setPendingSharedMedia(listOf(uri.toString()))
105 shareIntentHandled = true
106 }
107 } else if (action == Intent.ACTION_SEND_MULTIPLE && type.startsWith("image/")) {
108 val uris = IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
109 ?.take(NetworkConfig.maxAttachments)
110 .orEmpty()
111 if (uris.isNotEmpty()) {
112 container.setPendingSharedMedia(uris.map { it.toString() })
113 shareIntentHandled = true
114 }
115 } else {
116 val text = intent.getStringExtra(Intent.EXTRA_TEXT)
117 ?.takeIf { it.isNotBlank() }
118 ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
119 if (text != null) {
120 container.setPendingShare(text)
121 shareIntentHandled = true
122 }
123 }
124 }
125 // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
126 val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
127 ?.data
128 ?.takeIf { it.host == "session" }
129 ?.lastPathSegment
130 val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
131 // Validate: session ids are opaque server-generated identifiers. Reject anything
132 // with path separators or other traversal/control characters so a malicious deep
133 // link can't inject path components into the REST URL path.
134 // Guard against re-firing the retained ACTION_VIEW intent after process-death
135 // restore: onCreate calls handleIntent again with the original intent, so without
136 // this flag (mirroring shareIntentHandled) the session would re-open unexpectedly.
137 if (!openSessionHandled) {
138 id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
139 container.requestOpenSession(it)
140 openSessionHandled = true
141 }
142 }
143 }
145 companion object {
146 /** Intent extra carrying a session id to open (notifications / deep links). */
147 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
149 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
150 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
152 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
153 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
154 }
156 override fun onSaveInstanceState(outState: Bundle) {
157 super.onSaveInstanceState(outState)
158 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
159 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled)
160 }
162 /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
163 private fun maybeRequestNotificationPermission() {
164 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
165 val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
166 PackageManager.PERMISSION_GRANTED
167 if (!granted) requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
168 }