Add file selector

This commit is contained in:
2024-10-28 18:53:09 +01:00
parent 4ea09c5beb
commit bb282acf44
13 changed files with 265 additions and 67 deletions

View File

@@ -2,6 +2,9 @@
<project version="4">
<component name="deploymentTargetDropDown">
<value>
<entry key="Reset">
<State />
</entry>
<entry key="app">
<State />
</entry>

View File

@@ -76,6 +76,9 @@ dependencies {
implementation("androidx.wear.compose:compose-foundation:1.2.1")
implementation("androidx.activity:activity-compose:1.8.2")
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.preference:preference:1.2.1")
implementation("com.google.android.material:material:1.4.0")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")

View File

@@ -4,8 +4,12 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.type.watch" android:required="true"/>
<uses-feature android:name="android.hardware.nfc.hce" android:required="true"/>
<uses-feature
android:name="android.hardware.type.watch"
android:required="true" />
<uses-feature
android:name="android.hardware.nfc.hce"
android:required="true" />
<application
android:allowBackup="true"
@@ -13,6 +17,26 @@
android:label="@string/app_name"
android:supportsRtl="false"
android:theme="@android:style/Theme.DeviceDefault">
<activity
android:name=".presentation.MainActivity"
android:exported="true"
android:taskAffinity=""
android:theme="@style/MainActivityTheme.Starting">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".presentation.SettingsActivity"
android:exported="false"
android:label="@string/title_activity_settings"
android:theme="@style/Theme.AppCompat.NoActionBar"
android:taskAffinity="">
</activity>
<service
android:name=".MyHostApduService"
android:exported="true"
@@ -37,17 +61,7 @@
android:name="com.google.android.wearable.standalone"
android:value="true" />
<activity
android:name=".presentation.MainActivity"
android:exported="true"
android:taskAffinity=""
android:theme="@style/MainActivityTheme.Starting">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -10,12 +10,15 @@ import android.nfc.NdefRecord
import android.nfc.cardemulation.HostApduService
import android.os.Bundle
import android.util.Log
import androidx.preference.PreferenceManager
import org.xmlpull.v1.XmlPullParser
import java.io.File
import java.io.UnsupportedEncodingException
import java.math.BigInteger
import java.util.LinkedList
import java.util.Stack
import kotlin.math.max
import kotlin.math.min
class MyHostApduService : HostApduService() {
@@ -32,15 +35,18 @@ class MyHostApduService : HostApduService() {
0x82.toByte(), // SW2 Status byte 2 - Command processing qualifier
)
private var selectedNdefFile = "0000"
private var selectedNdefFile = 0
private val selectedNdefFilePath get() = String.format("%04X", this.selectedNdefFile)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(TAG, "onStartCommand()")
selectedNdefFile = "0000"
selectedNdefFile = 0
return Service.START_STICKY
}
@OptIn(ExperimentalStdlibApi::class)
override fun processCommandApdu(commandApdu: ByteArray, extras: Bundle?): ByteArray {
//
// The following flow is based on Appendix E "Example of Mapping Version 2.0 Command Flow"
@@ -67,18 +73,21 @@ class MyHostApduService : HostApduService() {
when (apduDissect["Data"]) {
"E103" -> { //CCFile
Log.i(TAG, "NDEF file " + apduDissect["Data"] + "(CCF) selected")
selectedNdefFile = apduDissect["Data"].toString()
return A_OKAY
}
"E104" -> {
Log.i(TAG, "NDEF file " + apduDissect["Data"] + "(NDEF File) selected")
selectedNdefFile = apduDissect["Data"].toString()
selectedNdefFile = apduDissect["Data"].toString().hexToInt()
return A_OKAY
}
else -> {
Log.w(TAG, "NDEF file " + apduDissect["Data"] + "select request denied")
if(apduDissect["Data"]!!.toInt(16) < 0xE1FF) {
Log.i(
TAG,
"NDEF file " + apduDissect["Data"] + "(NDEF File) selected"
)
selectedNdefFile = apduDissect["Data"].toString().hexToInt()
return A_OKAY
}
Log.w(TAG, "NDEF file " + apduDissect["Data"] + " select request denied")
return A_ERROR
}
}
@@ -92,19 +101,19 @@ class MyHostApduService : HostApduService() {
}
"B0" -> { //Read data
if (selectedNdefFile == "E103") { //CCfile
if (selectedNdefFile == 0xE103) { //CCfile
Log.i(TAG, "CCfile read")
//TODO do better here
return makeCCFileApdu()
} else if (selectedNdefFile == "E104") {
} else if ((selectedNdefFile >= 0xE104) and (selectedNdefFile <= 0xE1FF)) {
val offset = apduDissect["P1"].plus(apduDissect["P2"]).toInt(16)
val length = apduDissect["Le"]?.toInt(16) ?: 0
val ndefFileContent: ByteArray = ByteArray(length + 2)
val ndefFile = File(applicationContext.filesDir, selectedNdefFile)
val ndefFileContent = ByteArray(length + 2)
val ndefFile = File(applicationContext.filesDir, this.selectedNdefFilePath)
if (!ndefFile.exists()) {
applicationContext.openFileOutput(selectedNdefFile, Context.MODE_PRIVATE)
applicationContext.openFileOutput(this.selectedNdefFilePath, Context.MODE_PRIVATE)
.use {
val ndefMsg = NdefMessage(createUrlRecord("https://example.com"))
val ndefBytes = ndefMsg.toByteArray()
@@ -116,7 +125,7 @@ class MyHostApduService : HostApduService() {
it.write(ndefBytes)
}
}
applicationContext.openFileInput(selectedNdefFile).use {
applicationContext.openFileInput(this.selectedNdefFilePath).use {
it.skip(offset.toLong())
it.read(ndefFileContent, 0, length)
}
@@ -139,16 +148,16 @@ class MyHostApduService : HostApduService() {
val ccFile = parseCCFile()
val ndefFileContent: ByteArray =
val ndefFileContent =
ByteArray(Integer.decode(ccFile["NDEF_FILE_SIZE"] ?: "0000"))
val ndefFile = File(applicationContext.filesDir, selectedNdefFile)
val ndefFile = File(applicationContext.filesDir, this.selectedNdefFilePath)
if (ndefFile.exists()) {
applicationContext.openFileInput(selectedNdefFile).use {
applicationContext.openFileInput(this.selectedNdefFilePath).use {
it.read(ndefFileContent, 0, ndefFileContent.size)
}
}
commandApdu.copyInto(ndefFileContent, offset, 5)
applicationContext.openFileOutput(selectedNdefFile, Context.MODE_PRIVATE).use {
applicationContext.openFileOutput(this.selectedNdefFilePath, Context.MODE_PRIVATE).use {
it.write(ndefFileContent)
}
return A_OKAY
@@ -195,6 +204,7 @@ class MyHostApduService : HostApduService() {
return msg
}
@OptIn(ExperimentalStdlibApi::class)
private fun parseCCFile(): LinkedHashMap<String, String> {
val xrp = resources.getXml(R.xml.ccfile)
val parentTag = Stack<String>()
@@ -220,10 +230,15 @@ class MyHostApduService : HostApduService() {
}
xrp.next()
}
var id = ccHashMap["NDEF_FILE_ID"]?.drop(2)?.hexToInt() ?: 0xE104
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
id += sharedPreferences.getString("ndef_file_id","1")!!.toInt() - 1
id = min(max(id,0xE104),0xE1FF)
ccHashMap["NDEF_FILE_ID"] = String.format("0x%04X",id)
return ccHashMap
}
@OptIn(ExperimentalStdlibApi::class)
private fun parseApdu(commandApdu: ByteArray): HashMap<String, String> {
val dissected = HashMap<String, String>()
dissected["CLA"] = commandApdu.copyOfRange(0, 1).toHex()
@@ -240,7 +255,7 @@ class MyHostApduService : HostApduService() {
override fun onDeactivated(reason: Int) {
Log.i(TAG, "onDeactivated() Fired! Reason: $reason")
selectedNdefFile = "0000"
selectedNdefFile = 0
}
private val HEX_CHARS = "0123456789ABCDEF".toCharArray()
@@ -259,20 +274,6 @@ class MyHostApduService : HostApduService() {
return result.toString()
}
fun String.hexStringToByteArray(): ByteArray {
val result = ByteArray(length / 2)
for (i in indices step 2) {
val firstIndex = HEX_CHARS.indexOf(this[i])
val secondIndex = HEX_CHARS.indexOf(this[i + 1])
val octet = firstIndex.shl(4).or(secondIndex)
result[i.shr(1)] = octet.toByte()
}
return result
}
private fun createTextRecord(language: String, text: String, id: ByteArray): NdefRecord {
val languageBytes: ByteArray
val textBytes: ByteArray
@@ -316,15 +317,3 @@ class MyHostApduService : HostApduService() {
}
}
@OptIn(ExperimentalStdlibApi::class)
private fun Int.toByteArray(len: Int = 1): ByteArray {
var str = toHexString(HexFormat.UpperCase).drop(2)
str = str.padStart(len * 2, '0').takeLast(len * 2)
val res = ByteArray(len)
var i = 0
str.chunked(2).forEach {
res[i] = Integer.decode("0x$it").toByte()
i++
}
return res
}

View File

@@ -6,9 +6,11 @@
package fr.ar2000.ndefemulator.presentation
import android.content.Intent
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -21,24 +23,37 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.preference.PreferenceManager
import androidx.wear.compose.material.MaterialTheme
import androidx.wear.compose.material.Text
import androidx.wear.compose.material.TimeText
import fr.ar2000.ndefemulator.R
import fr.ar2000.ndefemulator.presentation.theme.NDEFEmulatorTheme
class MainActivity : ComponentActivity() {
class MainActivity : ComponentActivity(R.layout.main_activity) {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
setTheme(android.R.style.Theme_DeviceDefault)
setContent {
WearApp()
val settingButton = findViewById<Button>(R.id.setting_button)
settingButton.setOnClickListener {
val intent = Intent(this, SettingsActivity::class.java)
startActivity(intent)
}
}
override fun onResume() {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
super.onResume()
val currentFileId = findViewById<TextView>(R.id.current_file_id)
currentFileId.text = getString(
R.string.current_file_id,
sharedPreferences.getString("ndef_file_id","1")
)
}
}
@Composable

View File

@@ -0,0 +1,58 @@
package fr.ar2000.ndefemulator.presentation
import android.os.Bundle
import android.text.TextUtils
import android.view.LayoutInflater
import android.widget.EditText
import androidx.activity.ComponentActivity
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentContainerView
import androidx.fragment.app.findFragment
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager
import fr.ar2000.ndefemulator.R
class SettingsActivity : FragmentActivity(R.layout.settings_activity) {
override fun onCreate(savedInstanceState: Bundle?) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
super.onCreate(savedInstanceState)
//supportFragmentManager.beginTransaction().replace(R.id.fragmentContainerView,MySettingsFragment()).commit()
val fileIdInput = findViewById<EditText>(R.id.ndef_file_id)
fileIdInput.setOnFocusChangeListener { v, hasFocus ->
if(!hasFocus){
saveSetting()
}
}
if(savedInstanceState == null) {
fileIdInput.setText(sharedPreferences.getString("ndef_file_id","1"))
}
}
private fun saveSetting() {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
val fileIdInput = findViewById<EditText>(R.id.ndef_file_id)
val currentFileID = sharedPreferences.getString("ndef_file_id","1")
val newFileID = fileIdInput.text.toString()
if(TextUtils.isDigitsOnly((newFileID)) and (newFileID.toInt() > 0) and (newFileID.toInt() <= 252)) {
sharedPreferences.edit().putString("ndef_file_id", newFileID).apply()
}else{
fileIdInput.setText(currentFileID)
}
}
override fun onPause() {
super.onPause()
saveSetting()
}
}
class MySettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.preferences,rootKey)
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/hello_world"
android:textAppearance="@style/TextAppearance.AppCompat.Large" />
<TextView
android:id="@+id/current_file_id"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Current file id : 1" />
<Button
android:id="@+id/setting_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings" />
</LinearLayout>

View File

@@ -0,0 +1,44 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/textView4"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="NDEF file id"
android:textAlignment="center" />
<EditText
android:id="@+id/ndef_file_id"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ems="10"
android:inputType="number"
android:text="1"
android:textAlignment="center" />
</LinearLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragmentContainerView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">NDEF emulator</string>
<string name="hello_world">Emulation d\'étiquette NDEF</string>
<string name="servicedesc">Service démulation NFC</string>
<string name="title_activity_settings">Options</string>
<string name="settings">Options</string>
<string name="current_file_id">Fichier NDEF : %S</string>
</resources>

View File

@@ -0,0 +1,12 @@
<resources>
<!-- Reply Preference -->
<string-array name="reply_entries">
<item>Reply</item>
<item>Reply to all</item>
</string-array>
<string-array name="reply_values">
<item>reply</item>
<item>reply_all</item>
</string-array>
</resources>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="NDEF_AID">D2760000850101</string>
<string name="NDEF_AID" translatable="false">D2760000850101</string>
</resources>

View File

@@ -2,5 +2,14 @@
<string name="app_name">NDEF emulator</string>
<string name="hello_world">Emulating NDEF tag</string>
<string name="servicedesc">NFC emulator service</string>
<string name="aiddescription">NDEF</string>
<string name="aiddescription" translatable="false">NDEF</string>
<string name="title_activity_settings">Settings</string>
<!-- Preference Titles -->
<!-- Messages Preferences -->
<!-- Sync Preferences -->
<string name="settings">Settings</string>
<string name="current_file_id">Current file id : %S</string>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<EditTextPreference
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:defaultValue="1"
android:selectAllOnFocus="true"
android:title="NDEF File id"
android:inputType="number"
app:key="ndef_file_id" />
</PreferenceScreen>