add giga wear auth ui

This commit is contained in:
2026-09-14 19:25:24 +03:00
parent 53453c2688
commit fce29831a2
16 changed files with 383 additions and 15 deletions
@@ -12,6 +12,7 @@ import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.gigawear.backend.AppDatabase import ru.shadowsparky.gigawear.backend.AppDatabase
import ru.shadowsparky.gigawear.common.AuthRepository import ru.shadowsparky.gigawear.common.AuthRepository
import ru.shadowsparky.gigawear.common.LoginRequest import ru.shadowsparky.gigawear.common.LoginRequest
import ru.shadowsparky.gigawear.common.UpdateTokenRequest
import ru.shadowsparky.http.domain.BadRequestException import ru.shadowsparky.http.domain.BadRequestException
import ru.shadowsparky.http.domain.TokenInfo import ru.shadowsparky.http.domain.TokenInfo
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -31,16 +32,14 @@ class BackendAuthRepositoryImpl(
private val tokenQueries = appDatabase.refresh_tokensQueries private val tokenQueries = appDatabase.refresh_tokensQueries
override suspend fun login(request: LoginRequest) = withContext(dispatcherProvider.io) { override suspend fun login(request: LoginRequest) = withContext(dispatcherProvider.io) {
val userCode = request.code val user = usersQueries.selectByCode(request.code).executeAsOneOrNull()
val user = usersQueries.selectByCode(userCode).executeAsOneOrNull()
?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER)) ?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
userVerifier.verify(user.user_id) userVerifier.verify(user.user_id)
create(user.user_id) create(user.user_id)
} }
override suspend fun update(oldTokenInfo: TokenInfo) = withContext(dispatcherProvider.io) { override suspend fun update(refreshToken: UpdateTokenRequest) = withContext(dispatcherProvider.io) {
checkNotNull(oldTokenInfo.refresh) val tokenHash = refreshToken.refreshToken.toTokenHash(SALT)
val tokenHash = oldTokenInfo.refresh!!.toTokenHash(SALT)
val savedToken = tokenQueries.selectByHash(tokenHash) val savedToken = tokenQueries.selectByHash(tokenHash)
.executeAsOneOrNull() .executeAsOneOrNull()
?: throw BadRequestException("Token not found") ?: throw BadRequestException("Token not found")
+2
View File
@@ -32,6 +32,8 @@ dependencies {
implementation(project(":feature:chat:chat-common")) implementation(project(":feature:chat:chat-common"))
implementation(project(":feature:updater:updater-common")) implementation(project(":feature:updater:updater-common"))
implementation(project(":feature:updater:updater-client")) implementation(project(":feature:updater:updater-client"))
implementation(libs.androidx.wear.input)
implementation(libs.multiplatform.settings)
implementation(libs.activity.compose) implementation(libs.activity.compose)
implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.foundation)
@@ -0,0 +1,29 @@
package ru.shadowsparky.gigawear.data
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import org.koin.core.annotation.Factory
import ru.shadowsparky.gigawear.common.AuthRepository
import ru.shadowsparky.gigawear.common.LoginRequest
import ru.shadowsparky.gigawear.common.UpdateTokenRequest
import ru.shadowsparky.http.domain.TokenInfo
@Factory
class AuthRepositoryImpl(
private val httpClient: HttpClient
) : AuthRepository {
override suspend fun login(request: LoginRequest): TokenInfo {
return httpClient.post("$ENDPOINT/${AuthRepository.LOGIN_PATH}") {
setBody(request)
}.body()
}
override suspend fun update(refreshToken: UpdateTokenRequest): TokenInfo {
return httpClient.post("$ENDPOINT/${AuthRepository.UPDATE_TOKEN_PATH}") {
setBody(refreshToken)
}.body()
}
}
@@ -0,0 +1,19 @@
package ru.shadowsparky.gigawear.data
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import org.koin.core.annotation.Factory
import ru.shadowsparky.gigawear.common.AuthRepository
import ru.shadowsparky.http.domain.RefreshTokenAction
import ru.shadowsparky.http.domain.TokenInfo
@Factory
class RefreshTokenActionImpl : RefreshTokenAction {
override suspend fun refresh(client: HttpClient, refreshToken: String): TokenInfo {
return client.post("$ENDPOINT/${AuthRepository.UPDATE_TOKEN_PATH}") {
setBody(refreshToken)
}.body()
}
}
@@ -0,0 +1,32 @@
package ru.shadowsparky.gigawear.data
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Single
import ru.shadowsparky.domain.KeyValueStorage
import ru.shadowsparky.http.domain.TokenInfo
import ru.shadowsparky.http.domain.TokenStorage
@Single
class TokenStorageImpl(
private val keyValueStorage: KeyValueStorage,
private val json: Json
) : TokenStorage {
override val realm: String = "wear"
override val token: Flow<TokenInfo?> = keyValueStorage.get(KEY).map {
it?.let { json.decodeFromString(it) }
}
override suspend fun updateToken(newToken: TokenInfo) {
keyValueStorage.put(KEY, json.encodeToString(newToken))
}
override suspend fun clear() {
keyValueStorage.remove(KEY)
}
private companion object {
const val KEY = "token"
}
}
@@ -10,7 +10,7 @@ import ru.shadowsparky.ui.nav.RootComponent
class GigaWearRootComponent( class GigaWearRootComponent(
context: ComponentContext context: ComponentContext
) : RootComponent<Route>(context) { ) : RootComponent<Route>(context) {
override val initStack: List<Route> = listOf(Route.ChatScreen()) override val initStack: List<Route> = listOf(Route.AuthScreen())
override val serializer: KSerializer<Route> = Route.serializer() override val serializer: KSerializer<Route> = Route.serializer()
override val koin: Koin = ru.shadowsparky.koin override val koin: Koin = ru.shadowsparky.koin
} }
@@ -5,6 +5,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import com.arkivanov.decompose.retainedComponent import com.arkivanov.decompose.retainedComponent
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import ru.shadowsparky.gigawear.presentation.auth.AuthScreen
import ru.shadowsparky.gigawear.presentation.chat.ChatScreen import ru.shadowsparky.gigawear.presentation.chat.ChatScreen
import ru.shadowsparky.gigawear.presentation.nav.Child import ru.shadowsparky.gigawear.presentation.nav.Child
import ru.shadowsparky.ui.nav.Nav import ru.shadowsparky.ui.nav.Nav
@@ -19,6 +20,7 @@ class MainActivity : ComponentActivity() {
Nav(root) { child, _ -> Nav(root) { child, _ ->
when (child) { when (child) {
is Child.MainChild -> ChatScreen(child.component) is Child.MainChild -> ChatScreen(child.component)
is Child.AuthChild -> AuthScreen(child.component)
} }
} }
} }
@@ -0,0 +1,75 @@
package ru.shadowsparky.gigawear.presentation.auth
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.essenty.lifecycle.coroutines.coroutineScope
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.gigawear.common.AuthRepository
import ru.shadowsparky.gigawear.common.LoginRequest
import ru.shadowsparky.gigawear.presentation.nav.Child
import ru.shadowsparky.gigawear.presentation.nav.Route
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator
class AuthComponent(
private val context: ComponentContext,
private val tokenStorage: TokenStorage,
private val authRepository: AuthRepository,
private val goToChat: () -> Unit
) : ComponentContext by context {
private val scope = coroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val _state = MutableStateFlow(AuthState())
val state = _state.asStateFlow()
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
_state.value = AuthState(isLoading = false, error = throwable)
}
init {
scope.launch(exceptionHandler) {
if (tokenStorage.token.firstOrNull() != null) goToChat()
}
}
fun login(code: Int) {
scope.launch(exceptionHandler) {
_state.value = AuthState(isLoading = true, error = null)
val token = authRepository.login(LoginRequest(code))
tokenStorage.updateToken(token)
goToChat()
_state.value = AuthState(isLoading = false, error = null)
}
}
}
data class AuthState(
val isLoading: Boolean = false,
val error: Throwable? = null
)
@Factory
@Named(Route.AUTH)
class AuthComponentFactory(
private val tokenStorage: TokenStorage,
private val authRepository: AuthRepository,
) : ComponentFactory<Child.AuthChild, Route.AuthScreen, Route> {
override fun create(
route: Route.AuthScreen,
child: ComponentContext,
root: RouteNavigator<Route>
): Child.AuthChild {
val component = AuthComponent(
child,
tokenStorage,
authRepository
) { root.nav(Route.ChatScreen()) }
return Child.AuthChild(component)
}
}
@@ -0,0 +1,185 @@
package ru.shadowsparky.gigawear.presentation.auth
import android.app.RemoteInput
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.material3.AppScaffold
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.CircularProgressIndicator
import androidx.wear.input.RemoteInputIntentHelper
import ru.shadowsparky.gigawear.R
import androidx.wear.compose.material3.Text as WearText
private const val CODE_LENGTH = 6
private const val CODE_INPUT_KEY = "code_input"
@Composable
fun AuthScreen(component: AuthComponent) {
val state by component.state.collectAsState()
AuthScreen(
state = state,
onLogin = { code -> component.login(code) }
)
}
@Composable
private fun AuthScreen(
state: AuthState,
onLogin: (code: Int) -> Unit,
modifier: Modifier = Modifier
) {
var code by rememberSaveable { mutableStateOf("") }
AppScaffold(modifier = modifier) {
CodeStep(
code = code,
onCodeChange = { code = it },
onSubmit = {
code.toIntOrNull()?.let { onLogin(it) }
},
isLoading = state.isLoading,
error = state.error
)
}
}
@Composable
private fun CodeStep(
code: String,
onCodeChange: (String) -> Unit,
onSubmit: () -> Unit,
isLoading: Boolean,
error: Throwable?
) {
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val text = result.data
?.let { RemoteInput.getResultsFromIntent(it) }
?.getCharSequence(CODE_INPUT_KEY)
?.toString()
?: return@rememberLauncherForActivityResult
val clean = text.filter(Char::isDigit).take(CODE_LENGTH)
onCodeChange(clean)
if (clean.length == CODE_LENGTH) {
onSubmit()
}
}
val openCodeInput = {
val intent = RemoteInputIntentHelper.createActionRemoteInputIntent()
val remoteInputs = listOf(
RemoteInput.Builder(CODE_INPUT_KEY)
.setLabel("Код")
.build()
)
RemoteInputIntentHelper.putRemoteInputsExtra(intent, remoteInputs)
launcher.launch(intent)
}
ScalingLazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
item {
WearText(
text = stringResource(R.string.auth_code_title),
style = MaterialTheme.typography.bodyMedium
)
}
item {
CodeDots(
code = code,
onClick = openCodeInput
)
}
item {
WearText(
text = stringResource(
R.string.auth_code_counter,
code.length,
CODE_LENGTH
),
style = MaterialTheme.typography.labelMedium
)
}
item {
Button(
onClick = onSubmit,
enabled = code.length == CODE_LENGTH && !isLoading
) {
if (isLoading) {
Box(contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
} else {
WearText(stringResource(R.string.auth_submit))
}
}
}
if (error != null) {
item {
WearText(
text = error.message
?: stringResource(R.string.auth_error_unknown),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
@Composable
private fun CodeDots(
code: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
repeat(CODE_LENGTH) { index ->
val filled = index < code.length
Box(
modifier = Modifier
.padding(horizontal = 4.dp)
.size(20.dp)
.clip(CircleShape)
.background(
if (filled) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant
)
.clickable(onClick = onClick)
)
}
}
}
@@ -34,9 +34,7 @@ class ChatComponent(
private val _events = Channel<ComponentEvent>(Channel.BUFFERED) private val _events = Channel<ComponentEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow() val events = _events.receiveAsFlow()
init { init { updateComponent.checkUpdates() }
updateComponent.checkUpdates(true)
}
fun onMicClicked() { fun onMicClicked() {
if (_state.value.isLoading) return if (_state.value.isLoading) return
@@ -1,7 +1,9 @@
package ru.shadowsparky.gigawear.presentation.nav package ru.shadowsparky.gigawear.presentation.nav
import ru.shadowsparky.gigawear.presentation.auth.AuthComponent
import ru.shadowsparky.gigawear.presentation.chat.ChatComponent import ru.shadowsparky.gigawear.presentation.chat.ChatComponent
sealed class Child : ru.shadowsparky.ui.nav.Child { sealed class Child : ru.shadowsparky.ui.nav.Child {
class MainChild(val component: ChatComponent) : Child() class MainChild(val component: ChatComponent) : Child()
class AuthChild(val component: AuthComponent) : Child()
} }
@@ -8,7 +8,11 @@ sealed class Route : ru.shadowsparky.ui.nav.Route() {
@Serializable @Serializable
data class ChatScreen(override val routeId: String = CHAT) : Route() data class ChatScreen(override val routeId: String = CHAT) : Route()
@Serializable
data class AuthScreen(override val routeId: String = AUTH) : Route()
companion object { companion object {
const val CHAT = "chat" const val CHAT = "chat"
const val AUTH = "auth"
} }
} }
@@ -2,4 +2,17 @@
<string name="app_name">Свин Wear</string> <string name="app_name">Свин Wear</string>
<string name="input_text_to_assist">Введите запрос к ассистенту</string> <string name="input_text_to_assist">Введите запрос к ассистенту</string>
<string name="error_title">Произошла ошибка</string> <string name="error_title">Произошла ошибка</string>
<!-- Экран входа: шаг логина -->
<string name="auth_title">Вход</string>
<string name="auth_login_label">Логин</string>
<string name="auth_next">Далее</string>
<!-- Экран входа: шаг кода -->
<string name="auth_code_title">Код</string>
<string name="auth_code_label">%1$d цифр</string>
<string name="auth_code_counter">%1$d / %2$d</string>
<string name="auth_submit">Войти</string>
<string name="auth_back">Назад</string>
<string name="auth_error_unknown">Ошибка входа</string>
</resources> </resources>
@@ -5,7 +5,7 @@ import ru.shadowsparky.http.domain.TokenInfo
interface AuthRepository { interface AuthRepository {
suspend fun login(request: LoginRequest): TokenInfo suspend fun login(request: LoginRequest): TokenInfo
suspend fun update(oldTokenInfo: TokenInfo): TokenInfo suspend fun update(refreshToken: UpdateTokenRequest): TokenInfo
companion object { companion object {
const val LOGIN_PATH = "login" const val LOGIN_PATH = "login"
@@ -14,7 +14,9 @@ interface AuthRepository {
} }
@Serializable @Serializable
data class LoginRequest( class UpdateTokenRequest(
val login: String, val refreshToken: String
val code: Int
) )
@Serializable
data class LoginRequest(val code: Int)
+2
View File
@@ -69,6 +69,7 @@ guava = "33.2.1-android"
tilesTooling = "1.5.0" tilesTooling = "1.5.0"
tilesToolingPreview = "1.5.0" tilesToolingPreview = "1.5.0"
watchfaceComplicationsDataSourceKtx = "1.2.1" watchfaceComplicationsDataSourceKtx = "1.2.1"
wearInput = "1.2.0"
[libraries] [libraries]
adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "adaptiveLayout" } adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "adaptiveLayout" }
@@ -189,6 +190,7 @@ guava = { group = "com.google.guava", name = "guava", version.ref = "guava" }
androidx-tiles-tooling = { group = "androidx.wear.tiles", name = "tiles-tooling", version.ref = "tilesTooling" } androidx-tiles-tooling = { group = "androidx.wear.tiles", name = "tiles-tooling", version.ref = "tilesTooling" }
androidx-tiles-tooling-preview = { group = "androidx.wear.tiles", name = "tiles-tooling-preview", version.ref = "tilesToolingPreview" } androidx-tiles-tooling-preview = { group = "androidx.wear.tiles", name = "tiles-tooling-preview", version.ref = "tilesToolingPreview" }
androidx-watchface-complications-data-source-ktx = { group = "androidx.wear.watchface", name = "watchface-complications-data-source-ktx", version.ref = "watchfaceComplicationsDataSourceKtx" } androidx-watchface-complications-data-source-ktx = { group = "androidx.wear.watchface", name = "watchface-complications-data-source-ktx", version.ref = "watchfaceComplicationsDataSourceKtx" }
androidx-wear-input = { group = "androidx.wear", name = "wear-input", version.ref = "wearInput" }
[plugins] [plugins]
jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "compose-version" } jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "compose-version" }
@@ -14,8 +14,12 @@ class JwtPreparer(
private val envFetcher: EnvFetcher private val envFetcher: EnvFetcher
) { ) {
private val tokenTimeInMinutes = envFetcher.get("JWT_DURATION", "30").toLong() private val tokenTimeInMinutes = envFetcher.get("JWT_DURATION", "30").toLong()
fun prepare(userId: Long): String {
val exp = Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(tokenTimeInMinutes)) fun prepare(
userId: Long,
expMillis: Long = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(tokenTimeInMinutes)
): String {
val exp = Date(expMillis)
val token = JWT.create() val token = JWT.create()
.withAudience(jwtInfo.audience) .withAudience(jwtInfo.audience)
.withIssuer(jwtInfo.issuer) .withIssuer(jwtInfo.issuer)