First off, we have a notification when we receive an APDU, but we can't see it. Let's improve our tracing and see if it comes out - and, if so, what data we are receiving.
public byte[] processCommandApdu(byte[] apdu, Bundle extras) {
StringBuilder sb = new StringBuilder("apdu = ");
for (int i=0;i<apdu.length;i++) {
int x = ((int)apdu[i])&0xff;
String s = Integer.toHexString(x);
if (s.length() < 2)
sb.append("0");
sb.append(s);
sb.append(" ");
}
Log.w("service", sb.toString());
showNotification();
return new byte[] { (byte)0x90, 0x00 }; // APDU "OK"
}
ANDROID_APDU_TRACING:android_hce/app/src/main/java/com/example/android_hce/ReceiveReceiptService.java
Basically, this just takes the string of bytes, converts it into a hex string and logs it. A bit more complicated than doing the same thing in Go :-(This does get sent to LogCat:
apdu = 00 a4 04 00 10 f1 67 6d 6d 61 70 6f 77 65 6c 6c 2d 61 70 70 31 00This raises a lot of questions for me, and taxes my mental model generator.
apdu = ff d6 11 01 16 15 57 65 6c 63 6f 6d 65 20 74 6f 20 48 65 63 73 6b 64 76 71 72 64
apdu = ff d6 12 01 09 03 4d 49 44 04 37 39 36 37
apdu = ff d6 21 01 0d 08 48 61 72 64 77 61 72 65 00 00 00 7f
apdu = ff d6 21 02 0d 08 43 6c 6f 74 68 69 6e 67 00 00 00 49
apdu = ff d6 21 03 0d 08 43 6c 6f 74 68 69 6e 67 00 00 00 96
apdu = ff d6 22 01 05 00 00 00 00 00
apdu = ff d6 22 02 05 00 00 00 00 00
apdu = ff d6 21 04 0d 08 43 6c 6f 74 68 69 6e 67 00 00 00 68
apdu = ff d6 21 05 0c 07 47 72 6f 63 65 72 79 00 00 00 37
apdu = ff d6 31 01 0d 08 53 75 62 54 6f 74 61 6c 00 00 01 fd
apdu = ff d6 32 01 0e 09 44 69 73 63 6f 75 6e 74 73 00 00 00 00
apdu = ff d6 33 01 12 0d 49 6e 63 6c 75 64 69 6e 67 20 56 41 54 00 00 00 65
apdu = ff d6 3f 01 0a 05 54 6f 74 61 6c 00 00 01 fd
apdu = ff d6 41 01 0a 05 50 48 4f 4e 45 00 00 01 fd
apdu = ff d6 51 01 0a 09 54 68 61 6e 6b 20 79 6f 75
apdu = ff d6 00 00 01 00
First off, I'm somewhat surprised to see that the first message I receive is the "SELECT AID" message. Having said that, I think I was grateful when I first started sending messages that I received it, because otherwise I probably wouldn't have seen anything. And when I think about it, the description of the configuration makes it plain you can handle multiple AIDs in the same service.
Secondly, each of these messages are coming separately, which presumably means somewhere somebody knows what these messages are and where they "break" - what belongs to one message and what belongs to the next. That being the case, I'm surprised that I just get "a string of bytes" and not something more structured.
Thirdly, I was expecting to have some kind of session identifier, but I think that just reflects the amount of time I spend doing server software. This kind of communication is explicitly half-duplex and a session ends with an explicit call to onDeactivated. I believe, although I never entirely trust what I believe, that the service object itself is long-lived: that is, we can assume that the service object will exist "for all time" and certainly that it will be around for the entire length of the communication. Thus, we can store any data we want to track on the object.
Adding a Session
So the first thing I'm going to do is to add something that handles a "Session" which is basically something that exists for the lifetime of reading one Receipt. It's possible we could have different types of sessions (for different AIDs or whatever), so I'm going to over-architect this and define an interface SessionController and then implement this with a ReceiptSessionController.Each time processCommandApdu is called, I will start by converting the incoming bytes to an APDUCommand. If the command is a SELECT command, an appropriate SessionController will be instantiated, and then all the subsequent APDUs will be directed to that, until such time as it says "job done", when I will clean it up, set the state back to null and issue a notification. If onDeactivated is called before this happens, I will just clean it up and set the state back to null: any work in progress will simply be abandoned.
All Clear? Let's get started!
All the "changes" here happen in ReceiveReceiptService, but it's basically a rewrite so I'll show the whole thing here:
package com.example.android_hce;
import android.app.NotificationManager;
import android.content.Context;
import android.nfc.cardemulation.HostApduService;
import android.os.Bundle;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import com.example.android_hce.apdu.APDUCommand;
import com.example.android_hce.apdu.APDUCommandProcessor;
import com.example.android_hce.apdu.APDUResponse;
import com.example.android_hce.apdu.APDUSelectCommand;
public class ReceiveReceiptService extends HostApduService {
APDUCommandProcessor processor = new APDUCommandProcessor();
SessionController controller = null;
public ReceiveReceiptService() {
}
@Override
public byte[] processCommandApdu(byte[] apdu, Bundle extras) {
try {
processor.LogInput(apdu);
APDUCommand cmd = processor.parse(apdu);
if (cmd == null) {
return new byte[] { (byte) 0x69, (byte)0x00 }; // APDU "Command not allowed"
}
if (cmd instanceof APDUSelectCommand) {
controller = ((APDUSelectCommand)cmd).initiate(this);
}
if (controller == null) { // no current application selected - is there a more precise code?
return new byte[] { (byte) 0x6f, (byte) 0x00 }; // APDU "command aborted - OS Error?"
}
APDUResponse response = controller.dispatch(cmd);
if (response.endSession())
controller = null;
return response.asBytes();
} catch (Throwable t) {
return new byte[] { (byte) 0x6f, (byte) 0x00 }; // APDU "command aborted - OS Error?"
}
}
@Override
public void onDeactivated(int reason) {
Log.w("service", "lost contact with card: " + reason);
if (controller != null) {
controller = null;
}
}
}
ANDROID_APDU_DISPATCHING:android_hce/app/src/main/java/com/example/android_hce/ReceiveReceiptService.java
We start off by creating a nested APDUCommandProcessor, which is responsible for dealing with the input bytes and turning them into APDUCommand objects. This is somewhat overkill at the moment, as we only have two commands, but as I said above, I'm over-engineering this because I can.The first thing we do on receipt of a new packet is to ask the processor to log it - this is exactly the same code we added above; I've just moved it.
Then we ask the processor to try and parse the command - that is, to turn the command from a string of bytes into a usable class. Note that the expectation is that it will read all the bytes and build a complete command, even if it does not understand what the contents of the input buffer mean.
public APDUCommand parse(byte[] apdu) {
byte clz = apdu[0];
byte inst = apdu[1];
APDUCommand ret = figureCommand(clz, inst);
if (ret == null) {
return null;
}
byte p1 = apdu[2];
byte p2 = apdu[3];
ret.params(p1, p2);
AtomicInteger pos = new AtomicInteger(4);
byte[] in = null, out = null;
if (ret.wantsInput()) {
int xx = readLength(apdu, pos);
Log.w("processor", "input length = " + xx);
in = new byte[xx];
for (int i=0;i<xx;i++) {
in[i] = apdu[pos.getAndIncrement()];
}
}
if (ret.wantsOutputLength()) {
int s = apdu[pos.getAndIncrement()];
if (s != 0) {
Log.w("processor", "allocating " + s + " bytes for response");
out = new byte[s];
}
}
if (pos.get() != apdu.length) {
throw new RuntimeException("incorrect length: read " + pos.get() + " != " + apdu.length);
}
ret.buffers(in, out);
return ret;
}
ANDROID_APDU_DISPATCHING:android_hce/app/src/main/java/com/example/android_hce/apdu/APDUCommandProcessor.java
I want to be clear that I'm not at all convinced that this in completely correct. If I wanted to be sure, I would need to read the spec in a lot more detail than I have, and obviously I would want to encase it in unit tests. On the upside, as it is written it is easily testable, so if this were ever to become a serious project, that would be one of the tasks needing attention. But for now, it appears to work based on the logging output I see.Once we have a command, we check if it is a SELECT command, in which case we ask it to initiate a new controller. Probably, this should also check if there is already one initiated and close it, but again this is not something that should come up for now.
We then call dispatch on the controller with the new command, which returns a response which contains two pieces of information: whether the session has ended, and the response bytes. If the session has ended, we set the controller to null and finally we return the response.
Note that theoretically it would be possible to have an actual response message along with the response code, but that is not the case for either of the messages we are working with here.
The final class I'm going to talk through is the APDUUpdateBinaryCommand. Each of these represents the encoded value of one line of the receipt. If the value of p1 is 0, that indicates that the receipt is complete and the controller should execute any commands it has collected together and the session should then end; otherwise, this is a line of the receipt to be processed. We will come back to the processing and execution in the following sections.
package com.example.android_hce.apdu;
import android.util.Log;
import com.example.android_hce.SessionController;
public class APDUUpdateBinaryCommand extends APDUBaseCommand {
APDUUpdateBinaryCommand() {
super(true, false);
}
@Override
public APDUResponse dispatch(SessionController controller) {
Log.w("select", "update binary on " + Integer.toHexString(p1) + ":" + Integer.toHexString(p2));
if (p1 == 0) {
// tell the controller that we have read everything and are ready to execute it
controller.executeCommand();
// this is a close, so close
return new APDUResponse(true, (byte)0x90, (byte)0x00);
}
controller.updateBinary(p1, p2, input);
return new APDUResponse(false, (byte)0x90, (byte)0x00);
}
}
ANDROID_APDU_DISPATCHING:android_hce/app/src/main/java/com/example/android_hce/apdu/APDUUpdateBinaryCommand.java
I don't think any of the rest of the code is really that interesting.Processing a Receipt
The intention here is to have a clear separation between the recognition and parsing of the APDU commands and the actual logical flow of the instructions. We have seen the individual byte streams being turned into commands and then those commands being invoked against a SessionController. It is the job of each individual SessionController to define how the application works.In this case we have created a ReceiptSessionController and it has a method updateBinary which is called with the p1 and p2 values along with the data buffer contents embedded within an APDU UPDATE BINARY command. It is up to the controller to decide what to do with it. There is also an executeCommand stub which we will complete in the next section.
The boilerplate needed to get to this point looks like this:
package com.example.android_hce;
import android.app.NotificationManager;
import android.content.Context;
import androidx.core.app.NotificationCompat;
import com.example.android_hce.apdu.APDUCommand;
import com.example.android_hce.apdu.APDUResponse;
public class ReceiptSessionController implements SessionController {
Context context;
public ReceiptSessionController(Context context) {
this.context = context;
}
@Override
public APDUResponse dispatch(APDUCommand cmd) {
return cmd.dispatch(this);
}
@Override
public void updateBinary(int p1, int p2, byte[] data) {
// This needs to collect all these messages for later processing
}
@Override
public void executeCommand() {
// take all the messages we collected and build a receipt in the db
// then issue a notification
showNotification();
}
private void showNotification() {
int notificationId = 99; // is this more a constant or more a per request ID?
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationCompat.Builder notificationBuilder =
new NotificationCompat.Builder(context, ReceiptApplication.CHANNEL_ID)
.setSmallIcon(R.drawable.credit_card)
.setContentTitle("NFC")
.setContentText("NFC Contact Made")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_STATUS);
notificationManager.notify(notificationId, notificationBuilder.build());
}
}
ANDROID_APDU_DISPATCHING:android_hce/app/src/main/java/com/example/android_hce/ReceiptSessionController.java
(The showNotification code was moved from the ReceiveReceiptService earlier.)Temporarily storing the data
As each record comes in, we store it in a List of WireBlocks. If we were developing both sides in the same language, we could reuse our existing definition of WireBlock, but sadly, we aren't. And, yes, this was a deliberate choice at the beginning of the project: I knew the phone application would have to be in Java.package com.example.android_hce.receipt;
public class WireBlock {
private final int p1;
private final int p2;
private final byte[] data;
public WireBlock(int p1, int p2, byte[] data) {
this.p1 = p1;
this.p2 = p2;
this.data = data;
}
}
ANDROID_SESSION_STORAGE:android_hce/app/src/main/java/com/example/android_hce/receipt/WireBlock.java
And then in the session controller, we can keep a list of these as they come in:public void updateBinary(int p1, int p2, byte[] data) {
blocks.add(new WireBlock(p1, p2, data));
}
ANDROID_SESSION_STORAGE:android_hce/app/src/main/java/com/example/android_hce/ReceiptSessionController.java
SQLite
We now come to the executeCommand step. My original thought had been to just turn the receipt back into text form here and display it. But because we can't invoke an Intent directly, it seems that the only reasonable way of doing this is to store the receipt in the database, use the unique ReceiptID as the notification ID and then have the activity that is launched by the notification pull all the information back from the database to create the text to display.Let's get started. There is a lot of going through the motions here with regard to SQLite, and I am basically following the instructions in the Android documentation. Because of that, and because this is really not all that related to what I'm trying to experiment with here, I will be light on details although, as I've never done any of these specific things before, I may comment on things I run into that I don't feel are adequately covered in the documentation.
One thing that I will comment on is that it is very hard to test code that depends on a third-party library like this. Depending on what your setup is, it may or may not be repeatable, for a start. And you may or may not have the relevant libraries on your development machine. For now, I am not going to worry about that.
All in all, I want to be clear that this whole thing is a fairly non-serious solution to the problem: as one example, I use the "just delete the database and start again" approach to schema changes. I'm not really sold on the idea of using a relational database at all for this application, although it is a very easy fit for the data I am receiving and storing as it is already basically in a tabular form. My main motivation is simply that it is available.
So here is my version of the "database contract":
package com.example.android_hce.db;
import android.provider.BaseColumns;
public class ReceiptDatabaseContract {
private ReceiptDatabaseContract() {
}
public static class ReceiptEntry implements BaseColumns {
public final static String TABLE_NAME = "receipt";
public final static String COLUMN_NAME_MERCHANT = "merchant";
public final static String COLUMN_NAME_TOTAL = "total";
}
public static class ReceiptLineEntry implements BaseColumns {
public final static String TABLE_NAME = "receipt_line";
public final static String COLUMN_NAME_RECEIPT = "receipt_id";
public final static String COLUMN_NAME_TYPE = "type";
public final static String COLUMN_NAME_INDEX = "index";
}
public static class ReceiptLineCommentEntry implements BaseColumns {
public final static String TABLE_NAME = "receipt_line_comments";
public final static String COLUMN_NAME_ENTRY = "entry_id";
public final static String COLUMN_NAME_TYPE = "type";
public final static String COLUMN_NAME_INDEX = "index";
}
}
ANDROID_HCE_SQLITE:android_hce/app/src/main/java/com/example/android_hce/db/ReceiptDatabaseContract.java
and the "helper":package com.example.android_hce.db;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import com.example.android_hce.db.ReceiptDatabaseContract.ReceiptEntry;
import com.example.android_hce.db.ReceiptDatabaseContract.ReceiptLineEntry;
import com.example.android_hce.db.ReceiptDatabaseContract.ReceiptLineCommentEntry;
public class ReceiptDatabaseHelper extends SQLiteOpenHelper {
private static final String SQL_CREATE_RECEIPTS =
"CREATE TABLE " + ReceiptEntry.TABLE_NAME + " (" +
ReceiptEntry._ID + " INTEGER PRIMARY KEY," +
ReceiptEntry.COLUMN_NAME_MERCHANT + " TEXT," +
ReceiptEntry.COLUMN_NAME_TOTAL + " INTEGER)";
private static final String SQL_DELETE_RECEIPTS =
"DROP TABLE IF EXISTS " + ReceiptEntry.TABLE_NAME;
private static final String SQL_CREATE_ITEM_LINES =
"CREATE TABLE " + ReceiptLineEntry.TABLE_NAME + " (" +
ReceiptLineEntry._ID + " INTEGER PRIMARY KEY," +
ReceiptLineEntry.COLUMN_NAME_RECEIPT + " INTEGER," +
ReceiptLineEntry.COLUMN_NAME_INDEX + " INTEGER," +
ReceiptLineEntry.COLUMN_NAME_TYPE + " INTEGER)";
private static final String SQL_DELETE_ITEM_LINES =
"DROP TABLE IF EXISTS " + ReceiptLineEntry.TABLE_NAME;
private static final String SQL_CREATE_ITEM_COMMENTS =
"CREATE TABLE " + ReceiptLineCommentEntry.TABLE_NAME + " (" +
ReceiptLineCommentEntry._ID + " INTEGER PRIMARY KEY," +
ReceiptLineCommentEntry.COLUMN_NAME_ENTRY + " INTEGER," +
ReceiptLineCommentEntry.COLUMN_NAME_INDEX + " INTEGER," +
ReceiptLineCommentEntry.COLUMN_NAME_TYPE + " INTEGER)";
private static final String SQL_DELETE_ITEM_COMMENTS =
"DROP TABLE IF EXISTS " + ReceiptLineCommentEntry.TABLE_NAME;
// If you change the database schema, you must increment the database version.
public static final int DATABASE_VERSION = 1;
public static final String DATABASE_NAME = "Receipts.db";
public ReceiptDatabaseHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
public void onCreate(SQLiteDatabase db) {
db.execSQL(SQL_CREATE_RECEIPTS);
db.execSQL(SQL_CREATE_ITEM_LINES);
db.execSQL(SQL_CREATE_ITEM_COMMENTS);
}
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
// This database is only a cache for online data, so its upgrade policy is
// to simply to discard the data and start over
db.execSQL(SQL_DELETE_ITEM_COMMENTS);
db.execSQL(SQL_DELETE_ITEM_LINES);
db.execSQL(SQL_DELETE_RECEIPTS);
onCreate(db);
}
public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
onUpgrade(db, oldVersion, newVersion);
}
}
ANDROID_HCE_SQLITE:android_hce/app/src/main/java/com/example/android_hce/db/ReceiptDatabaseHelper.java
and I added this code to the session controller to add a row for each receipt coming in:public void executeCommand() {
try {
Log.w("receipt", "in executeCommand");
// take all the messages we collected and build a receipt in the db
SQLiteDatabase db = dbHelper.getWritableDatabase();
ContentValues row = new ContentValues();
row.put(ReceiptDatabaseContract.ReceiptEntry.COLUMN_NAME_MERCHANT, "hello, world");
row.put(ReceiptDatabaseContract.ReceiptEntry.COLUMN_NAME_TOTAL, 420);
long newRowId = db.insert(ReceiptDatabaseContract.ReceiptEntry.TABLE_NAME, null, row);
if (newRowId == -1) {
Log.e("receipt", "could not insert the data for some reason");
return;
}
// then issue a notification
showNotification(newRowId);
} catch (Throwable t) {
Log.w("receipt", t);
return;
}
}
ANDROID_HCE_SQLITE:android_hce/app/src/main/java/com/example/android_hce/ReceiptSessionController.java
Sadly, my first cut at this code did not work. I had a bunch of messages that had something to do with dalvik and threadNotify, but nothing useful. I updated the code to add more logging and to catch all Throwables, and finally managed to get this to come out:android.database.sqlite.SQLiteException: near "index": syntax error (code 1 SQLITE_ERROR): , while compiling: CREATE TABLE receipt_line (_id INTEGER PRIMARY KEY,receipt_id INTEGER,index INTEGER,type INTEGER)Ah, yes, index is an SQL keyword; I'd forgotten that, so I'd better not use it. (Apologies, I did a lot with SQL from 1995-2010, but I've basically been doing NoSQL since then. I've probably forgotten more than I ever knew at this point.)
at android.database.sqlite.SQLiteConnection.nativePrepareStatement(Native Method)
at android.database.sqlite.SQLiteConnection.-$Nest$smnativePrepareStatement(Unknown Source:0)
at android.database.sqlite.SQLiteConnection$PreparedStatementCache.createStatement(SQLiteConnection.java:1576)
at android.database.sqlite.SQLiteConnection.acquirePreparedStatementLI(SQLiteConnection.java:1111)
at android.database.sqlite.SQLiteConnection.acquirePreparedStatement(SQLiteConnection.java:1139)
at android.database.sqlite.SQLiteConnection.prepare(SQLiteConnection.java:699)
at android.database.sqlite.SQLiteSession.prepare(SQLiteSession.java:625)
at android.database.sqlite.SQLiteProgram.<init>(SQLiteProgram.java:62)
at android.database.sqlite.SQLiteStatement.<init>(SQLiteStatement.java:34)
at android.database.sqlite.SQLiteDatabase.executeSql(SQLiteDatabase.java:2234)
at android.database.sqlite.SQLiteDatabase.execSQL(SQLiteDatabase.java:2154)
at com.example.android_hce.db.ReceiptDatabaseHelper.onCreate(ReceiptDatabaseHelper.java:49)
at android.database.sqlite.SQLiteOpenHelper.getDatabaseLocked(SQLiteOpenHelper.java:410)
at android.database.sqlite.SQLiteOpenHelper.getWritableDatabase(SQLiteOpenHelper.java:316)
Of course, now I'm in an inconsistent state, where one of the tables has been created and the other two haven't. I think the easiest thing to do is to bump the version number on the database to 2, which will allegedly cause it to try and drop all the tables that exist and then move on. But that seems dishonest (and avoids an opportunity to learn something), so let me see if there is a tool I can use to deal with SQLite from the host. Based on this article from StackOverflow, it would seem I can clean up like this:
$ adb shell "run-as com.example.android_hce ls /data/data/com.example.android_hce/databases"When I run my application again, it works and sends me a notification. I don't know, of course, that it actually "worked": did it store anything in the database? Can I check that on the command line?
Receipts.db
Receipts.db-journal
$ adb shell "run-as com.example.android_hce rm /data/data/com.example.android_hce/databases/Receipts.db"
$ adb shell "run-as com.example.android_hce rm /data/data/com.example.android_hce/databases/Receipts.db-journal"
Allegedly, yes, although unfortunately this just gives me the error "sqlite3: not found". Oh, well. At the end it gives an alternative strategy to read the phone db from sqlite3 on my box. As this is Linux, that is already installed for me. Argh, that ends up in "permission denied".
$ adb pull /data/data/com.example.android_hce/databases/Receipts.dbOK, google, help me out. "Why don't you try this?". Why, thank you, Google, I will:
adb: error: failed to stat remote object '/data/data/com.example.android_hce/databases/Receipts.db': Permission denied
$ adb -d shell 'run-as com.example.android_hce cat /data/data/com.example.android_hce/databases/Receipts.db' > Receipts.dbVery good. Things appear to be "basically" working. Let's go back and store the correct data from the input and all the lines and comments.
$ ls -l Receipts.db
-rw-rw-r-- 1 gareth gareth 24576 Jan 20 14:06 Receipts.db
$ sqlite3 Receipts.db
SQLite version 3.44.3 2024-03-24 21:15:01
Enter ".help" for usage hints.
sqlite> select * from Receipt;
1|hello, world|420
This is so boring I'm not even sure I can bother to show the code. I had to go back and fix a number of things on the Go side as well which I'd forgotten or done wrong.
Suffice it to say that everything is checked in as ANDROID_HCE_SQLITE_FINISHED.
No comments:
Post a Comment