Popraw OTP, kafelki i aparat

This commit is contained in:
admin
2026-06-30 20:52:09 +02:00
parent f38123a1ae
commit d4f867bf14
6 changed files with 147 additions and 13 deletions
+4 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
versionCode = 2
versionName = "1.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"")
@@ -71,6 +71,8 @@ dependencies {
implementation(libs.coroutines.android)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.play.services.auth)
implementation(libs.play.services.auth.api.phone)
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
@@ -0,0 +1,8 @@
package pl.firmatpp.kierowca.domain
object OtpCodeExtractor {
private val codePattern = Regex("""(?<!\d)\d{4,10}(?!\d)""")
fun extract(message: String): String? =
codePattern.find(message)?.value
}
@@ -1,8 +1,14 @@
package pl.firmatpp.kierowca.ui
import android.Manifest
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.ManagedActivityResultLauncher
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
@@ -12,15 +18,18 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -50,7 +59,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -66,23 +75,32 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.google.android.gms.auth.api.phone.SmsRetriever
import com.google.android.gms.common.api.CommonStatusCodes
import com.google.android.gms.common.api.Status
import java.io.File
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.domain.OtpCodeExtractor
import pl.firmatpp.kierowca.domain.RouteDisplayMapper
import pl.firmatpp.kierowca.ui.theme.TppColors
@Composable
fun DriverApp(viewModel: DriverViewModel) {
val state by viewModel.state.collectAsState()
SmsUserConsentEffect(
enabled = state.screen == DriverScreen.Phone || state.screen == DriverScreen.Otp,
onCode = viewModel::updateOtpCode,
)
Box(Modifier.fillMaxSize().background(TppColors.Surface)) {
when (state.screen) {
DriverScreen.Phone -> PhoneScreen(state, viewModel::requestOtp)
DriverScreen.Otp -> OtpScreen(state, viewModel::verifyOtp, viewModel::back)
DriverScreen.Otp -> OtpScreen(state, viewModel::updateOtpCode, viewModel::verifyOtp, viewModel::back)
DriverScreen.Routes -> RoutesScreen(state, viewModel::refreshRoutes, viewModel::openRoute, viewModel::logout)
DriverScreen.Detail -> DetailScreen(state, viewModel::back, viewModel::uploadPhoto, viewModel::openPhoto)
DriverScreen.Photo -> PhotoScreen(state, viewModel::back)
@@ -110,24 +128,30 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
singleLine = true,
)
ErrorText(state.error)
Spacer(Modifier.height(if (state.error.isNullOrBlank()) 18.dp else 8.dp))
PrimaryButton("Wyslij kod") { onSubmit(phone) }
}
}
@Composable
private fun OtpScreen(state: DriverUiState, onSubmit: (String) -> Unit, onBack: () -> Unit) {
var code by remember { mutableStateOf("") }
private fun OtpScreen(
state: DriverUiState,
onCodeChange: (String) -> Unit,
onSubmit: (String) -> Unit,
onBack: () -> Unit,
) {
AuthShell(title = "Kod SMS", subtitle = "Wpisz kod wyslany na ${state.maskedPhone}.", onBack = onBack) {
OutlinedTextField(
value = code,
onValueChange = { code = it.filter(Char::isDigit).take(6) },
value = state.otpCode,
onValueChange = { onCodeChange(it) },
label = { Text("Kod OTP") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
ErrorText(state.error)
PrimaryButton("Zaloguj") { onSubmit(code) }
Spacer(Modifier.height(if (state.error.isNullOrBlank()) 18.dp else 8.dp))
PrimaryButton("Zaloguj") { onSubmit(state.otpCode) }
}
}
@@ -192,8 +216,8 @@ private fun RouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
shape = RoundedCornerShape(8.dp),
border = CardDefaults.outlinedCardBorder(),
) {
Row(Modifier.fillMaxWidth()) {
Box(Modifier.size(width = 5.dp, height = 132.dp).background(TppColors.Forest))
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
Box(Modifier.fillMaxHeight().width(5.dp).background(TppColors.Forest))
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(display.time, fontFamily = FontFamily.Monospace, color = TppColors.Navy, fontWeight = FontWeight.Bold)
Text("${display.origin} -> ${display.destination}", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
@@ -218,6 +242,10 @@ private fun DetailScreen(
val capturedUri = cameraUri
if (ok && capturedUri != null) onUpload(capturedUri, "camera")
}
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
val pendingUri = cameraUri
if (granted && pendingUri != null) cameraLauncher.launch(pendingUri)
}
val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) onUpload(uri, "gallery")
}
@@ -246,7 +274,11 @@ private fun DetailScreen(
PhotoGrid(route.photos, state.imageAuthHeader, onPhoto, onCamera = {
val newUri = createCameraUri(context)
cameraUri = newUri
cameraLauncher.launch(newUri)
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
cameraLauncher.launch(newUri)
} else {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}, onGallery = {
pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
})
@@ -361,6 +393,68 @@ private fun createCameraUri(context: Context): Uri {
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
}
@Composable
private fun SmsUserConsentEffect(enabled: Boolean, onCode: (String) -> Unit) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val message = result.data?.getStringExtra(SmsRetriever.EXTRA_SMS_MESSAGE).orEmpty()
OtpCodeExtractor.extract(message)?.let(onCode)
}
}
DisposableEffect(enabled, context) {
if (!enabled) return@DisposableEffect onDispose { }
SmsRetriever.getClient(context).startSmsUserConsent(null)
val receiver = object : BroadcastReceiver() {
override fun onReceive(receiverContext: Context?, intent: Intent?) {
if (intent?.action != SmsRetriever.SMS_RETRIEVED_ACTION) return
val status = intent.smsRetrieverStatus() ?: return
if (status.statusCode != CommonStatusCodes.SUCCESS) return
intent.smsConsentIntent()?.let(launcher::launch)
}
}
val filter = IntentFilter(SmsRetriever.SMS_RETRIEVED_ACTION)
ContextCompat.registerReceiver(
context,
receiver,
filter,
SmsRetriever.SEND_PERMISSION,
null,
ContextCompat.RECEIVER_EXPORTED,
)
onDispose {
runCatching { context.unregisterReceiver(receiver) }
}
}
}
private fun Intent.smsRetrieverStatus(): Status? {
val extras = extras ?: return null
@Suppress("DEPRECATION")
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
extras.getParcelable(SmsRetriever.EXTRA_STATUS, Status::class.java)
} else {
extras.getParcelable(SmsRetriever.EXTRA_STATUS)
}
}
private fun Intent.smsConsentIntent(): Intent? {
val extras = extras ?: return null
@Suppress("DEPRECATION")
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
extras.getParcelable(SmsRetriever.EXTRA_CONSENT_INTENT, Intent::class.java)
} else {
extras.getParcelable(SmsRetriever.EXTRA_CONSENT_INTENT)
}
}
@Composable
private fun imageRequest(url: String?, authorization: String?): ImageRequest {
val context = LocalContext.current
@@ -19,6 +19,7 @@ data class DriverUiState(
val screen: DriverScreen = DriverScreen.Phone,
val loading: Boolean = false,
val phone: String = "",
val otpCode: String = "",
val maskedPhone: String = "",
val driver: DriverDto? = null,
val routes: List<DriverRouteDto> = emptyList(),
@@ -45,6 +46,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
it.copy(
screen = DriverScreen.Otp,
phone = phone,
otpCode = "",
maskedPhone = response.phoneMasked.orEmpty(),
error = null,
)
@@ -57,6 +59,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
refreshRoutes()
}
fun updateOtpCode(code: String) {
_state.update { it.copy(otpCode = code.filter(Char::isDigit).take(10)) }
}
fun refreshRoutes() = runLoading {
val response = repository.bootstrap()
_state.update {
@@ -0,0 +1,20 @@
package pl.firmatpp.kierowca.domain
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class OtpCodeExtractorTest {
@Test
fun extractsFourToTenDigitOtpFromSmsBody() {
assertEquals("1234", OtpCodeExtractor.extract("Kod TPP: 1234"))
assertEquals("987654", OtpCodeExtractor.extract("Twoj kod weryfikacyjny to 987654."))
assertEquals("1234567890", OtpCodeExtractor.extract("Code 1234567890"))
}
@Test
fun ignoresNumbersThatAreTooShortOrTooLong() {
assertNull(OtpCodeExtractor.extract("Kod 123"))
assertNull(OtpCodeExtractor.extract("Kod 12345678901"))
}
}
+4
View File
@@ -13,6 +13,8 @@ datastore = "1.1.1"
coil = "2.7.0"
camerax = "1.4.1"
work = "2.10.0"
playServicesAuth = "21.6.0"
playServicesAuthApiPhone = "18.3.0"
junit = "4.13.2"
[libraries]
@@ -40,6 +42,8 @@ coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-
junit = { group = "junit", name = "junit", version.ref = "junit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" }
play-services-auth-api-phone = { group = "com.google.android.gms", name = "play-services-auth-api-phone", version.ref = "playServicesAuthApiPhone" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }