package com.dano.test1 import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.content.res.Configuration import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.os.Build import android.os.Bundle import android.content.Intent import androidx.activity.OnBackPressedCallback import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import com.dano.test1.LanguageManager import com.dano.test1.ui.AppLanguageStore import com.dano.test1.ui.NotificationPermissionPrompter import com.dano.test1.ui.AuthActivity import com.dano.test1.ui.HandlerOpeningScreen import com.dano.test1.ui.LogoutDialogs import com.dano.test1.ui.SessionLogout import com.dano.test1.ui.SystemBarInsets import com.dano.test1.utils.SyncProgressOverlay import com.dano.test1.utils.SyncProgressTexts import com.dano.test1.utils.ViewUtils import com.dano.test1.data.PendingUploadRepository import com.dano.test1.network.NetworkUtils import com.dano.test1.network.QuestionnaireApiClient import com.dano.test1.network.SyncLog import com.dano.test1.network.QuestionnaireCache import com.dano.test1.network.SyncCoordinator import com.dano.test1.network.SyncProgress import com.dano.test1.network.SyncResult import com.dano.test1.network.TokenStore import com.dano.test1.questionnaire.QuestionnaireBase import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class MainActivity : AppCompatActivity() { private companion object { private const val STARTUP_SYNC_PREFS = "startup_sync_state" private const val KEY_LAST_STARTUP_SYNC_MS = "last_startup_sync_ms" private const val STARTUP_SYNC_INTERVAL_MS = 30L * 60L * 1000L } private lateinit var openingScreenHandler: HandlerOpeningScreen var isInQuestionnaire: Boolean = false var isFirstQuestionnairePage: Boolean = false /** Set before opening DevSettings so onResume runs a full opening-screen rebuild. */ private var needsOpeningReinit: Boolean = false private var assignedClientsRefreshGeneration: Long = 0L fun markOpeningScreenReinit() { needsOpeningReinit = true } fun bumpAssignedClientsRefreshGeneration() { assignedClientsRefreshGeneration++ } private var syncProgressOverlay: SyncProgressOverlay? = null private var backPressInterceptor: (() -> Boolean)? = null private var notificationPromptRequested = false private val requestNotificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* no-op */ } private val authLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode != RESULT_OK) { finishAffinity() return@registerForActivityResult } val token = result.data?.getStringExtra(AuthActivity.EXTRA_TOKEN) if (token.isNullOrBlank()) { finishAffinity() return@registerForActivityResult } proceedAfterLogin(token) } // LIVE: Network-Callback (optional für Statusleiste) private var netCb: ConnectivityManager.NetworkCallback? = null private val bootLanguageId: String get() = AppLanguageStore.get(this) private fun t(key: String): String = LanguageManager.getText(bootLanguageId, key) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { when { isInQuestionnaire -> confirmExitQuestionnaire() backPressInterceptor?.invoke() == true -> Unit ::openingScreenHandler.isInitialized && openingScreenHandler.onBackPressed() -> Unit else -> { isEnabled = false onBackPressedDispatcher.onBackPressed() } } } }) TokenStore.ensureLegacyLoginTimestamp(this) setContentView(R.layout.activity_startup_loading) // === Offline-Start ermöglichen === // Bedingung: gespeicherter User/Token UND lokaler API-Cache vorhanden -> direkt OpeningScreen val hasCreds = !TokenStore.getUsername(this).isNullOrBlank() && TokenStore.hasToken(this) val hasCache = QuestionnaireCache.hasQuestionnaireCache(this) SyncLog.d("MAIN", "onCreate: hasCreds=$hasCreds, hasCache=$hasCache") SyncLog.d("MAIN", "onCreate: cached assigned clients count=${QuestionnaireCache.getAssignedClients(this).size}") if (hasCreds && hasCache) { validateStoredSessionThenOpen() return } SyncCoordinator.loadCachedTranslations(this) CoroutineScope(Dispatchers.Main).launch { SyncCoordinator.refreshPublicTranslations(this@MainActivity) } // Sonst: Login-Screen -> Login -> Daten laden -> OpeningScreen launchAuthScreen() } private fun validateStoredSessionThenOpen() { val token = TokenStore.getToken(this) if (token.isNullOrBlank() || !TokenStore.isSessionValid(this)) { requestLogin() return } if (!NetworkUtils.isOnline(this)) { showCachedOpeningScreen(refreshAfterOpen = false) return } CoroutineScope(Dispatchers.Main).launch { val sessionCheck = withContext(Dispatchers.IO) { runCatching { QuestionnaireApiClient.getSession(token) } } val session = sessionCheck.getOrNull() val error = sessionCheck.exceptionOrNull() if (session != null && session.valid && session.role == "coach" && !session.mustChangePassword) { routeCachedStartup(token) } else if (error != null && TokenStore.isAuthError(error)) { requestLogin() } else if (error == null) { requestLogin() } else { // Network/server transient failure: keep offline-capable cached mode. showCachedOpeningScreen(refreshAfterOpen = false) } } } private suspend fun routeCachedStartup(token: String) { val hasPendingUploads = withContext(Dispatchers.IO) { PendingUploadRepository.hasAny() } if (hasPendingUploads || !NetworkUtils.isOnline(this)) { showCachedOpeningScreen(refreshAfterOpen = false, showUploadPrompt = hasPendingUploads) return } if (!shouldRunStartupSync()) { showCachedOpeningScreen(refreshAfterOpen = false) return } showSyncProgressOverlay(bootLanguageId) val syncResult = withContext(Dispatchers.IO) { SyncCoordinator.refreshManual( context = this@MainActivity, token = token, onProgress = { reportSyncProgress(it) }, ) } hideSyncProgressOverlay() when (syncResult) { SyncResult.AuthRequired -> { requestLogin() return } else -> { if (syncResult is SyncResult.Success) { markSuccessfulSync() } showCachedOpeningScreen(refreshAfterOpen = false) if (syncResult is SyncResult.Failure) { SyncCoordinator.toastForResult(this, bootLanguageId, syncResult) } } } } private fun shouldRunStartupSync(): Boolean { val lastSyncAt = getSharedPreferences(STARTUP_SYNC_PREFS, Context.MODE_PRIVATE) .getLong(KEY_LAST_STARTUP_SYNC_MS, 0L) return lastSyncAt <= 0L || System.currentTimeMillis() - lastSyncAt >= STARTUP_SYNC_INTERVAL_MS } fun markSuccessfulSync() { getSharedPreferences(STARTUP_SYNC_PREFS, Context.MODE_PRIVATE) .edit() .putLong(KEY_LAST_STARTUP_SYNC_MS, System.currentTimeMillis()) .apply() } private fun showCachedOpeningScreen(refreshAfterOpen: Boolean, showUploadPrompt: Boolean = false) { SyncCoordinator.loadCachedTranslations(this) openingScreenHandler = HandlerOpeningScreen(this) openingScreenHandler.init() if (showUploadPrompt) { openingScreenHandler.runAfterFirstClientLoad { openingScreenHandler.showStartupUploadPrompt() } } if (refreshAfterOpen) { openingScreenHandler.runAfterFirstClientLoad { refreshAssignedClientsInBackground() } } scheduleNotificationPermissionPrompt() } /** Refreshes assigned clients from the server when online (e.g. after app restart). */ private fun refreshAssignedClientsInBackground() { val token = TokenStore.getToken(this) ?: return if (!NetworkUtils.isOnline(this)) { SyncLog.d("MAIN", "refreshAssignedClients skipped: offline") return } val generation = assignedClientsRefreshGeneration CoroutineScope(Dispatchers.IO).launch { when (val result = SyncCoordinator.refreshClientsOnly(this@MainActivity, token)) { SyncResult.AuthRequired -> { withContext(Dispatchers.Main) { requestLogin() } return@launch } SyncResult.Success -> { withContext(Dispatchers.Main) { if (generation != assignedClientsRefreshGeneration) return@withContext if (::openingScreenHandler.isInitialized && !isInQuestionnaire) { openingScreenHandler.onAssignedClientsSynced() } } } else -> {} } } } /** * Clears all local data and shows the login screen (e.g. expired token). * Does not check pending uploads — session is already invalid. */ fun requestLogin() { isInQuestionnaire = false CoroutineScope(Dispatchers.Main).launch { val previousUser = TokenStore.getUsername(this@MainActivity) withContext(Dispatchers.IO) { SessionLogout.wipeAllLocalData(this@MainActivity) } launchAuthScreen(prefillUsername = previousUser) } } /** User-initiated logout from settings; blocked when offline or unuploaded data remains. */ fun attemptLogout(languageId: String? = null) { val lang = languageId ?: bootLanguageId CoroutineScope(Dispatchers.Main).launch { val block = withContext(Dispatchers.IO) { SessionLogout.evaluate(this@MainActivity) } when (block) { null -> LogoutDialogs.showConfirm(this@MainActivity, lang) { CoroutineScope(Dispatchers.Main).launch { withContext(Dispatchers.IO) { SessionLogout.wipeAllLocalData(this@MainActivity) } isInQuestionnaire = false launchAuthScreen() } } SessionLogout.BlockReason.Offline -> LogoutDialogs.showBlocked( activity = this@MainActivity, languageId = lang, title = SessionLogout.message(lang, "logout_offline_title", "Cannot log out"), message = SessionLogout.message( lang, "logout_offline_message", "You are offline. Connect to the internet, upload pending data, then try again.", ), tone = LogoutDialogs.Tone.Info, ) is SessionLogout.BlockReason.UnuploadedWork -> { val details = SessionLogout.formatPendingDetails(lang, block.summary) val bodyTemplate = SessionLogout.message( lang, "logout_pending_message", "You still have data on this device that has not been uploaded.\n\n{details}\n\nPlease tap Upload and wait until it finishes.", ) val message = bodyTemplate.replace("{details}", "").trim() LogoutDialogs.showBlocked( activity = this@MainActivity, languageId = lang, title = SessionLogout.message(lang, "logout_pending_title", "Cannot log out"), message = message, details = details, tone = LogoutDialogs.Tone.Warning, ) } } } } private fun launchAuthScreen(prefillUsername: String? = null) { val intent = Intent(this, AuthActivity::class.java) prefillUsername?.takeIf { it.isNotBlank() }?.let { intent.putExtra(AuthActivity.EXTRA_PREFILL_USERNAME, it) } authLauncher.launch(intent) } fun showSyncProgressOverlay(languageId: String? = null) { val locale = SyncProgressTexts.Locale.fromLanguageId(languageId ?: bootLanguageId) ensureSyncProgressOverlay().setLocale(locale) ensureSyncProgressOverlay().show() } fun hideSyncProgressOverlay() { syncProgressOverlay?.hide() } fun reportSyncProgress(progress: SyncProgress) { runOnUiThread { ensureSyncProgressOverlay().update(progress) } } private fun ensureSyncProgressOverlay(): SyncProgressOverlay { if (syncProgressOverlay == null) { syncProgressOverlay = SyncProgressOverlay(this) } return syncProgressOverlay!! } private fun proceedAfterLogin(token: String) { SyncLog.d("MAIN", "Login success") SyncLog.d("MAIN", "Assigned clients from cache after login count=${QuestionnaireCache.getAssignedClients(this).size}") showSyncProgressOverlay(bootLanguageId) CoroutineScope(Dispatchers.IO).launch { val syncResult = SyncCoordinator.refreshAfterLogin( context = this@MainActivity, token = token, languageId = bootLanguageId, onProgress = { reportSyncProgress(it) }, ) withContext(Dispatchers.Main) { hideSyncProgressOverlay() when (syncResult) { SyncResult.AuthRequired -> { requestLogin() return@withContext } is SyncResult.Failure -> { SyncCoordinator.toastForResult(this@MainActivity, bootLanguageId, syncResult) } SyncResult.Success -> markSuccessfulSync() else -> {} } openingScreenHandler = HandlerOpeningScreen(this@MainActivity) openingScreenHandler.init() openingScreenHandler.refreshHeaderStatusLive() scheduleNotificationPermissionPrompt() } } } fun setBackPressInterceptor(interceptor: (() -> Boolean)?) { backPressInterceptor = interceptor } private fun scheduleNotificationPermissionPrompt() { if (notificationPromptRequested) return notificationPromptRequested = true window.decorView.post { NotificationPermissionPrompter.showIfNeeded(this, requestNotificationPermission) } } private fun confirmExitQuestionnaire() { fun td(key: String, fallbackDe: String, fallbackEn: String): String { val raw = LanguageManager.getText(bootLanguageId, key) if (raw != key) return raw return if (bootLanguageId == "GERMAN") fallbackDe else fallbackEn } MaterialAlertDialogBuilder(this) .setTitle(td("questionnaire_exit_title", "Fragebogen verlassen?", "Leave questionnaire?")) .setMessage( td( "questionnaire_exit_message", "Nicht gespeicherte Eingaben in diesem Schritt können verloren gehen.", "Unsaved answers on this screen may be lost.", ), ) .setNegativeButton(td("cancel", "Abbrechen", "Cancel")) { d, _ -> d.dismiss() } .setPositiveButton(td("leave", "Verlassen", "Leave")) { _, _ -> finishQuestionnaire() } .show() } // --- LIVE NETZSTATUS (optional, für deine Status-Leiste) --- override fun onResume() { super.onResume() registerNetworkCallback() if (::openingScreenHandler.isInitialized && !isInQuestionnaire) { if (needsOpeningReinit) { needsOpeningReinit = false openingScreenHandler.init() } else { openingScreenHandler.refreshHeaderStatusLive() } if (QuestionnaireCache.getAssignedClients(this).isEmpty()) { refreshAssignedClientsInBackground() } } } override fun onPause() { super.onPause() unregisterNetworkCallback() } private fun registerNetworkCallback() { if (netCb != null) return val cm = getSystemService(ConnectivityManager::class.java) val req = NetworkRequest.Builder() .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .build() netCb = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { runOnUiThread { if (::openingScreenHandler.isInitialized && !isInQuestionnaire) { openingScreenHandler.refreshHeaderStatusLive() } } } override fun onLost(network: Network) { runOnUiThread { if (::openingScreenHandler.isInitialized && !isInQuestionnaire) { openingScreenHandler.refreshHeaderStatusLive() } } } override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) { runOnUiThread { if (::openingScreenHandler.isInitialized && !isInQuestionnaire) { openingScreenHandler.refreshHeaderStatusLive() } } } } cm.registerNetworkCallback(req, netCb!!) } private fun unregisterNetworkCallback() { val cb = netCb ?: return val cm = getSystemService(ConnectivityManager::class.java) cm.unregisterNetworkCallback(cb) netCb = null } // --- /LIVE NETZSTATUS --- override fun onContentChanged() { super.onContentChanged() SystemBarInsets.applyToContentRoot(this, ensureMinContentPadding = true) } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) } fun startQuestionnaire(questionnaire: QuestionnaireBase<*>, languageID: String) { isInQuestionnaire = true isFirstQuestionnairePage = true questionnaire.attach(this, languageID) questionnaire.startQuestionnaire() } fun finishQuestionnaire() { isInQuestionnaire = false isFirstQuestionnairePage = false ViewUtils.setTextSizeScale(1f) LanguageManager.clearQuestionnaireTranslations() if (::openingScreenHandler.isInitialized) { openingScreenHandler.init() } } }