diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
deleted file mode 100644
index 2b979a6..0000000
--- a/.idea/deploymentTargetSelector.xml
+++ /dev/null
@@ -1,27 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 7b3006b..f9462f8 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -9,8 +9,8 @@
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index cde3e19..7061a0d 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -49,6 +49,10 @@
+
+
+
+
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
index 931b96c..16660f1 100644
--- a/.idea/runConfigurations.xml
+++ b/.idea/runConfigurations.xml
@@ -5,8 +5,12 @@
+
+
+
+
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 94a25f7..f4b19b4 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -1,6 +1,7 @@
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b085502..517a275 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
diff --git a/app/src/main/java/me/kdcf/aimereader/CardDialog.kt b/app/src/main/java/me/kdcf/aimereader/CardDialog.kt
new file mode 100644
index 0000000..a0d6098
--- /dev/null
+++ b/app/src/main/java/me/kdcf/aimereader/CardDialog.kt
@@ -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 = {})
+}
\ No newline at end of file
diff --git a/app/src/main/java/me/kdcf/aimereader/MainActivity.kt b/app/src/main/java/me/kdcf/aimereader/MainActivity.kt
index 5a89986..5576803 100644
--- a/app/src/main/java/me/kdcf/aimereader/MainActivity.kt
+++ b/app/src/main/java/me/kdcf/aimereader/MainActivity.kt
@@ -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,80 @@ 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.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? = null
var techListsArray: Array>? = null
lateinit var pendingIntent: PendingIntent
lateinit var adapter: NfcAdapter
- var canEmulateCard: Boolean = false
- val TAG = "AimeReader"
+ var canEmulateCard: Boolean = false
+ var emulationActive: Boolean = false
+
+ private val TAG = "AimeReader"
+ private val FILENAME = "cards.json"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -65,20 +104,48 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
- var lastCode by remember { mutableStateOf(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>(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 ->
- 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 +155,102 @@ class MainActivity : ComponentActivity() {
}
}
+ val scope = rememberCoroutineScope()
+ var snackbarHostState = remember { SnackbarHostState() }
+
+ var selectedCardIndexForEmulation by rememberSaveable { mutableStateOf(-1) }
+
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)
}
}
}
@@ -121,49 +275,124 @@ class MainActivity : ComponentActivity() {
}
@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,
+ 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")
+ }
}
-}
\ No newline at end of file
+}
+
+@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)
+}
diff --git a/app/src/main/java/me/kdcf/aimereader/SavedCard.kt b/app/src/main/java/me/kdcf/aimereader/SavedCard.kt
new file mode 100644
index 0000000..1739c36
--- /dev/null
+++ b/app/src/main/java/me/kdcf/aimereader/SavedCard.kt
@@ -0,0 +1,35 @@
+package me.kdcf.aimereader
+
+import android.os.Parcel
+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(" ")
+ }
+
+ fun getDisplay(): String {
+ if (friendlyName.isEmpty()) {
+ return getSpaced()
+ } else {
+ return friendlyName
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/values-b+ang/strings.xml b/app/src/main/res/values-b+ang/strings.xml
deleted file mode 100644
index 0493e42..0000000
--- a/app/src/main/res/values-b+ang/strings.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
- Aimeth Readere
- Aimeth carde emulatorth servicere
- Pleaseth Scaneth thy Carde
- Waitingeth…
- Faileth to Reade the Carde
- Pleaseth trye againe or reporteth a bugue
- Copyeth thy code
- Copyeth thy code withoute Spacese
- Copyeth thy code withoute Zeroes
-
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 340d9df..181c9de 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -8,4 +8,6 @@
Copy Code
Copy Without Spaces
Copy Without Zeroes
+ No cards.
+ Scan a card or press the add button.
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 6b40cdb..ec3bd51 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 009a091..f05c4ff 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -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