feat: offline-first

This commit is contained in:
2026-09-07 19:39:45 +03:00
parent 008ec3030b
commit d5194c2e6f
7 changed files with 247 additions and 54 deletions
@@ -4,6 +4,8 @@ package ru.sweetbread.unn
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import ru.sweetbread.unn.core.AuthRepository
import ru.sweetbread.unn.core.CredentialsStore
import ru.sweetbread.unn.core.SessionManager import ru.sweetbread.unn.core.SessionManager
import ru.sweetbread.unn.core.network.PortalApi import ru.sweetbread.unn.core.network.PortalApi
import ru.sweetbread.unn.core.network.createHttpClient import ru.sweetbread.unn.core.network.createHttpClient
@@ -27,6 +29,7 @@ class UNNApp : Application() {
} }
class AppContainer(context: Context) { class AppContainer(context: Context) {
val credentialsStore = CredentialsStore(context)
val sessionManager = SessionManager() val sessionManager = SessionManager()
val httpClient = createHttpClient() val httpClient = createHttpClient()
@@ -36,6 +39,8 @@ class AppContainer(context: Context) {
session = sessionManager session = sessionManager
) )
val authRepo = AuthRepository(credentialsStore, portalApi)
val database = roomDb<AppDatabase>(name = "cache") { val database = roomDb<AppDatabase>(name = "cache") {
fallbackToDestructiveMigration(dropAllTables = true) fallbackToDestructiveMigration(dropAllTables = true)
} }
@@ -0,0 +1,46 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.core
import android.util.Log
import kotlinx.io.IOException
import ru.sweetbread.unn.core.network.LoginResult
import ru.sweetbread.unn.core.network.PortalApi
class AuthRepository(
private val credentialsStore: CredentialsStore,
private val api: PortalApi
) {
suspend fun login(
login: String,
password: String
): Boolean {
val res = api.login(login, password) == LoginResult.Success
if (res) {
credentialsStore.save(
Credentials(login, password)
)
}
return res
}
suspend fun restoreSession(): Boolean {
val credentials =
credentialsStore.get()
?: return false
return try {
val res = api.login(
credentials.login,
credentials.password
)
res != LoginResult.InvalidCredentials
} catch (e: IOException) {
Log.e("AuthRepo", "restoreSession error", e)
false
}
}
}
@@ -0,0 +1,144 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.core
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import androidx.core.content.edit
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
data class Credentials(
val login: String,
val password: String
)
class CredentialsStore(
context: Context
) {
private val prefs = context.getSharedPreferences(
"credentials",
Context.MODE_PRIVATE
)
private val keyAlias = "credentials_key"
private val key: SecretKey
get() {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply {
load(null)
}
val existingKey = keyStore.getKey(
keyAlias,
null
) as? SecretKey
if (existingKey != null)
return existingKey
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore"
)
keyGenerator.init(
KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or
KeyProperties.PURPOSE_DECRYPT
).setBlockModes(
KeyProperties.BLOCK_MODE_GCM
).setEncryptionPaddings(
KeyProperties.ENCRYPTION_PADDING_NONE
).setKeySize(256)
.build()
)
return keyGenerator.generateKey()
}
fun save(credentials: Credentials) {
val plainText =
"${credentials.login}\u0000${credentials.password}"
.encodeToByteArray()
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.ENCRYPT_MODE,
key
)
val encrypted = cipher.doFinal(plainText)
prefs.edit {
putString(
"data",
Base64.encodeToString(
encrypted,
Base64.NO_WRAP
)
)
putString(
"iv",
Base64.encodeToString(
cipher.iv,
Base64.NO_WRAP
)
)
}
}
fun get(): Credentials? {
val encryptedString =
prefs.getString("data", null)
?: return null
val ivString =
prefs.getString("iv", null)
?: return null
val encrypted = Base64.decode(
encryptedString,
Base64.NO_WRAP
)
val iv = Base64.decode(
ivString,
Base64.NO_WRAP
)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE,
key,
GCMParameterSpec(128, iv)
)
val plainText = cipher
.doFinal(encrypted)
.decodeToString()
val parts = plainText.split('\u0000', limit = 2)
if (parts.size != 2)
return null
return Credentials(
login = parts[0],
password = parts[1]
)
}
fun clear() {
prefs.edit { clear() }
}
}
@@ -13,28 +13,22 @@ class SessionManager {
var sessionId: String? = null var sessionId: String? = null
private set private set
var csrf: String? = null
private set
var uns: Int? = null var uns: Int? = null
private set private set
fun update( fun update(
user: User, user: User,
sessionId: String, sessionId: String,
csrf: String,
uns: Int uns: Int
) { ) {
this._user.value = user this._user.value = user
this.sessionId = sessionId this.sessionId = sessionId
this.csrf = csrf
this.uns = uns this.uns = uns
} }
fun clear() { fun clear() {
_user.value = null _user.value = null
sessionId = null sessionId = null
csrf = null
uns = null uns = null
} }
} }
@@ -3,8 +3,8 @@
package ru.sweetbread.unn.core.network package ru.sweetbread.unn.core.network
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import kotlinx.io.IOException
import ru.sweetbread.unn.core.SessionManager import ru.sweetbread.unn.core.SessionManager
import ru.sweetbread.unn.ui.layout.LoginData
class PortalApi ( class PortalApi (
@@ -18,25 +18,27 @@ class PortalApi (
/** /**
* Authorize user by [login] and [password] * Authorize user by [login] and [password]
*/ */
suspend fun auth( suspend fun login(
login: String = LoginData.login, login: String,
password: String = LoginData.password, password: String
forced: Boolean = false ): LoginResult {
): Boolean { return try {
if (!forced and (session.sessionId != null)) val id = auth.getToken(login, password)
return true
val id = this.auth.getToken(login, password) if (id == null) {
if (id != null) { LoginResult.InvalidCredentials
session.update( } else {
bitrix.getCurrentProfile(), session.update(
id, user = bitrix.getCurrentProfile(),
this.auth.getCSRF(), sessionId = id,
login.drop(1).toInt() uns = login.drop(1).toInt()
) )
return true
LoginResult.Success
}
} catch (_: IOException) {
LoginResult.NetworkError
} }
return false
} }
// /** // /**
@@ -92,4 +94,10 @@ class PortalApi (
// //
// return schedule // return schedule
// } // }
}
sealed interface LoginResult {
data object Success : LoginResult
data object InvalidCredentials : LoginResult
data object NetworkError : LoginResult
} }
@@ -3,6 +3,7 @@
package ru.sweetbread.unn.core.network package ru.sweetbread.unn.core.network
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.plugins.retry
import io.ktor.client.request.forms.submitForm import io.ktor.client.request.forms.submitForm
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
@@ -49,7 +50,7 @@ class Auth (
append("USER_LOGIN", login) append("USER_LOGIN", login)
append("USER_PASSWORD", password) append("USER_PASSWORD", password)
} }
) ) { retry { noRetry() } }
if (r.status.value == 302) if (r.status.value == 302)
return """PHPSESSID=([\w\d]+)""".toRegex().find(r.headers["Set-Cookie"]!!)!!.groupValues[1] return """PHPSESSID=([\w\d]+)""".toRegex().find(r.headers["Set-Cookie"]!!)!!.groupValues[1]
@@ -36,35 +36,29 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import ru.sweetbread.unn.R import ru.sweetbread.unn.R
import ru.sweetbread.unn.UNNApp import ru.sweetbread.unn.UNNApp
import ru.sweetbread.unn.core.AuthRepository
import ru.sweetbread.unn.ui.theme.UNNTheme import ru.sweetbread.unn.ui.theme.UNNTheme
import splitties.activities.start import splitties.activities.start
import splitties.init.appCtx
import splitties.preferences.Preferences
object LoginData : Preferences("loginData") {
var login by stringPref("login", "")
var password by stringPref("password", "")
}
class LoginActivity : ComponentActivity() { class LoginActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val api = (application as UNNApp).container.portalApi val authRepo = (application as UNNApp).container.authRepo
if ((LoginData.login != "") and (LoginData.password != ""))
runBlocking { runBlocking {
if (api.auth()) { if (authRepo.restoreSession()) {
start<MainActivity>() start<MainActivity>()
finish() finish()
}
} }
}
setContent { setContent {
UNNTheme { UNNTheme {
@@ -80,12 +74,13 @@ class LoginActivity : ComponentActivity() {
SnackbarHost(hostState = snackbarHostState) SnackbarHost(hostState = snackbarHostState)
} }
) { innerPadding -> ) { innerPadding ->
Box(Modifier.padding(innerPadding).fillMaxSize(), Alignment.Center) { Box(Modifier
.padding(innerPadding)
.fillMaxSize(), Alignment.Center) {
LoginPanel( LoginPanel(
Modifier.imePadding(), Modifier.imePadding(),
{ login, password -> authRepo,
LoginData.login = login {
LoginData.password = password
start<MainActivity>() start<MainActivity>()
finish() finish()
}, },
@@ -110,10 +105,10 @@ class LoginActivity : ComponentActivity() {
@Composable @Composable
fun LoginPanel( fun LoginPanel(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
ok: (login: String, password: String) -> Unit, authRepo: AuthRepository,
ok: () -> Unit,
error: () -> Unit error: () -> Unit
) { ) {
val api = (appCtx as UNNApp).container.portalApi
var login by rememberSaveable { mutableStateOf("") } var login by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") }
var loading by remember { mutableStateOf(false) } var loading by remember { mutableStateOf(false) }
@@ -152,8 +147,8 @@ fun LoginPanel(
onClick = { onClick = {
loading = true loading = true
scope.launch { scope.launch {
if (api.auth(login, password)) if (authRepo.login(login, password))
ok(login, password) ok()
else else
error() error()
loading = false loading = false
@@ -169,10 +164,10 @@ fun LoginPanel(
} }
} }
@Preview(showBackground = true) //@Preview(showBackground = true)
@Composable //@Composable
fun LoginPreview() { //fun LoginPreview() {
UNNTheme { // UNNTheme {
LoginPanel(ok = { _, _ -> }, error = {}) // LoginPanel(ok = { _, _ -> }, error = {})
} // }
} //}