Compare commits

...
3 Commits
Author SHA1 Message Date
admin 693f879078 Odśwież ekran logowania i ikonę aplikacji 2026-07-01 01:11:51 +02:00
admin 0bc592f357 Automatyzuj logowanie po wpisaniu OTP 2026-07-01 00:07:26 +02:00
admin 9b6564eedd Wysyłaj dane urządzenia przy logowaniu 2026-06-30 23:57:50 +02:00
30 changed files with 340 additions and 17 deletions
+6 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 11
versionName = "1.0.10"
versionCode = 13
versionName = "1.0.12"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"")
@@ -45,6 +45,10 @@ android {
compose = true
buildConfig = true
}
lint {
disable += "NullSafeMutableLiveData"
}
}
dependencies {
+2
View File
@@ -7,7 +7,9 @@
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TppKierowca">
<activity
@@ -0,0 +1,38 @@
package pl.firmatpp.kierowca.data
import android.content.Context
import android.os.Build
import android.provider.Settings
import pl.firmatpp.kierowca.BuildConfig
import pl.firmatpp.kierowca.data.model.DriverDeviceInfo
class DeviceInfoProvider(
private val context: Context,
private val tokenStore: TokenStore,
) {
suspend fun currentDeviceInfo(): DriverDeviceInfo =
DriverDeviceInfo(
deviceId = tokenStore.deviceId(),
androidId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID),
brand = Build.BRAND,
manufacturer = Build.MANUFACTURER,
model = Build.MODEL,
device = Build.DEVICE,
product = Build.PRODUCT,
androidVersion = Build.VERSION.RELEASE,
sdkInt = Build.VERSION.SDK_INT,
appVersion = BuildConfig.VERSION_NAME,
serialNumber = readSerialNumber(),
)
private fun readSerialNumber(): String? =
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Build.getSerial()
} else {
@Suppress("DEPRECATION")
Build.SERIAL
}
}.getOrNull()
?.takeUnless { it.isBlank() || it.equals(Build.UNKNOWN, ignoreCase = true) }
}
@@ -19,12 +19,13 @@ class DriverRepository(
private val context: Context,
private val api: MobileDriverApi = ApiFactory.mobileDriverApi(),
private val tokenStore: TokenStore = TokenStore(context),
private val deviceInfoProvider: DeviceInfoProvider = DeviceInfoProvider(context, tokenStore),
) {
suspend fun requestOtp(phoneNumber: String): OtpResponse =
api.requestOtp(RequestOtpBody(phoneNumber))
suspend fun verifyOtp(phoneNumber: String, code: String, deviceName: String): DriverDto {
val response = api.verifyOtp(VerifyOtpBody(phoneNumber, code, deviceName))
val response = api.verifyOtp(VerifyOtpBody(phoneNumber, code, deviceName, deviceInfoProvider.currentDeviceInfo()))
val token = response.accessToken ?: error("Brak tokenu sesji.")
tokenStore.save(token)
return response.driver ?: error("Brak danych kierowcy.")
@@ -6,11 +6,13 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.util.UUID
private val Context.driverDataStore by preferencesDataStore(name = "driver_session")
class TokenStore(private val context: Context) {
private val tokenKey = stringPreferencesKey("access_token")
private val deviceIdKey = stringPreferencesKey("device_id")
suspend fun save(token: String) {
context.driverDataStore.edit { preferences ->
@@ -22,6 +24,21 @@ class TokenStore(private val context: Context) {
.map { it[tokenKey] }
.first()
suspend fun deviceId(): String {
val existing = context.driverDataStore.data
.map { it[deviceIdKey] }
.first()
if (!existing.isNullOrBlank()) return existing
val generated = UUID.randomUUID().toString()
context.driverDataStore.edit { preferences ->
preferences[deviceIdKey] = generated
}
return generated
}
suspend fun clear() {
context.driverDataStore.edit { it.remove(tokenKey) }
}
@@ -6,6 +6,21 @@ data class VerifyOtpBody(
val phoneNumber: String,
val code: String,
val deviceName: String,
val device: DriverDeviceInfo,
)
data class DriverDeviceInfo(
val deviceId: String,
val androidId: String?,
val brand: String?,
val manufacturer: String?,
val model: String?,
val device: String?,
val product: String?,
val androidVersion: String?,
val sdkInt: Int,
val appVersion: String?,
val serialNumber: String?,
)
data class OtpResponse(
@@ -18,6 +18,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -46,6 +47,8 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddPhotoAlternate
import androidx.compose.material.icons.outlined.ArrowBack
@@ -92,11 +95,14 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
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.compose.ui.unit.sp
import androidx.compose.foundation.verticalScroll
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.Lifecycle
@@ -186,20 +192,134 @@ fun DriverApp(viewModel: DriverViewModel) {
@Composable
private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
var phone by remember { mutableStateOf(state.phone) }
AuthShell(title = "TPP Kierowca", subtitle = "Zaloguj sie numerem telefonu kierowcy.") {
OutlinedTextField(
value = phone,
onValueChange = { phone = it },
label = { Text("Numer telefonu") },
leadingIcon = { Icon(Icons.Outlined.Phone, contentDescription = null) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
var phoneDigits by remember(state.phone) { mutableStateOf(polishPhoneDigits(state.phone)) }
Box(
Modifier
.fillMaxSize()
.background(TppColors.Surface)
.statusBarsPadding()
.navigationBarsPadding()
.padding(24.dp),
) {
Card(
modifier = Modifier.fillMaxSize(),
colors = CardDefaults.cardColors(containerColor = Color(0xFFFEFFFC)),
border = BorderStroke(1.dp, TppColors.Outline),
shape = RoundedCornerShape(4.dp),
) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 28.dp, vertical = 36.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(R.drawable.tpp5),
contentDescription = "Firma TPP",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxWidth()
.height(132.dp),
)
Spacer(Modifier.height(42.dp))
Text(
"Zaloguj się",
style = MaterialTheme.typography.headlineLarge,
color = TppColors.Muted,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(76.dp))
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(18.dp)) {
Text(
"Numer Telefonu",
color = Color.Black,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 23.sp,
)
PhoneNumberField(
digits = phoneDigits,
onDigitsChange = { phoneDigits = polishPhoneDigits(it) },
)
Text(
"Otrzymasz kod SMS do weryfikacji.",
color = TppColors.Muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
)
ErrorText(state.error)
Spacer(Modifier.height(if (state.error.isNullOrBlank()) 22.dp else 8.dp))
Button(
onClick = { onSubmit("+48$phoneDigits") },
modifier = Modifier
.fillMaxWidth()
.height(72.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppColors.ContainerGreen),
shape = RoundedCornerShape(4.dp),
contentPadding = PaddingValues(horizontal = 20.dp),
) {
Text(
"Wyślij kod OTP",
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
)
}
}
}
}
}
}
@Composable
private fun PhoneNumberField(digits: String, onDigitsChange: (String) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(82.dp)
.background(Color(0xFFFEFFFC), RoundedCornerShape(2.dp))
.border(BorderStroke(1.dp, TppColors.Outline), RoundedCornerShape(2.dp)),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.width(94.dp)
.fillMaxHeight()
.background(TppColors.Panel),
contentAlignment = Alignment.Center,
) {
Text("+48", color = TppColors.Ink, fontSize = 26.sp)
}
Box(Modifier.width(1.dp).fillMaxHeight().background(TppColors.Outline))
BasicTextField(
value = formatPolishPhoneDigits(digits),
onValueChange = { onDigitsChange(it) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
textStyle = TextStyle(
color = TppColors.Muted,
fontSize = 28.sp,
fontWeight = FontWeight.Medium,
),
modifier = Modifier
.weight(1f)
.padding(horizontal = 24.dp),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (digits.isBlank()) {
Text(
"000 000 000",
color = TppColors.Muted.copy(alpha = 0.92f),
fontSize = 28.sp,
fontWeight = FontWeight.Medium,
)
}
innerTextField()
}
},
)
ErrorText(state.error)
Spacer(Modifier.height(if (state.error.isNullOrBlank()) 18.dp else 8.dp))
PrimaryButton("Wyslij kod") { onSubmit(phone) }
}
}
@@ -998,6 +1118,16 @@ private fun ErrorText(error: String?) {
if (!error.isNullOrBlank()) Text(error, color = TppColors.Error, modifier = Modifier.padding(vertical = 8.dp))
}
private fun polishPhoneDigits(value: String): String {
val digits = value.filter(Char::isDigit)
val withoutCountryCode = if (digits.length > 9 && digits.startsWith("48")) digits.drop(2) else digits
return withoutCountryCode.take(9)
}
private fun formatPolishPhoneDigits(value: String): String =
value.chunked(3).joinToString(" ")
private fun createCameraUri(context: Context): Uri {
val dir = File(context.cacheDir, "camera").apply { mkdirs() }
val file = File.createTempFile("ladunek-", ".jpg", dir)
@@ -40,6 +40,7 @@ data class DriverUiState(
class DriverViewModel(application: Application) : AndroidViewModel(application) {
private val repository = DriverRepository(application)
private val _state = MutableStateFlow(DriverUiState(loading = true))
private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy()
val state: StateFlow<DriverUiState> = _state
init {
@@ -50,6 +51,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun requestOtp(phone: String) = runLoading {
val response = repository.requestOtp(phone)
otpAutoSubmitPolicy.reset()
_state.update {
it.copy(
screen = DriverScreen.Otp,
@@ -68,7 +70,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
fun updateOtpCode(code: String) {
_state.update { it.copy(otpCode = code.filter(Char::isDigit).take(10)) }
val sanitized = code.filter(Char::isDigit).take(6)
_state.update { it.copy(otpCode = sanitized) }
if (otpAutoSubmitPolicy.shouldSubmit(sanitized, _state.value.loading)) {
verifyOtp(sanitized)
}
}
fun refreshRoutes() = loadRoutes(date = _state.value.selectedDate, showLoading = true, navigateToRoutes = true)
@@ -0,0 +1,27 @@
package pl.firmatpp.kierowca.ui
class OtpAutoSubmitPolicy {
private var lastSubmittedCode: String? = null
fun shouldSubmit(code: String, loading: Boolean): Boolean {
if (code.length < OTP_LENGTH) {
lastSubmittedCode = null
return false
}
if (loading || code.length != OTP_LENGTH || code == lastSubmittedCode) {
return false
}
lastSubmittedCode = code
return true
}
fun reset() {
lastSubmittedCode = null
}
private companion object {
const val OTP_LENGTH = 6
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#F7FAF8" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TPP Kierowca</string>
<string name="app_name">Firma TPP - Kierowca</string>
</resources>
@@ -0,0 +1,41 @@
package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class OtpAutoSubmitPolicyTest {
@Test
fun submitsWhenSixthDigitIsEntered() {
val policy = OtpAutoSubmitPolicy()
assertFalse(policy.shouldSubmit("12345", loading = false))
assertTrue(policy.shouldSubmit("123456", loading = false))
}
@Test
fun submitsSmsRetrieverCodeOnlyOnceWhileItStaysTheSame() {
val policy = OtpAutoSubmitPolicy()
assertTrue(policy.shouldSubmit("987654", loading = false))
assertFalse(policy.shouldSubmit("987654", loading = false))
}
@Test
fun allowsRetryAfterCodeIsEditedBelowSixDigits() {
val policy = OtpAutoSubmitPolicy()
assertTrue(policy.shouldSubmit("111111", loading = false))
assertFalse(policy.shouldSubmit("111111", loading = false))
assertFalse(policy.shouldSubmit("11111", loading = false))
assertTrue(policy.shouldSubmit("111111", loading = false))
}
@Test
fun doesNotSubmitWhileLoadingOrForLongerCodes() {
val policy = OtpAutoSubmitPolicy()
assertFalse(policy.shouldSubmit("123456", loading = true))
assertFalse(policy.shouldSubmit("1234567", loading = false))
}
}
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
function readSource(path) {
return readFileSync(resolve(root, path), 'utf8');
}
test('driver app sends Android device metadata during OTP verification', () => {
const models = readSource('app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt');
const repository = readSource('app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt');
const deviceInfo = readSource('app/src/main/java/pl/firmatpp/kierowca/data/DeviceInfoProvider.kt');
assert.match(models, /data class DriverDeviceInfo/);
assert.match(models, /val device: DriverDeviceInfo/);
assert.match(repository, /DeviceInfoProvider/);
assert.match(repository, /deviceInfoProvider\.currentDeviceInfo/);
assert.match(deviceInfo, /Settings\.Secure\.ANDROID_ID/);
assert.match(deviceInfo, /Build\.MANUFACTURER/);
assert.match(deviceInfo, /Build\.MODEL/);
assert.match(deviceInfo, /BuildConfig\.VERSION_NAME/);
});