507 lines
20 KiB
Kotlin
507 lines
20 KiB
Kotlin
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()
|
|
}
|
|
}
|
|
}
|