feat: offline-first
This commit is contained in:
@@ -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 = {})
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
Reference in New Issue
Block a user