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