diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 711299c..8bb18e1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/pl/firmatpp/kierowca/domain/OtpCodeExtractor.kt b/app/src/main/java/pl/firmatpp/kierowca/domain/OtpCodeExtractor.kt new file mode 100644 index 0000000..c0c25d4 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/domain/OtpCodeExtractor.kt @@ -0,0 +1,8 @@ +package pl.firmatpp.kierowca.domain + +object OtpCodeExtractor { + private val codePattern = Regex("""(? 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 diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt index 507e302..00a49ec 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -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 = 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 { diff --git a/app/src/test/java/pl/firmatpp/kierowca/domain/OtpCodeExtractorTest.kt b/app/src/test/java/pl/firmatpp/kierowca/domain/OtpCodeExtractorTest.kt new file mode 100644 index 0000000..b07aa16 --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/domain/OtpCodeExtractorTest.kt @@ -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")) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f04f678..21ee39b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }