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.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.network.PortalApi
import ru.sweetbread.unn.core.network.createHttpClient
@@ -27,6 +29,7 @@ class UNNApp : Application() {
}
class AppContainer(context: Context) {
val credentialsStore = CredentialsStore(context)
val sessionManager = SessionManager()
val httpClient = createHttpClient()
@@ -36,6 +39,8 @@ class AppContainer(context: Context) {
session = sessionManager
)
val authRepo = AuthRepository(credentialsStore, portalApi)
val database = roomDb<AppDatabase>(name = "cache") {
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
private set
var csrf: String? = null
private set
var uns: Int? = null
private set
fun update(
user: User,
sessionId: String,
csrf: String,
uns: Int
) {
this._user.value = user
this.sessionId = sessionId
this.csrf = csrf
this.uns = uns
}
fun clear() {
_user.value = null
sessionId = null
csrf = null
uns = null
}
}
@@ -3,8 +3,8 @@
package ru.sweetbread.unn.core.network
import io.ktor.client.HttpClient
import kotlinx.io.IOException
import ru.sweetbread.unn.core.SessionManager
import ru.sweetbread.unn.ui.layout.LoginData
class PortalApi (
@@ -18,25 +18,27 @@ class PortalApi (
/**
* Authorize user by [login] and [password]
*/
suspend fun auth(
login: String = LoginData.login,
password: String = LoginData.password,
forced: Boolean = false
): Boolean {
if (!forced and (session.sessionId != null))
return true
suspend fun login(
login: String,
password: String
): LoginResult {
return try {
val id = auth.getToken(login, password)
val id = this.auth.getToken(login, password)
if (id != null) {
if (id == null) {
LoginResult.InvalidCredentials
} else {
session.update(
bitrix.getCurrentProfile(),
id,
this.auth.getCSRF(),
login.drop(1).toInt()
user = bitrix.getCurrentProfile(),
sessionId = id,
uns = login.drop(1).toInt()
)
return true
LoginResult.Success
}
} catch (_: IOException) {
LoginResult.NetworkError
}
return false
}
// /**
@@ -93,3 +95,9 @@ class PortalApi (
// 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
import io.ktor.client.HttpClient
import io.ktor.client.plugins.retry
import io.ktor.client.request.forms.submitForm
import io.ktor.client.request.get
import io.ktor.client.request.parameter
@@ -49,7 +50,7 @@ class Auth (
append("USER_LOGIN", login)
append("USER_PASSWORD", password)
}
)
) { retry { noRetry() } }
if (r.status.value == 302)
return """PHPSESSID=([\w\d]+)""".toRegex().find(r.headers["Set-Cookie"]!!)!!.groupValues[1]
@@ -36,31 +36,25 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import ru.sweetbread.unn.R
import ru.sweetbread.unn.UNNApp
import ru.sweetbread.unn.core.AuthRepository
import ru.sweetbread.unn.ui.theme.UNNTheme
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() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val api = (application as UNNApp).container.portalApi
val authRepo = (application as UNNApp).container.authRepo
if ((LoginData.login != "") and (LoginData.password != ""))
runBlocking {
if (api.auth()) {
if (authRepo.restoreSession()) {
start<MainActivity>()
finish()
}
@@ -80,12 +74,13 @@ class LoginActivity : ComponentActivity() {
SnackbarHost(hostState = snackbarHostState)
}
) { innerPadding ->
Box(Modifier.padding(innerPadding).fillMaxSize(), Alignment.Center) {
Box(Modifier
.padding(innerPadding)
.fillMaxSize(), Alignment.Center) {
LoginPanel(
Modifier.imePadding(),
{ login, password ->
LoginData.login = login
LoginData.password = password
authRepo,
{
start<MainActivity>()
finish()
},
@@ -110,10 +105,10 @@ class LoginActivity : ComponentActivity() {
@Composable
fun LoginPanel(
modifier: Modifier = Modifier,
ok: (login: String, password: String) -> Unit,
authRepo: AuthRepository,
ok: () -> Unit,
error: () -> Unit
) {
val api = (appCtx as UNNApp).container.portalApi
var login by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var loading by remember { mutableStateOf(false) }
@@ -152,8 +147,8 @@ fun LoginPanel(
onClick = {
loading = true
scope.launch {
if (api.auth(login, password))
ok(login, password)
if (authRepo.login(login, password))
ok()
else
error()
loading = false
@@ -169,10 +164,10 @@ fun LoginPanel(
}
}
@Preview(showBackground = true)
@Composable
fun LoginPreview() {
UNNTheme {
LoginPanel(ok = { _, _ -> }, error = {})
}
}
//@Preview(showBackground = true)
//@Composable
//fun LoginPreview() {
// UNNTheme {
// LoginPanel(ok = { _, _ -> }, error = {})
// }
//}