From 97c66e4a6a601813c5e5b075fc08987475d8e9fb Mon Sep 17 00:00:00 2001
From: JojoIV <jonas.broeckmann@gmx.de>
Date: Tue, 21 May 2024 14:32:45 +0200
Subject: [PATCH] Added support for persistent client sessions

---
 .../kaffeekasse/api/portal/PortalClient.kt    | 42 ++++++++++++++++---
 .../kaffeekasse/model/session/Session.kt      | 23 ++++++----
 .../kaffeekasse/repositories/Credentials.kt   |  9 ++++
 .../repositories/LoginRepository.kt           | 40 +++++++++++++-----
 .../kaffeekasse/repositories/Settings.kt      |  1 +
 .../repositories/i11/PortalRepository.kt      | 11 ++++-
 .../kaffeekasse/ui/kaffeekasse/ManualBill.kt  |  2 +-
 .../kaffeekasse/ui/login/UserSelection.kt     |  3 --
 8 files changed, 101 insertions(+), 30 deletions(-)

diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/portal/PortalClient.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/portal/PortalClient.kt
index 4922e8c..220c91e 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/api/portal/PortalClient.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/portal/PortalClient.kt
@@ -17,6 +17,8 @@ import io.ktor.http.Parameters
 import io.ktor.http.URLBuilder
 import io.ktor.http.Url
 import io.ktor.http.parameters
+import io.ktor.http.parseServerSetCookieHeader
+import io.ktor.http.renderSetCookieHeader
 import io.ktor.serialization.kotlinx.json.json
 import it.skrape.core.htmlDocument
 import it.skrape.selects.Doc
