git.iko.soy/opencode-android
on 0bc1eda help sign in
91 lines 4.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.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 override fun onCreate(savedInstanceState: Bundle?) {
28 installSplashScreen()
29 super.onCreate(savedInstanceState)
30 enableEdgeToEdge()
31 val container = (application as OpencodeApp).container
32 if (savedInstanceState == null) handleIntent(intent)
33 maybeRequestNotificationPermission()
34 setContent {
35 val themeMode by container.settingsStore.themeMode
36 .collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
37 val dynamicColor by container.settingsStore.dynamicColor
38 .collectAsStateWithLifecycle(initialValue = true)
39 val dark = when (themeMode) {
40 ThemeMode.SYSTEM -> isSystemInDarkTheme()
41 ThemeMode.LIGHT -> false
42 ThemeMode.DARK -> true
43 }
44 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
45 OpencodeAppUi(container = container)
46 }
47 }
48 }
50 override fun onNewIntent(intent: Intent) {
51 super.onNewIntent(intent)
52 setIntent(intent)
53 handleIntent(intent)
54 }
56 /** Capture text shared from another app so it can be prefilled into a session draft,
57 * and session ids from notification taps / deep links so we can open them. */
58 private fun handleIntent(intent: Intent?) {
59 val container = (application as OpencodeApp).container
60 if (intent?.action == Intent.ACTION_SEND) {
61 val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.takeIf { it.isNotBlank() }
62 if (text != null) container.setPendingShare(text)
63 }
64 // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
65 val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
66 ?.data
67 ?.takeIf { it.host == "session" }
68 ?.lastPathSegment
69 val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
70 // Validate: session ids are opaque server-generated identifiers. Reject anything
71 // with path separators or other traversal/control characters so a malicious deep
72 // link can't inject path components into the REST URL path.
73 id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let { container.requestOpenSession(it) }
74 }
76 companion object {
77 /** Intent extra carrying a session id to open (notifications / deep links). */
78 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
80 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
81 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
82 }
84 /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
85 private fun maybeRequestNotificationPermission() {
86 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
87 val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
88 PackageManager.PERMISSION_GRANTED
89 if (!granted) requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
90 }