From be52b58165dd735e0505da72893a880c9a06e65f Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Wed, 28 Jan 2026 07:28:19 +0530 Subject: [PATCH] Add NFC temporary card issue flow --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 5 + app/src/main/assets/scan_nfc_animation.json | 1 + .../com/android/trisolarispms/MainActivity.kt | 13 + .../android/trisolarispms/data/api/CardApi.kt | 14 + .../data/api/model/CardModels.kt | 5 +- .../com/android/trisolarispms/ui/AppRoute.kt | 1 + .../ui/card/IssueTemporaryCardScreen.kt | 146 ++++++++++ .../ui/card/IssueTemporaryCardState.kt | 10 + .../ui/card/IssueTemporaryCardViewModel.kt | 263 ++++++++++++++++++ .../trisolarispms/ui/room/RoomsScreen.kt | 4 +- gradle/libs.versions.toml | 2 + 12 files changed, 462 insertions(+), 3 deletions(-) create mode 100644 app/src/main/assets/scan_nfc_animation.json create mode 100644 app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardScreen.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0862798..df8f07d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -56,6 +56,7 @@ dependencies { implementation(libs.okhttp) implementation(libs.okhttp.logging) implementation(libs.coil.compose) + implementation(libs.lottie.compose) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.auth.ktx) implementation(libs.kotlinx.coroutines.play.services) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 941efb4..2e3469c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,11 @@ xmlns:tools="http://schemas.android.com/tools"> + + + RoomTypesScreen( @@ -197,6 +204,12 @@ class MainActivity : ComponentActivity() { route.value = AppRoute.RoomImages(currentRoute.propertyId, roomId) } ) + is AppRoute.IssueTemporaryCard -> IssueTemporaryCardScreen( + propertyId = currentRoute.propertyId, + roomId = currentRoute.roomId, + roomNumber = selectedRoom.value?.roomNumber?.toString(), + onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) } + ) is AppRoute.RoomImages -> RoomImagesScreen( propertyId = currentRoute.propertyId, roomId = currentRoute.roomId, diff --git a/app/src/main/java/com/android/trisolarispms/data/api/CardApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/CardApi.kt index 3121c89..00ee823 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/CardApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/CardApi.kt @@ -26,6 +26,20 @@ interface CardApi { @Body body: IssueCardRequest ): Response + @POST("properties/{propertyId}/rooms/{roomId}/cards/prepare-temp") + suspend fun prepareTemporaryRoomCard( + @Path("propertyId") propertyId: String, + @Path("roomId") roomId: String, + @Body body: CardPrepareRequest + ): Response + + @POST("properties/{propertyId}/rooms/{roomId}/cards/temp") + suspend fun issueTemporaryRoomCard( + @Path("propertyId") propertyId: String, + @Path("roomId") roomId: String, + @Body body: IssueCardRequest + ): Response + @GET("properties/{propertyId}/room-stays/{roomStayId}/cards") suspend fun listCards( @Path("propertyId") propertyId: String, diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/CardModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/CardModels.kt index 0a83e97..4ad888a 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/CardModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/CardModels.kt @@ -8,6 +8,9 @@ data class CardPrepareResponse( val cardIndex: Int? = null, val key: String? = null, val timeData: String? = null, + val sector3Block0: String? = null, + val sector3Block1: String? = null, + val sector3Block2: String? = null, val issuedAt: String? = null, val expiresAt: String? = null ) @@ -16,7 +19,7 @@ data class IssueCardRequest( val cardId: String, val cardIndex: Int, val issuedAt: String? = null, - val expiresAt: String + val expiresAt: String? = null ) data class IssuedCardResponse( diff --git a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt index 2b26627..6c9a06f 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt @@ -7,6 +7,7 @@ sealed interface AppRoute { data class Rooms(val propertyId: String) : AppRoute data class AddRoom(val propertyId: String) : AppRoute data class EditRoom(val propertyId: String, val roomId: String) : AppRoute + data class IssueTemporaryCard(val propertyId: String, val roomId: String) : AppRoute data class RoomTypes(val propertyId: String) : AppRoute data class AddRoomType(val propertyId: String) : AppRoute data class EditRoomType(val propertyId: String, val roomTypeId: String) : AppRoute diff --git a/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardScreen.kt new file mode 100644 index 0000000..5bc73e4 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardScreen.kt @@ -0,0 +1,146 @@ +package com.android.trisolarispms.ui.card + +import android.app.Activity +import android.nfc.NfcAdapter +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun IssueTemporaryCardScreen( + propertyId: String, + roomId: String, + roomNumber: String?, + onBack: () -> Unit, + viewModel: IssueTemporaryCardViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + val context = LocalContext.current + val activity = context as? Activity + val nfcAdapter = NfcAdapter.getDefaultAdapter(context) + val composition = rememberLottieComposition( + LottieCompositionSpec.Asset("scan_nfc_animation.json") + ) + val progress = animateLottieCompositionAsState( + composition = composition.value, + iterations = LottieConstants.IterateForever + ) + + DisposableEffect(activity, nfcAdapter, propertyId, roomId) { + if (activity != null && nfcAdapter != null) { + val flags = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK + val callback = NfcAdapter.ReaderCallback { tag -> + viewModel.onTagScanned(propertyId, roomId, tag) + } + nfcAdapter.enableReaderMode(activity, callback, flags, null) + onDispose { + nfcAdapter.disableReaderMode(activity) + } + } else { + onDispose { } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Issue Temporary Card") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = roomNumber?.let { "Room $it" } ?: "Room", + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Hold the NFC card near the reader to issue a temporary card.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (nfcAdapter == null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "NFC not supported on this device.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } else if (!nfcAdapter.isEnabled) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "NFC is off. Please enable NFC.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + state.status?.let { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + state.error?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + Spacer(modifier = Modifier.height(32.dp)) + LottieAnimation( + composition = composition.value, + progress = { progress.value }, + modifier = Modifier.size(220.dp) + ) + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardState.kt b/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardState.kt new file mode 100644 index 0000000..3786f8c --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardState.kt @@ -0,0 +1,10 @@ +package com.android.trisolarispms.ui.card + +data class IssueTemporaryCardState( + val isLoading: Boolean = false, + val isProcessing: Boolean = false, + val error: String? = null, + val status: String? = null, + val lastCardId: String? = null, + val lastExpiresAt: String? = null +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardViewModel.kt new file mode 100644 index 0000000..84a8553 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardViewModel.kt @@ -0,0 +1,263 @@ +package com.android.trisolarispms.ui.card + +import android.nfc.Tag +import android.nfc.tech.MifareClassic +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.trisolarispms.data.api.ApiClient +import com.android.trisolarispms.data.api.model.CardPrepareRequest +import com.android.trisolarispms.data.api.model.IssueCardRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import java.util.TimeZone + +class IssueTemporaryCardViewModel : ViewModel() { + private val _state = MutableStateFlow(IssueTemporaryCardState()) + val state: StateFlow = _state + + fun onTagScanned(propertyId: String, roomId: String, tag: Tag) { + if (propertyId.isBlank() || roomId.isBlank()) return + if (state.value.isProcessing) return + viewModelScope.launch(Dispatchers.IO) { + Log.d("IssueTempCard", "Tag detected. roomId=$roomId cardId=${tagIdToHex(tag.id)}") + _state.update { it.copy(isProcessing = true, error = null, status = "Preparing card...") } + try { + val api = ApiClient.create() + Log.d("IssueTempCard", "Calling prepare-temp") + val prepareResponse = api.prepareTemporaryRoomCard( + propertyId = propertyId, + roomId = roomId, + body = CardPrepareRequest(expiresAt = null) + ) + Log.d("IssueTempCard", "Prepare-temp response=${prepareResponse.code()}") + val prepareBody = prepareResponse.body() + if (!prepareResponse.isSuccessful || prepareBody == null) { + _state.update { + it.copy( + isProcessing = false, + error = "Prepare failed: ${prepareResponse.code()}", + status = null + ) + } + return@launch + } + val cardIndex = prepareBody.cardIndex + val keyHex = prepareBody.key + val timeHex = prepareBody.timeData + if (cardIndex == null || keyHex.isNullOrBlank() || timeHex.isNullOrBlank()) { + _state.update { + it.copy( + isProcessing = false, + error = "Prepare response missing data.", + status = null + ) + } + return@launch + } + + _state.update { it.copy(status = "Writing card...") } + val cardId = tagIdToHex(tag.id) + val writeResult = writeSector0( + tag = tag, + keyHex = keyHex, + timeHex = timeHex, + sector3Block0 = prepareBody.sector3Block0, + sector3Block1 = prepareBody.sector3Block1, + sector3Block2 = prepareBody.sector3Block2 + ) + if (writeResult != null) { + Log.d("IssueTempCard", "Write failed: $writeResult") + _state.update { + it.copy( + isProcessing = false, + error = writeResult, + status = null + ) + } + return@launch + } + + Log.d("IssueTempCard", "Calling issue-temp") + _state.update { it.copy(status = "Saving issued card...") } + val issueResponse = api.issueTemporaryRoomCard( + propertyId = propertyId, + roomId = roomId, + body = IssueCardRequest( + cardId = cardId, + cardIndex = cardIndex, + issuedAt = prepareBody.issuedAt + ?: nowAndExpiresIso(minutes = 7).first + ) + ) + Log.d("IssueTempCard", "Issue-temp response=${issueResponse.code()}") + if (issueResponse.isSuccessful) { + val body = issueResponse.body() + _state.update { + it.copy( + isProcessing = false, + error = null, + status = if (body != null) { + "Card issued. Expires in 7 minutes." + } else { + "Card issued (no response body)." + }, + lastCardId = cardId, + lastExpiresAt = prepareBody.expiresAt + ) + } + } else { + val errorText = issueResponse.errorBody()?.string() + Log.d("IssueTempCard", "Issue-temp error body=$errorText") + _state.update { + it.copy( + isProcessing = false, + error = when (issueResponse.code()) { + 409 -> "Active card already exists." + else -> "Issue failed: ${issueResponse.code()}" + }, + status = null + ) + } + } + } catch (e: Exception) { + Log.d("IssueTempCard", "Issue failed: ${e.localizedMessage}", e) + _state.update { + it.copy( + isProcessing = false, + error = e.localizedMessage ?: "Issue failed", + status = null + ) + } + } + } + } + + private fun writeSector0( + tag: Tag, + keyHex: String, + timeHex: String, + sector3Block0: String?, + sector3Block1: String?, + sector3Block2: String? + ): String? { + val mifare = MifareClassic.get(tag) ?: return "Unsupported card type (not Mifare Classic)." + return try { + mifare.connect() + val sectorIndex = 0 + val authA = mifare.authenticateSectorWithKeyA(sectorIndex, MifareClassic.KEY_DEFAULT) || + mifare.authenticateSectorWithKeyA(sectorIndex, MifareClassic.KEY_MIFARE_APPLICATION_DIRECTORY) + val authB = mifare.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_DEFAULT) || + mifare.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_MIFARE_APPLICATION_DIRECTORY) + if (!authA && !authB) return "Card authentication failed (sector 0)." + + val keyBytes = hexToBytes(keyHex) + val timeBytes = hexToBytes(timeHex) + val blockIndex = mifare.sectorToBlock(sectorIndex) + Log.d("IssueTempCard", "Writing blocks ${blockIndex + 1} and ${blockIndex + 2}") + mifare.writeBlock(blockIndex + 1, keyBytes) + mifare.writeBlock(blockIndex + 2, timeBytes) + val trailerWrite = writeTrailerBlocks(mifare) + if (trailerWrite != null) return trailerWrite + val sector3Write = writeSector3Blocks( + mifare = mifare, + block0 = sector3Block0, + block1 = sector3Block1, + block2 = sector3Block2 + ) + if (sector3Write != null) return sector3Write + null + } catch (e: Exception) { + e.localizedMessage ?: "Write failed." + } finally { + try { + mifare.close() + } catch (_: Exception) { + } + } + } + + private fun writeTrailerBlocks(mifare: MifareClassic): String? { + val sectors = listOf(0, 1, 2, 14) + for (sector in sectors) { + val authA = mifare.authenticateSectorWithKeyA(sector, MifareClassic.KEY_DEFAULT) + val authB = mifare.authenticateSectorWithKeyB(sector, MifareClassic.KEY_DEFAULT) + if (!authA && !authB) return "Trailer auth failed (sector $sector)." + val blockIndex = mifare.sectorToBlock(sector) + 3 + mifare.writeBlock(blockIndex, CARD_SECTOR_INFO) + } + return null + } + + private fun writeSector3Blocks( + mifare: MifareClassic, + block0: String?, + block1: String?, + block2: String? + ): String? { + if (block0.isNullOrBlank() && block1.isNullOrBlank() && block2.isNullOrBlank()) return null + val sector = 3 + val authA = mifare.authenticateSectorWithKeyA(sector, MifareClassic.KEY_DEFAULT) + val authB = mifare.authenticateSectorWithKeyB(sector, MifareClassic.KEY_DEFAULT) + if (!authA && !authB) return "Sector 3 authentication failed." + val blockIndex = mifare.sectorToBlock(sector) + block0?.let { mifare.writeBlock(blockIndex + 0, hexToBytes(it)) } + block1?.let { mifare.writeBlock(blockIndex + 1, hexToBytes(it)) } + block2?.let { mifare.writeBlock(blockIndex + 2, hexToBytes(it)) } + return null + } + + private fun nowAndExpiresIso(minutes: Int): Pair { + val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US).apply { + timeZone = TimeZone.getDefault() + } + val calendar = Calendar.getInstance() + val issuedAt = format.format(calendar.time) + calendar.add(Calendar.MINUTE, minutes) + val expiresAt = format.format(calendar.time) + return issuedAt to expiresAt + } + + private fun tagIdToHex(tagId: ByteArray?): String { + if (tagId == null) return "" + return tagId.joinToString("") { byte -> "%02x".format(byte) } + } + + private fun hexToBytes(hex: String): ByteArray { + val clean = hex.trim().replace(" ", "") + val len = clean.length + if (len % 2 != 0) return ByteArray(0) + val out = ByteArray(len / 2) + var i = 0 + while (i < len) { + out[i / 2] = ((clean.substring(i, i + 2)).toInt(16) and 0xFF).toByte() + i += 2 + } + return out + } + + companion object { + private val CARD_SECTOR_INFO = hexToBytesStatic("1AB23CD45EF6FF078069FFFFFFFFFFFF") + + private fun hexToBytesStatic(hex: String): ByteArray { + val clean = hex.trim().replace(" ", "") + val len = clean.length + if (len % 2 != 0) return ByteArray(0) + val out = ByteArray(len / 2) + var i = 0 + while (i < len) { + out[i / 2] = ((clean.substring(i, i + 2)).toInt(16) and 0xFF).toByte() + i += 2 + } + return out + } + } + +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt index 6e469fb..1c58026 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt @@ -1,6 +1,5 @@ package com.android.trisolarispms.ui.room -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -58,6 +57,7 @@ fun RoomsScreen( onViewRoomTypes: () -> Unit, canManageRooms: Boolean, onEditRoom: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit, + onIssueTemporaryCard: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit, viewModel: RoomListViewModel = viewModel(), roomTypeListViewModel: RoomTypeListViewModel = viewModel() ) { @@ -179,7 +179,7 @@ fun RoomsScreen( .alpha(alpha) .combinedClickable( enabled = room.id != null, - onClick = {}, + onClick = { onIssueTemporaryCard(room) }, onLongClick = { onEditRoom(room) } ), colors = CardDefaults.cardColors( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3ad08c6..1a259e5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ lifecycleViewModelCompose = "2.10.0" firebaseAuthKtx = "24.0.1" vectordrawable = "1.2.0" coilCompose = "2.7.0" +lottieCompose = "6.7.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -46,6 +47,7 @@ kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "ko androidx-vectordrawable = { group = "androidx.vectordrawable", name = "vectordrawable", version.ref = "vectordrawable" } androidx-vectordrawable-animated = { group = "androidx.vectordrawable", name = "vectordrawable-animated", version.ref = "vectordrawable" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" } +lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottieCompose" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }