Compare commits

..

2 Commits

Author SHA1 Message Date
de97b9b7e1
Implement emulation (hopefully!) 2025-08-25 18:53:18 +02:00
6481878cee
Implement storage of multiple cards 2025-08-25 18:18:36 +02:00
13 changed files with 553 additions and 125 deletions

View File

@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="GreetingPreview">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="MainActivity">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-08-10T19:19:09.581900351Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/kodi/.config/.android/avd/Pixel_3a_API_35.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="CardReaderPreview">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

4
.idea/gradle.xml generated
View File

@ -9,8 +9,8 @@
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="/data/src/aimereader" />
<option value="/data/src/aimereader/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />

View File

@ -49,6 +49,10 @@
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />

View File

@ -5,8 +5,12 @@
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>

1
.idea/vcs.xml generated
View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="/data/src/aimereader" vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@ -2,16 +2,19 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.parcelize)
}
android {
namespace = "me.kdcf.aimereader"
compileSdk = 34
compileSdk = 35
defaultConfig {
applicationId = "me.kdcf.aimereader"
minSdk = 24
targetSdk = 34
minSdk = 33
targetSdk = 35
compileSdk = 36
versionCode = 1
versionName = "1.0"
@ -20,7 +23,8 @@ android {
buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@ -40,7 +44,6 @@ android {
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
@ -49,6 +52,8 @@ dependencies {
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.material.icons.extended)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@ -0,0 +1,159 @@
package me.kdcf.aimereader
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.insert
import androidx.compose.foundation.text.input.maxLength
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.text.input.then
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.text.isDigitsOnly
import java.nio.ByteBuffer
import kotlin.random.Random
@Composable
fun CardDialog(
initialCard: SavedCard,
titleText: String,
confirmText: String,
onConfirm: (SavedCard) -> Unit,
onDismissRequest: () -> Unit
) {
val codeState = rememberTextFieldState(initialText = initialCard.getPadded())
val nameState = rememberTextFieldState(initialText = initialCard.getName())
Dialog(onDismissRequest = { onDismissRequest() }) {
Card (
modifier = Modifier
.fillMaxWidth()
.height(350.dp)
.padding(16.dp),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 8.dp),
verticalArrangement = Arrangement.Top
) {
Text (
titleText,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
textAlign = TextAlign.Center
)
OutlinedTextField (
state = nameState,
label = { Text("Card Name") },
placeholder = {
// Re-do the spacing, as the code inside the codeState text isn't actually spaced
Text(codeState.text.toString().chunked(4).joinToString(" "))
},
lineLimits = TextFieldLineLimits.SingleLine,
)
OutlinedTextField (
state = codeState,
label = { Text("Access Code") },
lineLimits = TextFieldLineLimits.SingleLine,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
inputTransformation = InputTransformation.maxLength(20).then {
if (!asCharSequence().isDigitsOnly()) {
revertAllChanges()
}
},
outputTransformation = OutputTransformation {
if (length > 4) insert(4, " ")
if (length > 9) insert(9, " ")
if (length > 14) insert(14, " ")
if (length > 19) insert(19, " ")
}
)
Spacer(modifier = Modifier.weight(0.1f))
// Display a warning if the code cannot be emulated, meaning that the first 2
// bytes are 0x02 0xF3
val codeLong = codeState.text.toString().toULong()
Row (horizontalArrangement = Arrangement.SpaceAround, modifier = Modifier.fillMaxWidth()) {
if (codeLong and 0xFFFF000000000000u == EMULATABLE_CODE_MATCH) {
Text(
"This Access Code can be emulated.",
style = MaterialTheme.typography.bodySmall
)
} else {
Text(
"This Access Code cannot be emulated.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
Spacer(modifier = Modifier.weight(0.2f))
Row (horizontalArrangement = Arrangement.SpaceAround, modifier = Modifier.fillMaxWidth()) {
OutlinedButton (
onClick = {
val newCodeLong = Random.nextLong() and EMULATABLE_CODE_MASK.toLong() or EMULATABLE_CODE_MATCH.toLong()
codeState.setTextAndPlaceCursorAtEnd(newCodeLong.toString().padStart(20, '0'))
}
) {
Text("Random Code")
}
}
Row (horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
FilledTonalButton(
onClick = onDismissRequest
) {
Text("Cancel")
}
Button (
enabled = codeState.text.length == 20,
onClick = {
val buffer = ByteBuffer.allocate(Long.SIZE_BYTES)
buffer.putLong(codeLong.toLong())
onConfirm(SavedCard(buffer.array(), nameState.text.toString()))
}
) {
Text(confirmText)
}
}
}
}
}
}
@Preview
@Composable
fun CardDialogPreview() {
CardDialog(
SavedCard(byteArrayOf(0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07), ""),
titleText = "Add Card",
confirmText = "Add",
onConfirm = {},
onDismissRequest = {})
}

View File

@ -3,6 +3,7 @@
package me.kdcf.aimereader
import android.app.PendingIntent
import android.content.ClipData
import android.content.ComponentName
import android.content.Intent
import android.content.IntentFilter
@ -12,42 +13,81 @@ import android.nfc.Tag
import android.nfc.cardemulation.NfcFCardEmulation
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Contactless
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Contactless
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledIconToggleButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.util.Consumer
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import me.kdcf.aimereader.ui.theme.AimeReaderTheme
import java.nio.ByteBuffer
import java.io.File
const val EMULATABLE_CODE_MASK: ULong = 0x02FEFFFFFFFFFFFFu
const val EMULATABLE_CODE_MATCH: ULong = 0x02FE000000000000u
class MainActivity : ComponentActivity() {
var intentFiltersArray: Array<IntentFilter>? = null
var techListsArray: Array<Array<String>>? = null
lateinit var pendingIntent: PendingIntent
lateinit var adapter: NfcAdapter
var canEmulateCard: Boolean = false
val TAG = "AimeReader"
var canEmulateCard: Boolean = false
var emulationCardID: String? = null
private val TAG = "AimeReader"
private val FILENAME = "cards.json"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -65,20 +105,48 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
var lastCode by remember { mutableStateOf<ByteArray?>(null) }
var scanStatus by remember { mutableStateOf(ScanStatus.WAITING) }
val cardsFile by rememberSaveable {
val file = File(filesDir, FILENAME)
if (!file.exists()) {
file.createNewFile()
file.writeText("[]") // Empty JSON array
}
mutableStateOf(file)
}
var cards by remember {
mutableStateOf(Json.decodeFromString<List<SavedCard>>(cardsFile.readText()))
}
DisposableEffect(lastCode, scanStatus) {
var openEditDialog by rememberSaveable { mutableStateOf(false) }
var editCardIndex by rememberSaveable { mutableIntStateOf(-1) }
var openAddDialog by rememberSaveable { mutableStateOf(false) }
var dialogCard by rememberSaveable { mutableStateOf(SavedCard(
byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
)) }
DisposableEffect(cardsFile, cards) {
val listener = Consumer<Intent> { intent ->
val tagFromIntent: Tag? = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)
val tagFromIntent = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java)
Log.i(TAG, tagFromIntent.toString())
if (tagFromIntent == null ) {
Log.i(TAG, "No tag found.")
scanStatus = ScanStatus.FAILURE
if (tagFromIntent == null) {
Log.d(TAG, "Got intent without tag")
} else {
Log.i(TAG, "Found tag $tagFromIntent")
lastCode = tagFromIntent.id
scanStatus = ScanStatus.SUCCESS
Log.i(TAG, "Got intent with tag $tagFromIntent")
if (tagFromIntent.id.size == Long.SIZE_BYTES) {
if (openAddDialog || openEditDialog) {
val card = SavedCard(tagFromIntent.id, dialogCard.getName())
dialogCard = card
} else {
val card = SavedCard(tagFromIntent.id)
cards = cards.plus(card)
cardsFile.writeText(Json.encodeToString(cards))
}
} else {
Toast.makeText(
this@MainActivity,
"Tag is not a valid Aime/e-Amusement card",
Toast.LENGTH_SHORT).show()
}
}
}
@ -88,15 +156,111 @@ class MainActivity : ComponentActivity() {
}
}
val scope = rememberCoroutineScope()
var snackbarHostState = remember { SnackbarHostState() }
var selectedCardIndexForEmulation by rememberSaveable { mutableIntStateOf(-1) }
LaunchedEffect(selectedCardIndexForEmulation) {
if (selectedCardIndexForEmulation == -1) {
this@MainActivity.emulationCardID = null
} else {
this@MainActivity.emulationCardID = cards[selectedCardIndexForEmulation].getHexID()
}
updateEmulation()
}
AimeReaderTheme {
Surface(modifier = Modifier.fillMaxSize()) {
Column (
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize()
) {
CardReader(scanStatus, lastCode)
Scaffold (
modifier = Modifier.fillMaxSize(),
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
floatingActionButton = {
FloatingActionButton(
onClick = {
openAddDialog = true
}
) {
Icon(Icons.Filled.Add, "Add card")
}
}
) { innerPadding ->
if (openEditDialog) {
CardDialog (
dialogCard,
titleText = "Edit Card",
confirmText = "Edit",
onConfirm = {
openEditDialog = false
cards = cards.mapIndexed { i, prev ->
return@mapIndexed if (i == editCardIndex) {
it
} else {
prev
}
}
cardsFile.writeText(Json.encodeToString(cards))
if (editCardIndex == selectedCardIndexForEmulation) {
selectedCardIndexForEmulation = -1
}
},
onDismissRequest = {
openEditDialog = false
}
)
}
if (openAddDialog) {
CardDialog(
titleText = "Add Card",
confirmText = "Add",
initialCard = SavedCard(
byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
),
onConfirm = {
openAddDialog = false
cards = cards.plus(it)
cardsFile.writeText(Json.encodeToString(cards))
},
onDismissRequest = {
openAddDialog = false
}
)
}
CardList(cards,
emulationAvailable = this@MainActivity.canEmulateCard,
deleteCard = {
val backedUpCard = cards[it]
cards = cards.filterIndexed { i ,_ -> i != it }
if (it == selectedCardIndexForEmulation) {
selectedCardIndexForEmulation = -1
}
cardsFile.writeText(Json.encodeToString(cards))
scope.launch {
val result = snackbarHostState
.showSnackbar(
message = "Removed card",
actionLabel = "Undo",
duration = SnackbarDuration.Long,
withDismissAction = true
)
if (result == SnackbarResult.ActionPerformed) {
cards = cards.plus(backedUpCard)
cardsFile.writeText(Json.encodeToString(cards))
}
}
}, editCard = {
dialogCard = cards[it]
editCardIndex = it
openEditDialog = true
}, selectForEmulation = {
selectedCardIndexForEmulation = it
},
selectedForEmulation = selectedCardIndexForEmulation,
innerPadding = innerPadding)
}
}
}
@ -107,63 +271,149 @@ class MainActivity : ComponentActivity() {
adapter.disableForegroundDispatch(this)
val nfcfCardEmulation = NfcFCardEmulation.getInstance(adapter)
nfcfCardEmulation.disableService(this)
Log.i(TAG, "Pausing activity, emulation disabled")
}
public override fun onResume() {
super.onResume()
adapter.enableForegroundDispatch(this, pendingIntent, intentFiltersArray, techListsArray)
updateEmulation()
}
fun updateEmulation() {
val nfcfCardEmulation = NfcFCardEmulation.getInstance(adapter)
nfcfCardEmulation.registerSystemCodeForService(ComponentName(this, AimeHostApduService::class.java), "4000")
Log.d(TAG, nfcfCardEmulation.setNfcid2ForService(ComponentName(this, AimeHostApduService::class.java), "02FE000000000000").toString())
Log.d(TAG, nfcfCardEmulation.enableService(this, ComponentName(this, AimeHostApduService::class.java)).toString())
Log.i(TAG, "activity resumed")
if (emulationCardID != null) {
Log.i(TAG, "Enabling emulation of card $emulationCardID")
nfcfCardEmulation.registerSystemCodeForService(ComponentName(this, AimeHostApduService::class.java), "4000")
Log.d(TAG, nfcfCardEmulation.setNfcid2ForService(ComponentName(this, AimeHostApduService::class.java), emulationCardID).toString())
Log.d(TAG, nfcfCardEmulation.enableService(this, ComponentName(this, AimeHostApduService::class.java)).toString())
} else {
Log.i(TAG, "Disabling card emulation")
nfcfCardEmulation.disableService(this)
}
}
}
@Composable
fun CardReader(status: ScanStatus, code: ByteArray?, modifier: Modifier = Modifier) {
if (status == ScanStatus.WAITING) {
Text(stringResource(R.string.main_scan_title), style = MaterialTheme.typography.titleLarge)
Text(stringResource(R.string.main_scan_subtitle), style = MaterialTheme.typography.bodyLarge)
} else if (status == ScanStatus.FAILURE || code == null) {
Text(stringResource(R.string.main_read_failure),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.error)
Text(stringResource(R.string.main_read_failture_sub), style = MaterialTheme.typography.bodyLarge)
fun CardList(
cards: List<SavedCard>,
emulationAvailable: Boolean,
selectedForEmulation: Int,
deleteCard: (Int) -> Unit,
editCard: (Int) -> Unit,
selectForEmulation: (Int) -> Unit,
innerPadding: PaddingValues,
modifier: Modifier = Modifier) {
if (cards.isEmpty()) {
Column (
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = modifier
.fillMaxSize()
.padding(innerPadding)
) {
Text(stringResource(R.string.list_no_cards), style = MaterialTheme.typography.titleLarge)
Text(stringResource(R.string.list_no_cards_body), style = MaterialTheme.typography.bodyLarge)
}
} else {
CodeDisplay(code)
LazyColumn (
modifier = modifier
.fillMaxWidth()
.padding(innerPadding)
) {
itemsIndexed(cards) { i, card ->
CardDisplay(card,
onDelete = { deleteCard(i) },
onEdit = { editCard(i) },
selectedForEmulation = i == selectedForEmulation,
emulationAvailable = emulationAvailable,
onSelectForEmulation = { selectForEmulation(i) })
}
}
}
}
@Composable
fun CodeDisplay(code: ByteArray) {
val codeNumber = ByteBuffer.wrap(code).long
val padded = codeNumber.toString().padStart(20, '0')
val spaced = padded.chunked(4).joinToString(" ")
val clipboardManager = LocalClipboardManager.current
fun CardDisplay(
card: SavedCard,
emulationAvailable: Boolean,
selectedForEmulation: Boolean,
onSelectForEmulation: () -> Unit,
onDelete: () -> Unit,
onEdit: () -> Unit,
modifier: Modifier = Modifier
) {
val clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
Text(spaced, style = MaterialTheme.typography.titleLarge)
Button(onClick = { clipboardManager.setText(AnnotatedString(spaced)) }) { Text(stringResource(R.string.main_copy_code)) }
FilledTonalButton(onClick = { clipboardManager.setText(AnnotatedString(padded)) }) { Text(
stringResource(R.string.main_copy_without_spaces)
) }
FilledTonalButton(onClick = { clipboardManager.setText(AnnotatedString(codeNumber.toString())) }) { Text(
stringResource(R.string.main_copy_without_zeroes)
) }
}
@Composable
@Preview
fun CardReaderPreview(modifier: Modifier = Modifier) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize()
Row (
modifier = modifier
.padding(horizontal = 8.dp, vertical = 8.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
verticalAlignment = Alignment.CenterVertically
) {
CardReader(ScanStatus.WAITING, null)
HorizontalDivider()
CardReader(ScanStatus.SUCCESS, ByteArray(8) { x -> x.toByte() })
HorizontalDivider()
CardReader(ScanStatus.FAILURE, null)
Text(card.getDisplay(),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = 10.dp).weight(100f),
maxLines = 1,
overflow = TextOverflow.Ellipsis)
Spacer(modifier = Modifier.weight(0.5f))
FilledIconButton (
onClick = {
coroutineScope.launch {
val text = card.getSpaced()
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(text, text)))
}
}
) {
Icon(Icons.Filled.ContentCopy, "Copy")
}
if (emulationAvailable) {
FilledIconToggleButton(
enabled = card.getCode().toULong() and 0xFFFF000000000000u == EMULATABLE_CODE_MATCH,
checked = selectedForEmulation,
onCheckedChange = {
if (it) {
onSelectForEmulation()
}
}
) {
if (selectedForEmulation) {
Icon(Icons.Filled.Contactless, "Emulating")
} else {
Icon(Icons.Outlined.Contactless, "Emulate")
}
}
}
FilledTonalIconButton(
onClick = onEdit
) {
Icon(Icons.Filled.Edit, "Edit")
}
FilledTonalIconButton(
onClick = onDelete
) {
Icon(Icons.Filled.Delete, "Delete")
}
}
}
@Preview
@Composable
fun CardDisplayPreview(modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxSize()) {
CardDisplay(SavedCard(byteArrayOf(0x02, 0xFE.toByte(), 0x02, 0x03, 0x04, 0x05, 0x06, 0x07)), true, true, {}, {}, {})
CardDisplay(SavedCard(byteArrayOf(0x02, 0xFE.toByte(), 0x12, 0x34, 0x56, 0x78, 0x12, 0x34)), true, false, {}, {}, {})
CardDisplay(SavedCard(byteArrayOf(0x02, 0x00, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07)), true, false, {}, {}, {})
CardDisplay(SavedCard(byteArrayOf(0x00, 0x00, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07)), false, false, {}, {}, {})
}
}
@Preview
@Composable
fun EmptyCardList(modifier: Modifier = Modifier) {
CardList(emptyList(), true, -1, {}, {}, {}, PaddingValues(0.dp), modifier)
}

View File

@ -0,0 +1,39 @@
package me.kdcf.aimereader
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import java.nio.ByteBuffer
@Parcelize
@Serializable
class SavedCard(private var code: ByteArray, private var friendlyName: String = "") : Parcelable {
fun getName(): String {
return friendlyName
}
fun getCode(): Long {
return ByteBuffer.wrap(code).getLong()
}
fun getPadded(): String {
return getCode().toString().padStart(20, '0')
}
fun getSpaced(): String {
return getPadded().chunked(4).joinToString(" ")
}
@OptIn(ExperimentalStdlibApi::class)
fun getHexID(): String {
return getCode().toHexString()
}
fun getDisplay(): String {
if (friendlyName.isEmpty()) {
return getSpaced()
} else {
return friendlyName
}
}
}

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aimeth Readere</string>
<string name="emu_service_desc">Aimeth carde emulatorth servicere</string>
<string name="main_scan_title">Pleaseth Scaneth thy Carde</string>
<string name="main_scan_subtitle">Waitingeth…</string>
<string name="main_read_failure">Faileth to Reade the Carde</string>
<string name="main_read_failture_sub">Pleaseth trye againe or reporteth a bugue</string>
<string name="main_copy_code">Copyeth thy code</string>
<string name="main_copy_without_spaces">Copyeth thy code withoute Spacese</string>
<string name="main_copy_without_zeroes">Copyeth thy code withoute Zeroes</string>
</resources>

View File

@ -8,4 +8,6 @@
<string name="main_copy_code">Copy Code</string>
<string name="main_copy_without_spaces">Copy Without Spaces</string>
<string name="main_copy_without_zeroes">Copy Without Zeroes</string>
<string name="list_no_cards">No cards.</string>
<string name="list_no_cards_body">Scan a card or press the add button.</string>
</resources>

View File

@ -1,13 +1,13 @@
[versions]
agp = "8.7.0"
agp = "8.10.1"
kotlin = "2.0.0"
coreKtx = "1.10.1"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
composeBom = "2024.04.01"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.9.2"
activityCompose = "1.10.1"
composeBom = "2025.08.00"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -23,10 +23,13 @@ androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version = "1.4.0-beta02" }
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended-android", version = "1.7.8" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json"}
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize" }

View File

@ -1,6 +1,6 @@
#Mon Aug 04 17:25:24 GMT 2025
#Sun Aug 17 11:26:34 GMT 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists