diff --git a/app/src/main/java/com/android/trisolarispms/MainActivity.kt b/app/src/main/java/com/android/trisolarispms/MainActivity.kt index 6637fed..c907401 100644 --- a/app/src/main/java/com/android/trisolarispms/MainActivity.kt +++ b/app/src/main/java/com/android/trisolarispms/MainActivity.kt @@ -19,6 +19,7 @@ import com.android.trisolarispms.ui.property.AddPropertyScreen import com.android.trisolarispms.ui.room.RoomFormScreen import com.android.trisolarispms.ui.room.RoomsScreen import com.android.trisolarispms.ui.card.IssueTemporaryCardScreen +import com.android.trisolarispms.ui.card.CardInfoScreen import com.android.trisolarispms.ui.roomimage.RoomImagesScreen import com.android.trisolarispms.ui.roomimage.ImageTagsScreen import com.android.trisolarispms.ui.roomimage.AddImageTagScreen @@ -110,6 +111,7 @@ class MainActivity : ComponentActivity() { route.value = AppRoute.AddRoom(currentRoute.propertyId) }, onViewRoomTypes = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) }, + onViewCardInfo = { route.value = AppRoute.CardInfo(currentRoute.propertyId) }, canManageRooms = canManageProperty(currentRoute.propertyId), onEditRoom = { selectedRoom.value = it @@ -210,6 +212,10 @@ class MainActivity : ComponentActivity() { roomNumber = selectedRoom.value?.roomNumber?.toString(), onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) } ) + is AppRoute.CardInfo -> CardInfoScreen( + propertyId = currentRoute.propertyId, + 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 00ee823..f2b4a9a 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 @@ -1,10 +1,10 @@ package com.android.trisolarispms.data.api -import com.android.trisolarispms.data.api.model.ActionResponse import com.android.trisolarispms.data.api.model.CardPrepareRequest import com.android.trisolarispms.data.api.model.CardPrepareResponse import com.android.trisolarispms.data.api.model.IssueCardRequest import com.android.trisolarispms.data.api.model.IssuedCardResponse +import com.android.trisolarispms.data.api.model.RevokeCardResponse import retrofit2.Response import retrofit2.http.Body import retrofit2.http.GET @@ -50,5 +50,5 @@ interface CardApi { suspend fun revokeCard( @Path("propertyId") propertyId: String, @Path("cardId") cardId: String - ): Response + ): Response } 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 4ad888a..075f9e2 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 @@ -34,3 +34,7 @@ data class IssuedCardResponse( val issuedByUserId: String? = null, val revokedAt: String? = null ) + +data class RevokeCardResponse( + val timeData: String? = null +) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt index ee518cf..86da905 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt @@ -30,7 +30,9 @@ data class RoomDto( val hasNfc: Boolean? = null, val active: Boolean? = null, val maintenance: Boolean? = null, - val notes: String? = null + val notes: String? = null, + val tempCardActive: Boolean? = null, + val tempCardExpiresAt: String? = null ) data class RoomBoardDto( 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 6c9a06f..b12b884 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt @@ -8,6 +8,7 @@ sealed interface 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 CardInfo(val propertyId: 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/CardInfoScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/card/CardInfoScreen.kt new file mode 100644 index 0000000..28eec4c --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/card/CardInfoScreen.kt @@ -0,0 +1,382 @@ +package com.android.trisolarispms.ui.card + +import android.app.Activity +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.MifareClassic +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.AlertDialog +import androidx.compose.material3.TextButton +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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 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 +import com.android.trisolarispms.data.api.ApiClient +import java.util.Calendar +import java.util.Date +import kotlinx.coroutines.launch + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun CardInfoScreen( + propertyId: String, + onBack: () -> Unit +) { + val context = LocalContext.current + val activity = context as? Activity + val nfcAdapter = NfcAdapter.getDefaultAdapter(context) + val scope = rememberCoroutineScope() + val lastTag = remember { mutableStateOf(null) } + val cardId = remember { mutableStateOf(null) } + val roomNumber = remember { mutableStateOf(null) } + val cardIndex = remember { mutableStateOf(null) } + val issuedBy = remember { mutableStateOf(null) } + val issuedById = remember { mutableStateOf(null) } + val issuedAt = remember { mutableStateOf(null) } + val expiresAt = remember { mutableStateOf(null) } + val expired = remember { mutableStateOf(null) } + val error = remember { mutableStateOf(null) } + val revokeError = remember { mutableStateOf(null) } + val revokeStatus = remember { mutableStateOf(null) } + val showRevokeConfirm = remember { mutableStateOf(false) } + val showRevokeSuccess = remember { mutableStateOf(false) } + + val composition = rememberLottieComposition( + LottieCompositionSpec.Asset("scan_nfc_animation.json") + ) + val progress = animateLottieCompositionAsState( + composition = composition.value, + iterations = LottieConstants.IterateForever + ) + + DisposableEffect(activity, nfcAdapter) { + if (activity != null && nfcAdapter != null) { + val flags = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK + val callback = NfcAdapter.ReaderCallback { tag -> + lastTag.value = tag + handleTag( + tag = tag, + cardId = cardId, + roomNumber = roomNumber, + cardIndex = cardIndex, + issuedBy = issuedBy, + issuedById = issuedById, + issuedAt = issuedAt, + expiresAt = expiresAt, + expired = expired, + error = error + ) + } + nfcAdapter.enableReaderMode(activity, callback, flags, null) + onDispose { nfcAdapter.disableReaderMode(activity) } + } else { + onDispose { } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Card Details") }, + 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.Top + ) { + Text( + text = "Tap a card on the back of the phone", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + LottieAnimation( + composition = composition.value, + progress = { progress.value }, + modifier = Modifier.size(180.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + error.value?.let { + Text(text = it, color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(8.dp)) + } + revokeError.value?.let { + Text(text = it, color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(8.dp)) + } + revokeStatus.value?.let { + Text(text = it, color = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.height(8.dp)) + } + infoRow("Card ID", cardId.value) + infoRow("Room", roomNumber.value) + infoRow("Card Index", cardIndex.value) + infoRow("Issued By", issuedBy.value) + infoRow("Issuer ID", issuedById.value) + infoRow("Issued At", issuedAt.value) + infoRow("Expires At", expiresAt.value) + expired.value?.let { + infoRow("Expired", if (it) "Yes" else "No") + } + if (!cardId.value.isNullOrBlank() && expired.value != true) { + Spacer(modifier = Modifier.height(16.dp)) + TextButton(onClick = { showRevokeConfirm.value = true }) { + Text("Revoke Card") + } + } + } + } + + if (showRevokeConfirm.value) { + AlertDialog( + onDismissRequest = { showRevokeConfirm.value = false }, + title = { Text("Revoke card?") }, + text = { Text("This will revoke the current card and make it inactive.") }, + confirmButton = { + TextButton( + onClick = { + showRevokeConfirm.value = false + val currentIndex = cardIndex.value + val currentTag = lastTag.value + if (currentIndex.isNullOrBlank() || currentTag == null) { + revokeError.value = "Place the card on the phone to revoke." + return@TextButton + } + val indexValue = currentIndex.toIntOrNull() + if (indexValue == null) { + revokeError.value = "Invalid card index." + return@TextButton + } + revokeError.value = null + revokeStatus.value = "Revoking..." + scope.launch { + try { + val api = ApiClient.create() + val response = api.revokeCard(propertyId, indexValue.toString()) + val body = response.body() + if (response.isSuccessful && body?.timeData != null) { + val writeResult = writeRevokeTimeData(currentTag, body.timeData) + if (writeResult == null) { + revokeStatus.value = "Card revoked." + showRevokeSuccess.value = true + expired.value = true + cardId.value = null + roomNumber.value = null + cardIndex.value = null + issuedBy.value = null + issuedById.value = null + issuedAt.value = null + expiresAt.value = null + error.value = null + } else { + revokeStatus.value = null + revokeError.value = writeResult + } + } else { + revokeStatus.value = null + revokeError.value = "Revoke failed: ${response.code()}" + } + } catch (e: Exception) { + revokeStatus.value = null + revokeError.value = e.localizedMessage ?: "Revoke failed." + } + } + } + ) { + Text("Revoke") + } + }, + dismissButton = { + TextButton(onClick = { showRevokeConfirm.value = false }) { + Text("Cancel") + } + } + ) + } + + if (showRevokeSuccess.value) { + AlertDialog( + onDismissRequest = { showRevokeSuccess.value = false }, + title = { Text("Card revoked") }, + text = { Text("Card revoked successfully.") }, + confirmButton = { + TextButton( + onClick = { + showRevokeSuccess.value = false + onBack() + } + ) { + Text("OK") + } + } + ) + } +} + +@Composable +private fun infoRow(label: String, value: String?) { + if (value.isNullOrBlank()) return + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "$label: $value", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + +private fun handleTag( + tag: Tag, + cardId: androidx.compose.runtime.MutableState, + roomNumber: androidx.compose.runtime.MutableState, + cardIndex: androidx.compose.runtime.MutableState, + issuedBy: androidx.compose.runtime.MutableState, + issuedById: androidx.compose.runtime.MutableState, + issuedAt: androidx.compose.runtime.MutableState, + expiresAt: androidx.compose.runtime.MutableState, + expired: androidx.compose.runtime.MutableState, + error: androidx.compose.runtime.MutableState +) { + val mifare = MifareClassic.get(tag) ?: run { + error.value = "Unsupported card type (not Mifare Classic)." + return + } + try { + mifare.connect() + val sectorIndex = 0 + val authA = mifare.authenticateSectorWithKeyA(sectorIndex, MifareClassic.KEY_DEFAULT) + val authB = mifare.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_DEFAULT) + if (!authA && !authB) { + error.value = "Authentication failed for sector 0." + return + } + val key = getBlockHex(1, mifare) + val timeData = getBlockHex(2, mifare) + cardId.value = tag.id.joinToString("") { String.format("%02X", it) } + if (key.length >= 32) { + roomNumber.value = key.substring(26, 28) + cardIndex.value = key.substring(4, 10) + } + if (timeData.length >= 32) { + val startMin = timeData.substring(10, 12) + val startHour = timeData.substring(12, 14) + val startDate = timeData.substring(14, 16) + val startMonth = timeData.substring(16, 18) + val startYear = timeData.substring(18, 20) + issuedAt.value = "$startDate/$startMonth/$startYear $startHour:$startMin" + + val endMin = timeData.substring(20, 22) + val endHour = timeData.substring(22, 24) + val endDate = timeData.substring(24, 26) + val endMonth = timeData.substring(26, 28) + val endYear = timeData.substring(28, 30) + expiresAt.value = "$endDate/$endMonth/$endYear $endHour:$endMin" + + val checkoutTimeLong = Calendar.getInstance().apply { + set(Calendar.YEAR, 2000 + endYear.toInt()) + set(Calendar.MONTH, endMonth.toInt() - 1) + set(Calendar.DAY_OF_MONTH, endDate.toInt()) + set(Calendar.HOUR_OF_DAY, endHour.toInt()) + set(Calendar.MINUTE, endMin.toInt()) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.time.time + expired.value = Date().time > checkoutTimeLong + } + val authC = mifare.authenticateSectorWithKeyA(3, MifareClassic.KEY_DEFAULT) + val authD = mifare.authenticateSectorWithKeyB(3, MifareClassic.KEY_DEFAULT) + if (authC || authD) { + val issuerBytes = mifare.readBlock(mifare.sectorToBlock(3) + 0) + val issuerIdBytes = mifare.readBlock(mifare.sectorToBlock(3) + 1) + issuedBy.value = issuerBytes.toString(Charsets.UTF_8).trim() + issuedById.value = issuerIdBytes.toString(Charsets.UTF_8).trim() + } + error.value = null + } catch (e: Exception) { + error.value = e.localizedMessage ?: "Read failed." + } finally { + try { + mifare.close() + } catch (_: Exception) { + } + } +} + +private fun getBlockHex(blockIndex: Int, mifareClassic: MifareClassic): String { + val data = mifareClassic.readBlock(blockIndex) + return data.joinToString("") { String.format("%02X", it) } +} + +private fun writeRevokeTimeData(tag: Tag, timeDataHex: 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) + val authB = mifare.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_DEFAULT) + if (!authA && !authB) return "Card authentication failed (sector 0)." + val timeBytes = hexToBytes(timeDataHex) + if (timeBytes.size != 16) return "Invalid time data size." + val blockIndex = mifare.sectorToBlock(sectorIndex) + mifare.writeBlock(blockIndex + 2, timeBytes) + null + } catch (e: Exception) { + e.localizedMessage ?: "Write failed." + } finally { + try { + mifare.close() + } catch (_: Exception) { + } + } +} + +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 +} 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 index 5bc73e4..2a2e78e 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardScreen.kt @@ -16,6 +16,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.TextButton import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -23,6 +25,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -48,6 +53,7 @@ fun IssueTemporaryCardScreen( val context = LocalContext.current val activity = context as? Activity val nfcAdapter = NfcAdapter.getDefaultAdapter(context) + val showSuccessDialog = remember { mutableStateOf(false) } val composition = rememberLottieComposition( LottieCompositionSpec.Asset("scan_nfc_animation.json") ) @@ -56,6 +62,17 @@ fun IssueTemporaryCardScreen( iterations = LottieConstants.IterateForever ) + LaunchedEffect(propertyId, roomId) { + viewModel.reset() + showSuccessDialog.value = false + } + + LaunchedEffect(state.lastCardId) { + if (!state.lastCardId.isNullOrBlank()) { + showSuccessDialog.value = true + } + } + DisposableEffect(activity, nfcAdapter, propertyId, roomId) { if (activity != null && nfcAdapter != null) { val flags = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK @@ -143,4 +160,27 @@ fun IssueTemporaryCardScreen( ) } } + + if (showSuccessDialog.value) { + AlertDialog( + onDismissRequest = { + showSuccessDialog.value = false + onBack() + }, + title = { Text("Card issued") }, + text = { + Text("Temporary card issued successfully.") + }, + confirmButton = { + TextButton( + onClick = { + showSuccessDialog.value = false + onBack() + } + ) { + Text("OK") + } + } + ) + } } 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 index 84a8553..6216b26 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardViewModel.kt @@ -23,6 +23,10 @@ class IssueTemporaryCardViewModel : ViewModel() { private val _state = MutableStateFlow(IssueTemporaryCardState()) val state: StateFlow = _state + fun reset() { + _state.value = IssueTemporaryCardState() + } + fun onTagScanned(propertyId: String, roomId: String, tag: Tag) { if (propertyId.isBlank() || roomId.isBlank()) return if (state.value.isProcessing) return 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 1c58026..de708c0 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 @@ -18,6 +18,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Category +import androidx.compose.material.icons.filled.CreditCard import androidx.compose.material.icons.filled.Hotel import androidx.compose.material.icons.filled.Layers import androidx.compose.material.icons.filled.Groups @@ -55,6 +56,7 @@ fun RoomsScreen( onBack: () -> Unit, onAddRoom: () -> Unit, onViewRoomTypes: () -> Unit, + onViewCardInfo: () -> Unit, canManageRooms: Boolean, onEditRoom: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit, onIssueTemporaryCard: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit, @@ -84,6 +86,9 @@ fun RoomsScreen( IconButton(onClick = onViewRoomTypes) { Icon(Icons.Default.Category, contentDescription = "Room Types") } + IconButton(onClick = onViewCardInfo) { + Icon(Icons.Default.CreditCard, contentDescription = "Card Info") + } IconButton(onClick = onAddRoom) { Icon(Icons.Default.Add, contentDescription = "Add Room") } @@ -179,7 +184,11 @@ fun RoomsScreen( .alpha(alpha) .combinedClickable( enabled = room.id != null, - onClick = { onIssueTemporaryCard(room) }, + onClick = { + if (room.hasNfc != false) { + onIssueTemporaryCard(room) + } + }, onLongClick = { onEditRoom(room) } ), colors = CardDefaults.cardColors( @@ -255,6 +264,14 @@ fun RoomsScreen( color = MaterialTheme.colorScheme.error ) } + if (room.tempCardActive == true) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "Temp card active", + style = MaterialTheme.typography.labelSmall, + color = androidx.compose.ui.graphics.Color(0xFF2E7D32) + ) + } } } }