git.iko.soy/opencode-android
on d643c13 help sign in
107 lines 4.9 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.getValue
14import androidx.core.content.ContextCompat
15import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
16import androidx.lifecycle.compose.collectAsStateWithLifecycle
17import soy.iko.opencode.data.repo.ThemeMode
18import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
19import soy.iko.opencode.ui.theme.OpencodeTheme
21class MainActivity : ComponentActivity() {
23 private val requestNotificationPermission = registerForActivityResult(
24 ActivityResultContracts.RequestPermission(),
25 ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
27 private var shareIntentHandled = false
29 override fun onCreate(savedInstanceState: Bundle?) {
30 installSplashScreen()
31 super.onCreate(savedInstanceState)
32 enableEdgeToEdge()
33 savedInstanceState?.let { shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false) }
34 val container = (application as OpencodeApp).container
35 handleIntent(intent)
36 if (savedInstanceState == null) maybeRequestNotificationPermission()
37 setContent {
38 val themeMode by container.settingsStore.themeMode
39 .collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
40 val dynamicColor by container.settingsStore.dynamicColor
41 .collectAsStateWithLifecycle(initialValue = true)
42 val dark = when (themeMode) {
43 ThemeMode.SYSTEM -> isSystemInDarkTheme()
44 ThemeMode.LIGHT -> false
45 ThemeMode.DARK -> true
46 }
47 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
48 OpencodeAppUi(container = container)
49 }
50 }
51 }
53 override fun onNewIntent(intent: Intent) {
54 super.onNewIntent(intent)
55 setIntent(intent)
56 shareIntentHandled = false
57 handleIntent(intent)
58 }
60 /** Capture text shared from another app so it can be prefilled into a session draft,
61 * and session ids from notification taps / deep links so we can open them. */
62 private fun handleIntent(intent: Intent?) {
63 val container = (application as OpencodeApp).container
64 if (intent?.action == Intent.ACTION_SEND && !shareIntentHandled) {
65 val text = intent.getStringExtra(Intent.EXTRA_TEXT)
66 ?.takeIf { it.isNotBlank() }
67 ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
68 if (text != null) {
69 container.setPendingShare(text)
70 shareIntentHandled = true
71 }
72 }
73 // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
74 val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
75 ?.data
76 ?.takeIf { it.host == "session" }
77 ?.lastPathSegment
78 val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
79 // Validate: session ids are opaque server-generated identifiers. Reject anything
80 // with path separators or other traversal/control characters so a malicious deep
81 // link can't inject path components into the REST URL path.
82 id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let { container.requestOpenSession(it) }
83 }
85 companion object {
86 /** Intent extra carrying a session id to open (notifications / deep links). */
87 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
89 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
90 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
92 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
93 }
95 override fun onSaveInstanceState(outState: Bundle) {
96 super.onSaveInstanceState(outState)
97 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
98 }
100 /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
101 private fun maybeRequestNotificationPermission() {
102 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
103 val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
104 PackageManager.PERMISSION_GRANTED
105 if (!granted) requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
106 }