@@ -74,20 +76,46 @@ class PortalClient(
 
 
     private val portal = object : Module(this, "portal") {
-        val currentSession = MutableStateFlow(PortalAPIResponse.Session())
-        suspend fun updateSession(): Unit = withContext(computationScope) {
+        val currentSession = MutableStateFlow(Session())
+        suspend fun updateSession(restore: String? = null): Unit = withContext(computationScope) {
+            if (restore != null) {
+                debug { "Restoring cookie: $restore" }
+                try {
+                    val cookie = parseServerSetCookieHeader(restore)
+                    cookiesStorage.addCookie(baseUrl, cookie)
+                    debug { "Restored cookie: $cookie" }
+                } catch (e: Throwable) {
+                    error(e) { "Failed to restore cookie" }
+                }
+            }
+
             debug { "Updating session" }
             val response = requestAPI(updateSession = false).decodeAs<PortalAPIResponse>()
-            debug { "Session: ${response.session}" }
-            currentSession.value = response.session
+            val serializedCookie = cookiesStorage.get(baseUrl)
+                .firstOrNull { it.name == SessionCookieName }
+                ?.let { renderSetCookieHeader(it) }
+            debug { "Session: ${response.session}; Cookie: $serializedCookie" }
+
+            currentSession.value = Session(
+                response.session,
+                serializedCookie
+            )
         }
+        private val SessionCookieName = "PORTALSESSID"
     }
 
     val session get() = portal.currentSession.asStateFlow()
 
+    data class Session(
+        val response: PortalAPIResponse.Session = PortalAPIResponse.Session(),
+        val data: String? = null
+    )
+
+    suspend fun forceUpdateSession(restore: String? = null) = portal.updateSession(restore)
+
     suspend fun login(login: Login) {
         require(login.isValid) { "Invalid login" }
-        if (session.value.username == login.username) return
+        if (session.value.response.username == login.username) return
         debug { "Logging in as ${login.username}" }
         post(
             portal.moduleUrl,
@@ -98,7 +126,7 @@ class PortalClient(
             },
             updateSession = true
         )
-        debug { "Logged in: ${session.value.isLoggedIn}" }
+        debug { "Logged in: ${session.value.response.isLoggedIn}" }
     }
 
     suspend fun logoutAll() {
@@ -182,3 +210,5 @@ class PortalClient(
         protected val computationScope get() = client.computationScope
     }
 }
+
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/session/Session.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/session/Session.kt
index a44768c..4b2a28c 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/model/session/Session.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/session/Session.kt
@@ -2,7 +2,10 @@ package net.novagamestudios.kaffeekasse.model.session
 
 
 sealed interface Session : java.io.Serializable {
-    data object Empty : Session
+    val data: String?
+    data object Empty : Session {
+        override val data: String? get() = null
+    }
     interface WithDevice : Session {
         val device: Device
     }
@@ -14,13 +17,14 @@ sealed interface Session : java.io.Serializable {
     companion object {
         operator fun invoke(
             device: Device?,
-            user: User?
+            user: User?,
+            data: String? = null
         ): Session {
             val realUser = user?.takeIf { it.isRealUser }
             return when {
-                device != null && realUser != null -> SessionWithDeviceAndRealUser(device, realUser)
-                device != null -> SessionWithDevice(device)
-                realUser != null -> SessionWithRealUser(realUser)
+                device != null && realUser != null -> SessionWithDeviceAndRealUser(device, realUser, data)
+                device != null -> SessionWithDevice(device, data)
+                realUser != null -> SessionWithRealUser(realUser, data)
                 else -> Empty
             }
         }
@@ -37,14 +41,17 @@ private val User.isRealUser get() = !isKaffeekasse
 
 
 private data class SessionWithDevice(
-    override val device: Device
+    override val device: Device,
+    override val data: String?
 ) : Session.WithDevice
 
 private data class SessionWithRealUser(
-    override val realUser: User
+    override val realUser: User,
+    override val data: String?
 ) : Session.WithRealUser
 
 private data class SessionWithDeviceAndRealUser(
     override val device: Device,
-    override val realUser: User
+    override val realUser: User,
+    override val data: String?
 ) : Session.WithDeviceAndUser
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Credentials.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Credentials.kt
index 6784cff..44c2047 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Credentials.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Credentials.kt
@@ -57,6 +57,15 @@ class Credentials(
     }
 
 
+    suspend fun sessionData(): String? {
+        return settingsStore.value.savedSessionData
+    }
+
+    suspend fun storeSessionData(data: String?) {
+        settingsStore.update { it.copy(savedSessionData = data) }
+    }
+
+
     context (Context)
     suspend fun userLogin(): Login? {
         getDebugUserLogin()?.let { return it }
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/LoginRepository.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/LoginRepository.kt
index 1cc778e..421ae73 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/LoginRepository.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/LoginRepository.kt
@@ -51,6 +51,10 @@ class LoginRepository(
     val errors = _errors.asStateFlow()
 
 
+    suspend fun forceUpdateSession() = action {
+        portal.forceUpdateSession()
+    }
+
 
     val autoLogin by lazy { settings.values.mapState { it.autoLogin } }
     private var autoLoginAttemptAvailable = true
@@ -77,6 +81,14 @@ class LoginRepository(
 
         debug { "Auto login enabled: ${autoLogin.value}" }
         if (!autoLogin.value) return@action null
+
+        val sessionData = credentialsRepository.sessionData()
+        debug { "Auto login session data: $sessionData" }
+        if (sessionData != null) {
+            portal.forceUpdateSession(sessionData)
+            if (portal.session.value !is Session.Empty) return@action null
+        }
+
         val login = with(activityContext) {
             credentialsRepository.userLogin()
                 ?.takeIf { it.isValid }
@@ -88,12 +100,13 @@ class LoginRepository(
         autoLoginAttemptAvailable = false
         info { "Trying to auto login user: ${login.username}" }
         performUserLogin(login)
+        credentialsRepository.storeSessionData(portal.session.value.data)
         return@action login
     }
 
-    suspend fun login(login: Login, autoLogin: Boolean, activityContext: Context) {
+    suspend fun login(login: Login, autoLogin: Boolean, activityContext: Context) = action {
         val cleanedLogin = login.copy(username = login.username.trim())
-        if (!performUserLogin(cleanedLogin)) return
+        if (!performUserLogin(cleanedLogin)) return@action
         with(activityContext) {
             updateAutoLogin(cleanedLogin, autoLogin)
         }
@@ -103,15 +116,19 @@ class LoginRepository(
     context (Context)
     private suspend fun updateAutoLogin(login: Login, autoLogin: Boolean) {
         settings.update { it.copy(autoLogin = autoLogin) }
-        if (autoLogin) when (val result = credentialsRepository.storeUserLogin(login)) {
-            Credentials.StoreResult.Success -> { }
-            Credentials.StoreResult.Cancelled -> { }
-            Credentials.StoreResult.Unsupported -> {
-                toastShort("Credential storage not supported")
-                settings.tryUpdate { it.copy(autoLogin = false) }
-            }
-            is Credentials.StoreResult.Error -> {
-                toastShort("Failed to store credentials: ${result.message}")
+        if (autoLogin) {
+            credentialsRepository.storeSessionData(portal.session.value.data)
+            when (val result = credentialsRepository.storeUserLogin(login)) {
+                Credentials.StoreResult.Success -> { }
+                Credentials.StoreResult.Cancelled -> { }
+                Credentials.StoreResult.Unsupported -> {
+                    toastShort("Credential storage not supported")
+                    settings.tryUpdate { it.copy(autoLogin = false) }
+                }
+
+                is Credentials.StoreResult.Error -> {
+                    toastShort("Failed to store credentials: ${result.message}")
+                }
             }
         }
     }
@@ -149,6 +166,7 @@ class LoginRepository(
 
 
     suspend fun logoutUser() = action {
+        credentialsRepository.storeSessionData(null)
         portal.logoutUser()
     }
 
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Settings.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Settings.kt
index 6ca7957..c80b801 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Settings.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Settings.kt
@@ -25,6 +25,7 @@ data class Settings(
     val fullscreen: Boolean = false,
     val animationsEnabled: Boolean = true,
     val autoLogin: Boolean = false,
+    val savedSessionData: String? = null,
     val deviceCredentials: DeviceCredentials? = null,
     val developerMode: Boolean = false,
     val userSettings: Map<String, UserSettings> = emptyMap()
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/PortalRepository.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/PortalRepository.kt
index b217d1c..48d3c7a 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/PortalRepository.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/PortalRepository.kt
@@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.stateIn
 import net.novagamestudios.common_utils.Logger
+import net.novagamestudios.common_utils.debug
 import net.novagamestudios.common_utils.info
 import net.novagamestudios.common_utils.warn
 import net.novagamestudios.kaffeekasse.api.kaffeekasse.KaffeekasseAPI
@@ -31,12 +32,20 @@ class PortalRepository(
         client.session,
         kaffeekasse.currentDevice
     ) { session, device ->
+        val response = session.response
         Session(
             device = device,
-            user = if (session.isLoggedIn) User(session.username, session.displayName) else null
+            user = if (response.isLoggedIn) User(response.username, response.displayName) else null,
+            data = session.data
         )
     }.stateIn(coroutineScope, SharingStarted.Eagerly, Session.Empty)
 
+    suspend fun forceUpdateSession(restore: String? = null) {
+        info { "Updating session" }
+        client.forceUpdateSession(restore)
+        debug { "Session updated" }
+    }
+
     suspend fun loginUser(login: Login) {
         if (session.value is Session.WithRealUser) throw IllegalStateException("User already logged in")
         client.login(login)
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/ManualBill.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/ManualBill.kt
index 1fdd587..1d121a2 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/ManualBill.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/ManualBill.kt
@@ -145,7 +145,7 @@ class ManualBillScreenModel private constructor(
         get() = currentPagerIndex - 1
         set(value) { currentPagerIndex = value + 1 }
 
-    @Suppress("unused")
+    @Suppress("unused", "RedundantSuspendModifier")
     suspend fun autoScrollToDeviceItemGroup() {
         val initialItemGroupIndex = session.deviceOrNull?.itemTypeId
             ?.let { id -> itemGroups.indexOfFirst { it.id == id }.takeIf { it >= 0 } }
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/UserSelection.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/UserSelection.kt
index 208444f..00ced60 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/UserSelection.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/UserSelection.kt
@@ -102,7 +102,6 @@ import net.novagamestudios.kaffeekasse.ui.util.navigation.BackNavigationHandler
 import net.novagamestudios.kaffeekasse.ui.util.rememberPullToRefreshState
 import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
 import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelProvider
-import net.novagamestudios.kaffeekasse.ui.util.screenmodel.collectAsStateHere
 import net.novagamestudios.kaffeekasse.util.richdata.asRichDataFlow
 import net.novagamestudios.kaffeekasse.util.richdata.combineRich
 import net.novagamestudios.kaffeekasse.util.richdata.stateIn
@@ -114,8 +113,6 @@ class UserSelectionScreenModel private constructor(
     val loginRepository: LoginRepository,
     kaffeekasse: KaffeekasseRepository
 ) : ScreenModel, Logger {
-    val isPerformingLoginAction by loginRepository.isPerformingAction.collectAsStateHere()
-
     val users = kaffeekasse.basicUserInfoList
 
     val searchState = TopBarSearchFieldState()
-- 
GitLab