Compare commits
2 Commits
d9218af808
...
de97b9b7e1
Author | SHA1 | Date | |
---|---|---|---|
de97b9b7e1 | |||
6481878cee |
27
.idea/deploymentTargetSelector.xml
generated
27
.idea/deploymentTargetSelector.xml
generated
@ -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
4
.idea/gradle.xml
generated
@ -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" />
|
||||
|
4
.idea/inspectionProfiles/Project_Default.xml
generated
4
.idea/inspectionProfiles/Project_Default.xml
generated
@ -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" />
|
||||
|
4
.idea/runConfigurations.xml
generated
4
.idea/runConfigurations.xml
generated
@ -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
1
.idea/vcs.xml
generated
@ -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>
|
@ -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)
|
||||
|
159
app/src/main/java/me/kdcf/aimereader/CardDialog.kt
Normal file
159
app/src/main/java/me/kdcf/aimereader/CardDialog.kt
Normal 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 = {})
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
39
app/src/main/java/me/kdcf/aimereader/SavedCard.kt
Normal file
39
app/src/main/java/me/kdcf/aimereader/SavedCard.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
@ -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>
|
@ -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" }
|
4
gradle/wrapper/gradle-wrapper.properties
vendored
4
gradle/wrapper/gradle-wrapper.properties
vendored
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user