package org.irmacard.androidcardproxy;
import java.io.StringReader;
import java.io.UnsupportedEncodingException;
import java.util.Timer;
import java.util.TimerTask;
import net.sf.scuba.smartcards.CardServiceException;
import net.sf.scuba.smartcards.IsoDepCardService;
import net.sf.scuba.smartcards.ProtocolCommand;
import net.sf.scuba.smartcards.ProtocolResponse;
import net.sf.scuba.smartcards.ProtocolResponses;
import net.sf.scuba.smartcards.ResponseAPDU;
import org.apache.http.entity.StringEntity;
import org.irmacard.android.util.pindialog.EnterPINDialogFragment;
import org.irmacard.android.util.pindialog.EnterPINDialogFragment.PINDialogListener;
import org.irmacard.androidcardproxy.messages.EventArguments;
import org.irmacard.androidcardproxy.messages.PinResultArguments;
import org.irmacard.androidcardproxy.messages.ReaderMessage;
import org.irmacard.androidcardproxy.messages.ReaderMessageDeserializer;
import org.irmacard.androidcardproxy.messages.ResponseArguments;
import org.irmacard.androidcardproxy.messages.TransmitCommandSetArguments;
import org.irmacard.idemix.IdemixService;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.app.DialogFragment;
import android.app.PendingIntent;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.net.Uri;
import android.nfc.NfcAdapter;
import android.nfc.Tag;
import android.nfc.tech.IsoDep;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.CountDownTimer;
import android.os.Handler;
import android.os.Message;
import android.util.Log;
import android.view.Menu;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonParser;
import com.google.gson.stream.JsonReader;
import com.google.zxing.integration.android.IntentIntegrator;
import com.google.zxing.integration.android.IntentResult;
import com.loopj.android.http.AsyncHttpClient;
import com.loopj.android.http.AsyncHttpResponseHandler;
public class MainActivity extends Activity implements PINDialogListener {
private String TAG = "CardProxyMainActivity";
private NfcAdapter nfcA;
private PendingIntent mPendingIntent;
private IntentFilter[] mFilters;
private String[][] mTechLists;
// PIN handling
private int tries = -1;
// State variables
private IsoDep lastTag = null;
private int activityState = STATE_IDLE;
// New states
private static final int STATE_IDLE = 1;
private static final int STATE_CONNECTING_TO_SERVER = 2;
private static final int STATE_CONNECTED = 3;
private static final int STATE_READY = 4;
private static final int STATE_COMMUNICATING = 5;
private static final int STATE_WAITING_FOR_PIN = 6;
// Timer for testing card connectivity
Timer timer;
private static final int CARD_POLL_DELAY = 2000;
// Timer for briefly displaying feedback messages on CardProxy
CountDownTimer cdt;
private static final int FEEDBACK_SHOW_DELAY = 10000;
private boolean showingFeedback = false;
// Counter for number of connection tries
private static final int MAX_RETRIES = 3;
private int retry_counter = 0;
private void setState(int state) {
Log.i(TAG,"Set state: " + state);
activityState = state;
switch (activityState) {
case STATE_IDLE:
lastTag = null;
break;
default:
break;
}
setUIForState();
}
private void setUIForState() {
int imageResource = 0;
int statusTextResource = 0;
int feedbackTextResource = 0;
switch (activityState) {
case STATE_IDLE:
imageResource = R.drawable.irma_icon_place_card_520px;
statusTextResource = R.string.status_idle;
break;
case STATE_CONNECTING_TO_SERVER:
imageResource = R.drawable.irma_icon_place_card_520px;
statusTextResource = R.string.status_connecting;
break;
case STATE_CONNECTED:
imageResource = R.drawable.irma_icon_place_card_520px;
statusTextResource = R.string.status_connected;
feedbackTextResource = R.string.feedback_waiting_for_card;
break;
case STATE_READY:
imageResource = R.drawable.irma_icon_card_found_520px;
statusTextResource = R.string.status_ready;
break;
case STATE_COMMUNICATING:
imageResource = R.drawable.irma_icon_card_found_520px;
statusTextResource = R.string.status_communicating;
break;
case STATE_WAITING_FOR_PIN:
imageResource = R.drawable.irma_icon_card_found_520px;
statusTextResource = R.string.status_waitingforpin;
break;
default:
break;
}
((TextView)findViewById(R.id.status_text)).setText(statusTextResource);
if(!showingFeedback)
((ImageView)findViewById(R.id.statusimage)).setImageResource(imageResource);
if(feedbackTextResource != 0)
((TextView)findViewById(R.id.status_text)).setText(feedbackTextResource);
}
private void setFeedback(String message, String state) {
int imageResource = 0;
setUIForState();
if (state.equals("success")) {
imageResource = R.drawable.irma_icon_ok_520px;
} if (state.equals("warning")) {
imageResource = R.drawable.irma_icon_warning_520px;
} if (state.equals("failure")) {
imageResource = R.drawable.irma_icon_missing_520px;
}
((TextView)findViewById(R.id.feedback_text)).setText(message);
if(imageResource != 0) {
((ImageView)findViewById(R.id.statusimage)).setImageResource(imageResource);
showingFeedback = true;
}
if(cdt != null)
cdt.cancel();
cdt = new CountDownTimer(FEEDBACK_SHOW_DELAY, 1000) {
public void onTick(long millisUntilFinished) {
}
public void onFinish() {
clearFeedback();
}
}.start();
}
private void clearFeedback() {
showingFeedback = false;
((TextView)findViewById(R.id.feedback_text)).setText("");
setUIForState();
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// NFC stuff
nfcA = NfcAdapter.getDefaultAdapter(getApplicationContext());
mPendingIntent = PendingIntent.getActivity(this, 0,
new Intent(this, getClass()).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), 0);
// Setup an intent filter for all TECH based dispatches
IntentFilter tech = new IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED);
mFilters = new IntentFilter[] { tech };
// Setup a tech list for all IsoDep cards
mTechLists = new String[][] { new String[] { IsoDep.class.getName() } };
setState(STATE_IDLE);
timer = new Timer();
timer.scheduleAtFixedRate(new CardPollerTask(), CARD_POLL_DELAY, CARD_POLL_DELAY);
}
@Override
protected void onPause() {
super.onPause();
if (nfcA != null) {
nfcA.disableForegroundDispatch(this);
}
}
@Override
protected void onResume() {
super.onResume();
Log.i(TAG, "Action: " + getIntent().getAction());
if (NfcAdapter.ACTION_TECH_DISCOVERED.equals(getIntent().getAction())) {
processIntent(getIntent());
} else if (Intent.ACTION_VIEW.equals(getIntent().getAction()) && "cardproxy".equals(getIntent().getScheme())) {
// TODO: this is legacy code to have the cardproxy app respond to cardproxy:// urls. This doesn't
// work anymore, should check whether we want te re-enable it.
Uri uri = getIntent().getData();
String startURL = "http://" + uri.getHost() + ":" + uri.getPort() + uri.getPath();
gotoConnectingState(startURL);
}
if (nfcA != null) {
nfcA.enableForegroundDispatch(this, mPendingIntent, mFilters, mTechLists);
}
}
@Override
protected void onDestroy() {
super.onDestroy();
}
private static final int MESSAGE_STARTGET = 1;
String currentReaderURL = "";
int currentHandlers = 0;
Handler handler = new Handler() {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case MESSAGE_STARTGET:
Log.i(TAG,"MESSAGE_STARTGET received in handler!");
AsyncHttpClient client = new AsyncHttpClient();
client.setTimeout(50000); // timeout of 50 seconds
client.setUserAgent("org.irmacard.androidcardproxy");
client.get(MainActivity.this, currentReaderURL, new AsyncHttpResponseHandler() {
@Override
public void onSuccess(int arg0, String responseData) {
if (!responseData.equals("")) {
//Toast.makeText(MainActivity.this, responseData, Toast.LENGTH_SHORT).show();
handleChannelData(responseData);
}
// Do a new request, but only if no new requests have started
// in the mean time
if (currentHandlers <= 1) {
Message newMsg = new Message();
newMsg.what = MESSAGE_STARTGET;
if(!(activityState == STATE_IDLE))
handler.sendMessageDelayed(newMsg, 200);
}
}
@Override
public void onFailure(Throwable arg0, String arg1) {
if(activityState != STATE_CONNECTING_TO_SERVER) {
retry_counter = 0;
return;
}
retry_counter += 1;
// We should try again, but only if no new requests have started
// and we should wait a bit longer
if (currentHandlers <= 1 && retry_counter < MAX_RETRIES) {
Message newMsg = new Message();
setFeedback("Trying to reach server again...", "none");
newMsg.what = MESSAGE_STARTGET;
handler.sendMessageDelayed(newMsg, 5000);
} else {
retry_counter = 0;
setFeedback("Failed to connect to server", "warning");
setState(STATE_IDLE);
}
}
public void onStart() {
currentHandlers += 1;
};
public void onFinish() {
currentHandlers -= 1;
};
});
break;
default:
break;
}
}
};
private String currentWriteURL = null;
private ReaderMessage lastReaderMessage = null;
private void handleChannelData(String data) {
Gson gson = new GsonBuilder().
registerTypeAdapter(ProtocolCommand.class, new ProtocolCommandDeserializer()).
registerTypeAdapter(ReaderMessage.class, new ReaderMessageDeserializer()).
create();
if (activityState == STATE_CONNECTING_TO_SERVER) {
// this is the message that containts the url to write to
JsonParser p = new JsonParser();
String write_url = p.parse(data).getAsJsonObject().get("write_url").getAsString();
currentWriteURL = write_url;
setState(STATE_CONNECTED);
// Signal to the other end that we we are ready accept commands
postMessage(
new ReaderMessage(ReaderMessage.TYPE_EVENT, ReaderMessage.NAME_EVENT_CARDREADERFOUND, null,
new EventArguments().withEntry("type", "phone")));
} else {
ReaderMessage rm;
try {
Log.i(TAG, "Length (real): " + data);
JsonReader reader = new JsonReader(new StringReader(data));
reader.setLenient(true);
rm = gson.fromJson(reader, ReaderMessage.class);
} catch(Exception e) {
e.printStackTrace();
return;
}
lastReaderMessage = rm;
if (rm.type.equals(ReaderMessage.TYPE_COMMAND)) {
Log.i(TAG, "Got command message");
if (activityState != STATE_READY) {
// FIXME: Only when ready can we handle commands
throw new RuntimeException(
"Illegal command from server, no card currently connected");
}
if (rm.name.equals(ReaderMessage.NAME_COMMAND_AUTHPIN)) {
askForPIN();
} else {
setState(STATE_COMMUNICATING);
new ProcessReaderMessage().execute(new ReaderInput(lastTag, rm));
}
} else if (rm.type.equals(ReaderMessage.TYPE_EVENT)) {
EventArguments ea = (EventArguments)rm.arguments;
if (rm.name.equals(ReaderMessage.NAME_EVENT_STATUSUPDATE)) {
String state = ea.data.get("state");
String feedback = ea.data.get("feedback");
if (state != null) {
setFeedback(feedback, state);
}
} else if(rm.name.equals(ReaderMessage.NAME_EVENT_TIMEOUT)) {
setState(STATE_IDLE);
} else if(rm.name.equals(ReaderMessage.NAME_EVENT_DONE)) {
setState(STATE_IDLE);
}
}
}
}
private void postMessage(ReaderMessage rm) {
if (currentWriteURL != null) {
Gson gson = new GsonBuilder().
registerTypeAdapter(ProtocolResponse.class, new ProtocolResponseSerializer()).
create();
String data = gson.toJson(rm);
AsyncHttpClient client = new AsyncHttpClient();
try {
client.post(MainActivity.this, currentWriteURL, new StringEntity(data) , "application/json", new AsyncHttpResponseHandler() {
@Override
public void onSuccess(int arg0, String arg1) {
// TODO: Should there be some simple user feedback?
super.onSuccess(arg0, arg1);
}
@Override
public void onFailure(Throwable arg0, String arg1) {
// TODO: Give proper feedback to the user that we are unable to send stuff
super.onFailure(arg0, arg1);
}
});
} catch (UnsupportedEncodingException e) {
// Ignore, shouldn't happen ;)
e.printStackTrace();
}
}
}
public void onMainTouch(View v) {
if (activityState == STATE_IDLE) {
lastTag = null;
startQRScanner("Scan the QR image in the browser.");
}
}
@Override
public void onNewIntent(Intent intent) {
setIntent(intent);
}
public void processIntent(Intent intent) {
Tag tagFromIntent = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG);
IsoDep tag = IsoDep.get(tagFromIntent);
// Only proces tag when we're actually expecting a card.
if (tag != null && activityState == STATE_CONNECTED) {
setState(STATE_READY);
postMessage(new ReaderMessage(ReaderMessage.TYPE_EVENT, ReaderMessage.NAME_EVENT_CARDFOUND, null));
lastTag = tag;
}
}
class CardPollerTask extends TimerTask {
/**
* Dirty Hack. Since android doesn't produce events when an NFC card
* is lost, we send a command to the card, and see if it still responds.
* It is important that this command does not affect the card's state.
*
* FIXME: The command we sent is IRMA dependent, which is dangerous when
* the proxy is used with other cards/protocols.
*/
public void run() {
// Only in the ready state do we need to actively check for card
// presence.
if(activityState == STATE_READY) {
Log.i("CardPollerTask", "Checking card presence");
ReaderMessage rm = new ReaderMessage(
ReaderMessage.TYPE_COMMAND,
ReaderMessage.NAME_COMMAND_IDLE, "idle");
new ProcessReaderMessage().execute(new ReaderInput(lastTag, rm));
}
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
IntentResult scanResult = IntentIntegrator
.parseActivityResult(requestCode, resultCode, data);
// Process the results from the QR-scanning activity
if (scanResult != null) {
String contents = scanResult.getContents();
if (contents != null) {
gotoConnectingState(contents);
}
}
}
private void gotoConnectingState(String url) {
Log.i(TAG, "Start channel listening: " + url);
currentReaderURL = url;
Message msg = new Message();
msg.what = MESSAGE_STARTGET;
setState(STATE_CONNECTING_TO_SERVER);
handler.sendMessage(msg);
}
public void askForPIN() {
setState(STATE_WAITING_FOR_PIN);
DialogFragment newFragment = EnterPINDialogFragment.getInstance(tries);
newFragment.show(getFragmentManager(), "pinentry");
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
// Inflate the menu; this adds items to the action bar if it is present.
getMenuInflater().inflate(R.menu.activity_main, menu);
return true;
}
public void startQRScanner(String message) {
IntentIntegrator integrator = new IntentIntegrator(this);
integrator.setPrompt(message);
integrator.initiateScan();
}
private class ReaderInput {
public IsoDep tag;
public ReaderMessage message;
public String pincode = null;
public ReaderInput(IsoDep tag, ReaderMessage message) {
this.tag = tag;
this.message = message;
}
public ReaderInput(IsoDep tag, ReaderMessage message, String pincode) {
this.tag = tag;
this.message = message;
this.pincode = pincode;
}
}
private class ProcessReaderMessage extends AsyncTask<ReaderInput, Void, ReaderMessage> {
@Override
protected ReaderMessage doInBackground(ReaderInput... params) {
ReaderInput input = params[0];
IsoDep tag = input.tag;
ReaderMessage rm = input.message;
// It seems sometimes tag is null afterall
if(tag == null) {
Log.e("ReaderMessage", "tag is null, this should not happen!");
return new ReaderMessage(ReaderMessage.TYPE_EVENT, ReaderMessage.NAME_EVENT_CARDLOST, null);
}
// Make sure time-out is long enough (10 seconds)
tag.setTimeout(10000);
// TODO: The current version of the cardproxy shouldn't depend on idemix terminal, but for now
// it is convenient.
IdemixService is = new IdemixService(new IsoDepCardService(tag));
try {
if (!is.isOpen()) {
// TODO: this is dangerous, this call to IdemixService already does a "select applet"
is.open();
}
if (rm.name.equals(ReaderMessage.NAME_COMMAND_AUTHPIN)) {
if (input.pincode != null) {
// TODO: this should be done properly, maybe without using IdemixService?
tries = is.sendCredentialPin(input.pincode.getBytes());
return new ReaderMessage("response", rm.name, rm.id, new PinResultArguments(tries));
}
} else if (rm.name.equals(ReaderMessage.NAME_COMMAND_TRANSMIT)) {
TransmitCommandSetArguments arg = (TransmitCommandSetArguments)rm.arguments;
ProtocolResponses responses = new ProtocolResponses();
for (ProtocolCommand c: arg.commands) {
ResponseAPDU apdu_response = is.transmit(c.getAPDU());
responses.put(c.getKey(),
new ProtocolResponse(c.getKey(), apdu_response));
if(apdu_response.getSW() != 0x9000) {
break;
}
}
return new ReaderMessage(ReaderMessage.TYPE_RESPONSE, rm.name, rm.id, new ResponseArguments(responses));
} else if (rm.name.equals(ReaderMessage.NAME_COMMAND_IDLE)) {
// FIXME: IRMA specific implementation,
// This command is not allowed in normal mode,
// so it will result in an exception.
Log.i("READER", "Processing idle command");
is.getCredentials();
}
} catch (CardServiceException e) {
// FIXME: IRMA specific handling of failed command, this is too generic.
if(e.getMessage().contains("Command failed:") && e.getSW() == 0x6982) {
return null;
}
e.printStackTrace();
// TODO: maybe also include the information about the exception in the event?
return new ReaderMessage(ReaderMessage.TYPE_EVENT, ReaderMessage.NAME_EVENT_CARDLOST, null);
} catch (IllegalStateException e) {
// This sometimes props up when applications comes out of suspend for now we just ignore this.
Log.i("READER", "IllegalStateException ignored");
return null;
}
return null;
}
@Override
protected void onPostExecute(ReaderMessage result) {
if(result == null)
return;
// Update state
if( result.type.equals(ReaderMessage.TYPE_EVENT) &&
result.name.equals(ReaderMessage.NAME_EVENT_CARDLOST)) {
// Connection to the card is lost
setState(STATE_CONNECTED);
} else {
if(activityState == STATE_COMMUNICATING) {
setState(STATE_READY);
}
}
if (result.name.equals(ReaderMessage.NAME_COMMAND_AUTHPIN)) {
// Handle pin separately, abort if pin incorrect and more tries
// left
PinResultArguments args = (PinResultArguments) result.arguments;
if (!args.success) {
if (args.tries > 0) {
// Still some tries left, asking again
setState(STATE_WAITING_FOR_PIN);
askForPIN();
return; // do not send a response yet.
} else {
// FIXME: No more tries left
// Need to go to error state
}
}
}
// Post result to browser
postMessage(result);
}
}
@Override
public void onPINEntry(String dialogPincode) {
// TODO: in the final version, the following debug code should go :)
Log.i(TAG, "PIN entered: " + dialogPincode);
setState(STATE_COMMUNICATING);
new ProcessReaderMessage().execute(new ReaderInput(lastTag, lastReaderMessage, dialogPincode));
}
@Override
public void onPINCancel() {
Log.i(TAG, "PIN entry canceled!");
postMessage(
new ReaderMessage(ReaderMessage.TYPE_RESPONSE,
ReaderMessage.NAME_COMMAND_AUTHPIN,
lastReaderMessage.id,
new ResponseArguments("cancel")));
setState(STATE_READY);
}
public static class ErrorFeedbackDialogFragment extends DialogFragment {
public static ErrorFeedbackDialogFragment newInstance(String title, String message) {
ErrorFeedbackDialogFragment f = new ErrorFeedbackDialogFragment();
Bundle args = new Bundle();
args.putString("message", message);
args.putString("title", title);
f.setArguments(args);
return f;
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
builder.setMessage(getArguments().getString("message"))
.setTitle(getArguments().getString("title"))
.setPositiveButton("OK", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int id) {
dialog.dismiss();
}
});
return builder.create();
}
}
@Override
public void onBackPressed() {
// When we are not in IDLE state, return there
if(activityState != STATE_IDLE) {
if(cdt != null)
cdt.cancel();
setState(STATE_IDLE);
clearFeedback();
} else {
// We are in Idle, do what we always do
super.onBackPressed();
}
}
}