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="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="/data/src/aimereader" />
<option value="$PROJECT_DIR$/app" /> <option value="/data/src/aimereader/app" />
</set> </set>
</option> </option>
<option name="resolveExternalAnnotations" value="false" /> <option name="resolveExternalAnnotations" value="false" />

View File

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

View File

@ -5,8 +5,12 @@
<set> <set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" /> <option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" /> <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.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" /> <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> </set>
</option> </option>
</component> </component>

1
.idea/vcs.xml generated
View File

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

View File

@ -2,16 +2,19 @@ plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.parcelize)
} }
android { android {
namespace = "me.kdcf.aimereader" namespace = "me.kdcf.aimereader"
compileSdk = 34 compileSdk = 35
defaultConfig { defaultConfig {
applicationId = "me.kdcf.aimereader" applicationId = "me.kdcf.aimereader"
minSdk = 24 minSdk = 33
targetSdk = 34 targetSdk = 35
compileSdk = 36
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
@ -20,7 +23,8 @@ android {
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true
isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
@ -40,7 +44,6 @@ android {
} }
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
@ -49,6 +52,8 @@ dependencies {
implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.material.icons.extended)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) 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 package me.kdcf.aimereader
import android.app.PendingIntent import android.app.PendingIntent
import android.content.ClipData
import android.content.ComponentName import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
@ -12,42 +13,81 @@ import android.nfc.Tag
import android.nfc.cardemulation.NfcFCardEmulation import android.nfc.cardemulation.NfcFCardEmulation
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.foundation.layout.fillMaxSize
import androidx.compose.material3.Button import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.FilledTonalButton import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider 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.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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.util.Consumer 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 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() { class MainActivity : ComponentActivity() {
var intentFiltersArray: Array<IntentFilter>? = null var intentFiltersArray: Array<IntentFilter>? = null
var techListsArray: Array<Array<String>>? = null var techListsArray: Array<Array<String>>? = null
lateinit var pendingIntent: PendingIntent lateinit var pendingIntent: PendingIntent
lateinit var adapter: NfcAdapter 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -65,20 +105,48 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
var lastCode by remember { mutableStateOf<ByteArray?>(null) } val cardsFile by rememberSaveable {
var scanStatus by remember { mutableStateOf(ScanStatus.WAITING) } 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 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()) Log.i(TAG, tagFromIntent.toString())
if (tagFromIntent == null) { if (tagFromIntent == null) {
Log.i(TAG, "No tag found.") Log.d(TAG, "Got intent without tag")
scanStatus = ScanStatus.FAILURE
} else { } else {
Log.i(TAG, "Found tag $tagFromIntent") Log.i(TAG, "Got intent with tag $tagFromIntent")
lastCode = tagFromIntent.id if (tagFromIntent.id.size == Long.SIZE_BYTES) {
scanStatus = ScanStatus.SUCCESS 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() {
} }
} }
AimeReaderTheme { val scope = rememberCoroutineScope()
Surface(modifier = Modifier.fillMaxSize()) { var snackbarHostState = remember { SnackbarHostState() }
Column (
horizontalAlignment = Alignment.CenterHorizontally, var selectedCardIndexForEmulation by rememberSaveable { mutableIntStateOf(-1) }
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize() LaunchedEffect(selectedCardIndexForEmulation) {
) { if (selectedCardIndexForEmulation == -1) {
CardReader(scanStatus, lastCode) this@MainActivity.emulationCardID = null
} else {
this@MainActivity.emulationCardID = cards[selectedCardIndexForEmulation].getHexID()
} }
updateEmulation()
}
AimeReaderTheme {
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) adapter.disableForegroundDispatch(this)
val nfcfCardEmulation = NfcFCardEmulation.getInstance(adapter) val nfcfCardEmulation = NfcFCardEmulation.getInstance(adapter)
nfcfCardEmulation.disableService(this) nfcfCardEmulation.disableService(this)
Log.i(TAG, "Pausing activity, emulation disabled")
} }
public override fun onResume() { public override fun onResume() {
super.onResume() super.onResume()
adapter.enableForegroundDispatch(this, pendingIntent, intentFiltersArray, techListsArray) adapter.enableForegroundDispatch(this, pendingIntent, intentFiltersArray, techListsArray)
updateEmulation()
}
fun updateEmulation() {
val nfcfCardEmulation = NfcFCardEmulation.getInstance(adapter) val nfcfCardEmulation = NfcFCardEmulation.getInstance(adapter)
if (emulationCardID != null) {
Log.i(TAG, "Enabling emulation of card $emulationCardID")
nfcfCardEmulation.registerSystemCodeForService(ComponentName(this, AimeHostApduService::class.java), "4000") nfcfCardEmulation.registerSystemCodeForService(ComponentName(this, AimeHostApduService::class.java), "4000")
Log.d(TAG, nfcfCardEmulation.setNfcid2ForService(ComponentName(this, AimeHostApduService::class.java), "02FE000000000000").toString()) Log.d(TAG, nfcfCardEmulation.setNfcid2ForService(ComponentName(this, AimeHostApduService::class.java), emulationCardID).toString())
Log.d(TAG, nfcfCardEmulation.enableService(this, ComponentName(this, AimeHostApduService::class.java)).toString()) Log.d(TAG, nfcfCardEmulation.enableService(this, ComponentName(this, AimeHostApduService::class.java)).toString())
Log.i(TAG, "activity resumed")
}
}
@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)
} else { } else {
CodeDisplay(code) Log.i(TAG, "Disabling card emulation")
nfcfCardEmulation.disableService(this)
} }
} }
}
@Composable @Composable
fun CodeDisplay(code: ByteArray) { fun CardList(
val codeNumber = ByteBuffer.wrap(code).long cards: List<SavedCard>,
val padded = codeNumber.toString().padStart(20, '0') emulationAvailable: Boolean,
val spaced = padded.chunked(4).joinToString(" ") selectedForEmulation: Int,
val clipboardManager = LocalClipboardManager.current deleteCard: (Int) -> Unit,
editCard: (Int) -> Unit,
Text(spaced, style = MaterialTheme.typography.titleLarge) selectForEmulation: (Int) -> Unit,
Button(onClick = { clipboardManager.setText(AnnotatedString(spaced)) }) { Text(stringResource(R.string.main_copy_code)) } innerPadding: PaddingValues,
FilledTonalButton(onClick = { clipboardManager.setText(AnnotatedString(padded)) }) { Text( modifier: Modifier = Modifier) {
stringResource(R.string.main_copy_without_spaces) if (cards.isEmpty()) {
) }
FilledTonalButton(onClick = { clipboardManager.setText(AnnotatedString(codeNumber.toString())) }) { Text(
stringResource(R.string.main_copy_without_zeroes)
) }
}
@Composable
@Preview
fun CardReaderPreview(modifier: Modifier = Modifier) {
Column ( Column (
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize() modifier = modifier
.fillMaxSize()
.padding(innerPadding)
) { ) {
CardReader(ScanStatus.WAITING, null) Text(stringResource(R.string.list_no_cards), style = MaterialTheme.typography.titleLarge)
HorizontalDivider() Text(stringResource(R.string.list_no_cards_body), style = MaterialTheme.typography.bodyLarge)
CardReader(ScanStatus.SUCCESS, ByteArray(8) { x -> x.toByte() }) }
HorizontalDivider() } else {
CardReader(ScanStatus.FAILURE, null) 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 CardDisplay(
card: SavedCard,
emulationAvailable: Boolean,
selectedForEmulation: Boolean,
onSelectForEmulation: () -> Unit,
onDelete: () -> Unit,
onEdit: () -> Unit,
modifier: Modifier = Modifier
) {
val clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
Row (
modifier = modifier
.padding(horizontal = 8.dp, vertical = 8.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
verticalAlignment = Alignment.CenterVertically
) {
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_code">Copy Code</string>
<string name="main_copy_without_spaces">Copy Without Spaces</string> <string name="main_copy_without_spaces">Copy Without Spaces</string>
<string name="main_copy_without_zeroes">Copy Without Zeroes</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> </resources>

View File

@ -1,13 +1,13 @@
[versions] [versions]
agp = "8.7.0" agp = "8.10.1"
kotlin = "2.0.0" kotlin = "2.0.0"
coreKtx = "1.10.1" coreKtx = "1.17.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.1.5" junitVersion = "1.3.0"
espressoCore = "3.5.1" espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.6.1" lifecycleRuntimeKtx = "2.9.2"
activityCompose = "1.8.0" activityCompose = "1.10.1"
composeBom = "2024.04.01" composeBom = "2025.08.00"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", 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 distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists