Files
aiturk-mobil/app/src/main/java/com/m57/hermescontrol/MainActivity.kt
T
yilsemandClaude Opus 5.5 6f4309c746
Merge Conflict Detector / detect-and-label (push) Successful in 3s
Android CI/CD / Detect Changes (push) Successful in 6s
CodeQL Analysis / Analyze (java-kotlin) (push) Failing after 2m31s
Android CI/CD / Android Lint — Static Analysis (push) Failing after 3m57s
Android CI/CD / Build Debug APK (push) Failing after 1m56s
Android CI/CD / Instrumented Tests (push) Failing after 1m15s
Android CI/CD / ktlint — Code Style (push) Failing after 5m16s
Android CI/CD / Unit & Integration Tests (push) Failing after 4m8s
Android CI/CD / Release Compile Check (push) Failing after 14m1s
Android CI/CD / CI Summary (push) Failing after 1s
feat(aiturk): add the connect-to-computer screen (QR scan, code, deep link)
Landing now leads with QR / 8-character code pairing; manual login stays as the secondary action. aiturk://pair links open the same screen and ask for confirmation first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-04 06:06:31 +03:00

173 lines
7.3 KiB
Kotlin

package com.m57.hermescontrol
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.notification.NotificationReplyReceiver
import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.ui.aiturkpair.AiturkPairDeepLink
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.util.LocaleContextWrapper
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
companion object {
/**
* Action that identifies notification-driven "open this chat" intents.
* [ChatNotificationService] stamps it on the content intent; the
* consumer refuses anything else (issue #832 — MainActivity is
* exported as the launcher, so a foreign app could otherwise inject
* a session id via an explicit intent).
*/
const val ACTION_OPEN_CHAT_FROM_NOTIFICATION =
"com.m57.hermescontrol.ACTION_OPEN_CHAT_FROM_NOTIFICATION"
}
private var baseContextLanguageCode: String = LocaleContextWrapper.SYSTEM_LANGUAGE
/**
* Apply the user-selected display language before any view is inflated.
* Reads the persisted code from [AuthManager]; an uninitialized store
* (shouldn't happen here, but guarded) falls back to the device locale.
*/
override fun attachBaseContext(base: Context) {
val code =
runCatching { AuthManager.getAppLanguage() }
.getOrDefault(LocaleContextWrapper.SYSTEM_LANGUAGE)
baseContextLanguageCode = code
super.attachBaseContext(LocaleContextWrapper.wrapWithCode(base, code))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
consumeNotificationIntent(intent)
AiturkPairDeepLink.offer(intent)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
AuthManager.initializationState.collect { state ->
if (state == AuthManager.InitializationState.Ready) {
if (AuthManager.isGatedMode() || !AuthManager.getToken().isNullOrBlank()) {
HermesWsClient.connect()
}
}
}
}
}
enableEdgeToEdge()
setContent {
val themePreference by AuthManager.themePreferenceFlow.collectAsState()
val useDynamicColors by AuthManager.useDynamicColorsFlow.collectAsState()
val themePreset by AuthManager.themePresetFlow.collectAsState()
val chatFontScale by AuthManager.chatFontScaleFlow.collectAsState()
HermesControlTheme(
themePreference = themePreference,
useDynamicColors = useDynamicColors,
themePreset = themePreset,
chatFontScale = chatFontScale,
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
val initState by AuthManager.initializationState.collectAsStateWithLifecycle()
when (initState) {
AuthManager.InitializationState.Loading -> {
LoadingState()
}
AuthManager.InitializationState.Ready -> {
val savedLanguage = AuthManager.getAppLanguage()
if (savedLanguage != baseContextLanguageCode) {
// The next activity attaches with loaded preferences. Keep navigation
// gated across recompositions until that recreation completes (#1171).
LaunchedEffect(savedLanguage) {
recreate()
}
LoadingState()
} else {
MainNavigation()
}
}
AuthManager.InitializationState.Failed -> {
ErrorState(
message = stringResource(R.string.auth_init_failed),
onRetry = {
AuthManager.init(applicationContext)
},
)
}
}
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
consumeNotificationIntent(intent)
AiturkPairDeepLink.offer(intent)
}
override fun onResume() {
super.onResume()
lifecycleScope.launch {
AuthManager.initializationState.first { it == AuthManager.InitializationState.Ready }
UpdateNoticeManager.checkOnLaunch()
}
NotificationHelper.setAppForeground(this, true)
ExternalActivityLifecycleGuard.onHostResumed()
NotificationHelper.stop(this)
}
override fun onPause() {
ExternalActivityLifecycleGuard.onHostPaused()
// Prepare the FGS while still eligible to start it. A pause alone does
// not mean background: rotation also pauses and recreates this activity.
NotificationHelper.start(this)
super.onPause()
}
override fun onStop() {
// isChangingConfigurations is reliable here, not during onPause.
// Keep the shared socket alive across recreation, but still release an
// idle connection when the user really leaves the app or locks the phone.
if (!isChangingConfigurations) NotificationHelper.setAppForeground(this, false)
super.onStop()
}
private fun consumeNotificationIntent(intent: Intent?) {
// Issue #832: MainActivity is exported (launcher requirement) — only
// honor intents stamped with our own notification action.
if (intent?.action != ACTION_OPEN_CHAT_FROM_NOTIFICATION) return
val sessionId = intent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID)
intent.removeExtra(NotificationReplyReceiver.EXTRA_SESSION_ID)
sessionId?.takeIf { it.isNotBlank() }?.let(NavigationController::openChatSessionFromNotification)
}
}