Librechat-Mobile/app/src/main/kotlin/com/garfiec/librechat/MainActivity.kt

191 lines
8 KiB
Kotlin

package com.garfiec.librechat
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
import com.garfiec.librechat.core.common.network.ConnectivityObserver
import com.garfiec.librechat.core.data.datastore.ThemeDataStore
import com.garfiec.librechat.core.data.datastore.ThemeMode
import com.garfiec.librechat.core.ui.theme.LibreChatTheme
import com.garfiec.librechat.feature.chat.ShareIntentConsumer
import com.garfiec.librechat.feature.chat.SharedContent
import com.garfiec.librechat.navigation.LibreChatNavHost
import org.koin.android.ext.android.inject
class MainActivity : ComponentActivity() {
private val connectivityObserver: ConnectivityObserver by inject()
private val themeDataStore: ThemeDataStore by inject()
private var deepLinkUri by mutableStateOf<Uri?>(null)
/**
* Incremented each time a share intent arrives.
* The NavDisplay observes this and either:
* - Navigates to NewChat if the user is not on a chat screen, or
* - Lets the active ChatViewModel consume the shared content in-place if already on a chat screen.
*/
private var shareNavigationTrigger by mutableStateOf(0)
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
handleIntent(intent)
setContent {
val windowSizeClass = calculateWindowSizeClass(this)
val isConnected by connectivityObserver.isConnected.collectAsStateWithLifecycle(initialValue = true)
val themeMode by themeDataStore.themeMode.collectAsStateWithLifecycle(initialValue = themeDataStore.initialThemeMode)
val darkTheme = when (themeMode) {
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
ThemeMode.SYSTEM -> isSystemInDarkTheme()
}
// Update system bar icon colors to match the app's resolved theme.
// When light theme: dark icons on light background (isAppearanceLight = true).
// When dark theme: light icons on dark background (isAppearanceLight = false).
// This ensures correct visibility even when the user overrides the system theme.
SideEffect {
val insetsController = WindowCompat.getInsetsController(window, window.decorView)
insetsController.isAppearanceLightStatusBars = !darkTheme
insetsController.isAppearanceLightNavigationBars = !darkTheme
}
LibreChatTheme(darkTheme = darkTheme) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
AnimatedVisibility(
visible = !isConnected,
enter = expandVertically(),
exit = shrinkVertically(),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.errorContainer)
.padding(vertical = 6.dp, horizontal = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.no_connection),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
LibreChatNavHost(
windowSizeClass = windowSizeClass,
deepLinkUri = deepLinkUri,
onDeepLinkConsumed = { deepLinkUri = null },
shareNavigationTrigger = shareNavigationTrigger,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent?) {
if (intent == null) return
when (intent.action) {
Intent.ACTION_SEND -> handleShareIntent(intent)
Intent.ACTION_SEND_MULTIPLE -> handleShareMultipleIntent(intent)
else -> handleDeepLink(intent)
}
}
private fun handleDeepLink(intent: Intent) {
intent.data?.let { uri ->
if (uri.scheme == "librechat" && uri.host in KNOWN_DEEP_LINK_HOSTS) {
deepLinkUri = uri
} else if (uri.scheme == "librechat") {
Logger.w { "Ignoring deep link with unknown host: ${uri.host}" }
}
}
}
companion object {
/** Hosts accepted by the librechat:// deep link scheme. */
private val KNOWN_DEEP_LINK_HOSTS = setOf("conversation", "oauth")
/** MongoDB ObjectID: exactly 24 lowercase hex characters. */
val CONVERSATION_ID_REGEX = Regex("^[a-f0-9]{24}$")
}
private fun handleShareIntent(intent: Intent) {
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
@Suppress("DEPRECATION")
val sharedUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
val fileUris = if (sharedUri != null) listOf(sharedUri) else emptyList()
if (sharedText != null || fileUris.isNotEmpty()) {
Logger.d { "Share intent received: text=${sharedText != null}, uris=${fileUris.size}" }
ShareIntentConsumer.setPendingShare(
SharedContent(text = sharedText, fileUris = fileUris),
)
shareNavigationTrigger++
}
}
private fun handleShareMultipleIntent(intent: Intent) {
@Suppress("DEPRECATION")
val sharedUris = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
if (!sharedUris.isNullOrEmpty()) {
Logger.d { "Share multiple intent received: uris=${sharedUris.size}" }
ShareIntentConsumer.setPendingShare(
SharedContent(fileUris = sharedUris),
)
shareNavigationTrigger++
}
}
}