/*
* Copyright 2013 i'm SpA
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package it.imwatch.nfclottery;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.Activity;
import android.app.PendingIntent;
import android.content.ContentResolver;
import android.content.Intent;
import android.content.IntentFilter;
import android.database.Cursor;
import android.nfc.NdefMessage;
import android.nfc.NdefRecord;
import android.nfc.NfcAdapter;
import android.nfc.Tag;
import android.nfc.tech.Ndef;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.support.v4.app.DialogFragment;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentManager;
import android.support.v4.app.FragmentTransaction;
import android.support.v7.app.ActionBarActivity;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.Window;
import android.widget.TextView;
import android.widget.Toast;
import de.keyboardsurfer.android.widget.crouton.Crouton;
import de.keyboardsurfer.android.widget.crouton.Style;
import it.imwatch.nfclottery.data.provider.NFCMLContent;
import it.imwatch.nfclottery.dialogs.ClearDBAlertDialog;
import it.imwatch.nfclottery.dialogs.ClearWinnersDialog;
import it.imwatch.nfclottery.dialogs.InsertContactDialog;
import it.imwatch.nfclottery.dialogs.WinnersListDialog;
import net.sourceforge.cardme.engine.VCardEngine;
import net.sourceforge.cardme.vcard.VCard;
import net.sourceforge.cardme.vcard.features.EmailFeature;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.Random;
import static it.imwatch.nfclottery.Const.DEBUG;
/**
* The main Activity for the app, that handles all the NFC events
* and shows feedbacks to the users.
*/
public class MainActivity extends ActionBarActivity implements DropboxHelper.DropboxOperationsListener {
private static final String TAG = MainActivity.class.getSimpleName();
private static final String MIME_VCARD = "text/vcard";
private static final String MIME_XVCARD = "text/x-vcard";
private static final String EXTRA_NO_NFC_CHECK_ERR = "no_nfc_check_err";
private TextView mTxtStatus, mTxtNfcScan;
private NfcAdapter mNfcAdapter; // Late-initialized within isNfcAvailable()
private VCardEngine mVCardEngine;
private boolean mHasRefreshUi;
private Handler mHandler;
// NFC checker interval (ms)
private static final int NFC_CHECK_INTERVAL = 1000;
private Thread mNfcCheckerThread;
private final Runnable mNfcChecker = new Runnable() {
private boolean mLastNfcEnabled = false;
@Override
public void run() {
try {
// This keeps running until stopped. Its task is to detect
// changes in the NFC status while the Activity is showing,
// because Android doesn't have any broadcast for those events
while (true) {
if (Thread.interrupted()) {
return;
}
if (mNfcAdapter != null) {
boolean nfcEnabled = mNfcAdapter.isEnabled();
if (nfcEnabled != mLastNfcEnabled) {
mHandler.postAtFrontOfQueue(new Runnable() {
@Override
public void run() {
updateNfcUi();
}
});
}
mLastNfcEnabled = nfcEnabled;
}
Thread.sleep(NFC_CHECK_INTERVAL);
}
}
catch (InterruptedException e) {
if (DEBUG) Log.d("NFC_checker", "Checker execution interrupted");
}
}
};
private Crouton mCurrentCrouton;
@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
@SuppressLint("ResourceAsColor")
@Override
protected void onCreate(Bundle savedInstanceState) {
// Initialize the progress bar on the actionbar
// Must be done BEFORE setContentView, then hidden
requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mTxtStatus = (TextView) findViewById(R.id.txt_status);
mTxtNfcScan = (TextView) findViewById(R.id.txt_nfc_scan);
getSupportActionBar().setIcon(getResources().getDrawable(R.drawable.ic_actionbar_logo));
mHandler = new Handler();
// Only show the error toast (when necessary) if the saved instance state
// doesn't tell us not to show it
final boolean showUi = savedInstanceState == null ||
!savedInstanceState.getBoolean(EXTRA_NO_NFC_CHECK_ERR, false);
if (isNfcAvailable(showUi)) {
mTxtNfcScan.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
startActivity(new Intent(android.provider.Settings.ACTION_NFC_SETTINGS));
}
});
}
else {
// isNfcAvailable() shows an error Toast by itself in case of need
Log.w(TAG, "This device doesn't support NFC");
}
updateParticipantsCount();
setShowsRefreshUi(false);
addDropboxMenuFragment();
handleIntent(getIntent());
}
/**
* Updates the NFC-related UI elements to reflect the status of the NFC chip.
*/
private void updateNfcUi() {
if (mNfcAdapter != null) {
if (!mNfcAdapter.isEnabled()) {
mTxtNfcScan.setCompoundDrawablesWithIntrinsicBounds(0, R.drawable.nfc_off, 0, 0);
mTxtNfcScan.setText(R.string.hint_nfc_off);
mTxtNfcScan.setClickable(true);
showCroutonNao(getString(R.string.error_nfc_off), Style.INFO);
}
else {
mTxtNfcScan.setCompoundDrawablesWithIntrinsicBounds(0, R.drawable.nfc_scan, 0, 0);
mTxtNfcScan.setText(R.string.hint_nfc_scan);
mTxtNfcScan.setClickable(false);
}
}
else {
mTxtNfcScan.setCompoundDrawablesWithIntrinsicBounds(0, R.drawable.nfc_off, 0, 0);
mTxtNfcScan.setText(R.string.hint_nfc_not_available);
mTxtNfcScan.setClickable(false);
}
}
/**
* Determines if the NFC adapter is available on the device.
*
* @return Returns true if the NFC adapter is available, false otherwise
*/
private boolean isNfcAvailable() {
return isNfcAvailable(true);
}
/**
* Determines if the NFC adapter is available on the device.
*
* @param showUi True to show some feedback to the user when needed, false
* to avoid showing feedbacks
*
* @return Returns true if the NFC adapter is available, false otherwise
*/
private boolean isNfcAvailable(boolean showUi) {
if (mNfcAdapter == null) {
// Try to retrieve the default NFC adapter (might not have been initialized yet)
mNfcAdapter = NfcAdapter.getDefaultAdapter(this);
}
if (mNfcAdapter == null) {
// This means the device doesn't have NFC, and we don't support this: finish here
if (showUi) {
Toast.makeText(this, getString(R.string.error_nfc_not_available), Toast.LENGTH_LONG)
.show();
}
return false;
}
return true;
}
@Override
protected void onResume() {
super.onResume();
// It's important that the Activity is in the foreground (resumed) when we
// setup the dispatching. If it's not, this will throw an IllegalStateException.
setupForegroundDispatch(this, mNfcAdapter);
updateNfcUi();
// Start checking the NFC status periodically
mNfcCheckerThread = new Thread(mNfcChecker);
mNfcCheckerThread.setName("NfcChecker");
mNfcCheckerThread.setPriority(Thread.MIN_PRIORITY);
mNfcCheckerThread.start();
}
@Override
protected void onPause() {
// It's important that the Activity is still in the foreground (resumed) when we
// stop the dispatching. If it's not, this will throw an IllegalStateException.
stopForegroundDispatch(this, mNfcAdapter);
try {
mNfcCheckerThread.interrupt();
mNfcCheckerThread.join();
}
catch (InterruptedException e) {
Log.w(TAG, "Thread stopping join interrupted.");
}
super.onPause();
}
@Override
protected void onDestroy() {
Crouton.cancelAllCroutons();
super.onDestroy();
}
@Override
protected void onNewIntent(Intent intent) {
// This method gets called when the user scans a Tag with the device
handleIntent(intent);
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putBoolean(EXTRA_NO_NFC_CHECK_ERR, true);
}
/**
* Handles an intent as received by the Activity, be it with a MAIN or an
* NDEF_DISCOVERED action.
*
* @param intent The intent to handle
*/
private void handleIntent(Intent intent) {
String action = intent.getAction();
if (NfcAdapter.ACTION_NDEF_DISCOVERED.equals(action)) {
// This is an "NFC tag scanned" intent
String type = intent.getType();
Log.d(TAG, "Read tag with type: " + type);
if (MIME_VCARD.equals(type) || MIME_XVCARD.equals(type)) {
if (mVCardEngine == null) {
mVCardEngine = new VCardEngine();
}
Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG);
if (tag != null) {
Ndef ndefTag = Ndef.get(tag);
NdefMessage ndefMessage;
// ndefTag can be null if the tag was INITIALIZED
// but not actually written to
if (ndefTag != null) {
ndefMessage = ndefTag.getCachedNdefMessage();
parseAndInsertVCard(ndefMessage);
}
}
}
else {
Log.i(TAG, "Ignoring NDEF with mime type: " + type);
}
}
// Nothing else to do if this is a MAIN intent...
}
/**
* Parses a discovered NDEF vCard message and stores it
* in the app's ContentProvider. If the card contains more than one
* for each detail type (name, organization, title, email) then they
* are merged when inserting them in the database in a list string
* for each type. Values in these lists are separated by Only the first non-empty value for each type of
* field will be shown on the UI, anyway.
*
* @param ndefMessage The NDEF message to parse and store data from
*/
private void parseAndInsertVCard(NdefMessage ndefMessage) {
NdefRecord[] records = ndefMessage.getRecords();
ArrayList<String> names = new ArrayList<String>();
ArrayList<String> emails = new ArrayList<String>();
ArrayList<String> organizations = new ArrayList<String>();
ArrayList<String> titles = new ArrayList<String>();
// Tags might contain more than one record, each of which might contain
// one or more of the elements (or no record, if malformed...)!
for (NdefRecord record : records) {
String vCardString = new String(record.getPayload());
try {
readDetailsFromvCard(mVCardEngine, vCardString, names, emails,
organizations, titles);
}
catch (IOException e) {
Log.e(TAG, "Error while parsing a vCard!", e);
if (DEBUG) Log.v(TAG, "Malformed vCard contents:\n" + vCardString);
}
}
if (emails.isEmpty()) {
// It's mandatory to have at least one email address
showCroutonNao(getString(R.string.error_email_not_present), Style.ALERT);
Log.e(TAG, "The tag doesn't contain any email address");
return;
}
// Now check and consolidate all emails in a CSV list
// (to avoid looping though this list all over again later on)
StringBuilder emailCsv = new StringBuilder();
for (String email : emails) {
if (DataHelper.isEmailAlreadyPresent(this, email)) {
// we add only once every email
showCroutonNao(getString(R.string.error_email_already_present, email), Style.ALERT);
return;
}
emailCsv.append(email).append(DataHelper.VALUES_SEPARATOR);
}
if (DataHelper.insertContact(this, names, emailCsv.toString(), organizations, titles)) {
showCroutonNao(getString(R.string.new_contact_added, emails.get(0)), Style.CONFIRM);
updateParticipantsCount();
}
}
/**
* Parses the details from the specified vCard string and adds them to the right
* details lists.
*
* @param vCardEngine The vCard parsing engine to use
* @param vCardString The vCard contents as a string
* @param names The "formatted names" list
* @param emails The "email addresses" list
* @param organizations The "organization names" list
* @param titles The "titles" list
*
* @throws IOException Thrown if the vCard string is malformed
*/
private static void readDetailsFromvCard(VCardEngine vCardEngine, String vCardString, ArrayList<String> names,
ArrayList<String> emails, ArrayList<String> organizations,
ArrayList<String> titles) throws IOException {
String tmp;
VCard vCard = vCardEngine.parse(vCardString);
// Read the formatted name and title from the vCard first
if (vCard.getFormattedName() != null) {
tmp = vCard.getFormattedName().getFormattedName();
names.add(tmp);
if (DEBUG) Log.v(TAG, "Tag formatted name: " + tmp);
}
if (vCard.getTitle() != null) {
tmp = vCard.getTitle().getTitle();
titles.add(tmp);
if (DEBUG) Log.v(TAG, "Tag title: " + tmp);
}
// Read the emails and organizations (there can be more than one per vCard)
if (vCard.getEmails() != null) {
final Iterator<EmailFeature> emailIterator = vCard.getEmails();
while (emailIterator.hasNext()) {
tmp = emailIterator.next().getEmail();
emails.add(tmp);
if (DEBUG) Log.v(TAG, "Tag email: " + tmp);
}
}
if (vCard.getOrganizations() != null && vCard.getOrganizations().getOrganizations() != null) {
final Iterator<String> orgsIterator = vCard.getOrganizations().getOrganizations();
while (orgsIterator.hasNext()) {
tmp = orgsIterator.next();
organizations.add(tmp);
if (DEBUG) Log.v(TAG, "Tag organization: " + tmp);
}
}
}
/**
* Sets up the dispatching of NFC events a the foreground Activity.
*
* @param activity The {@link android.app.Activity} to setup the foreground dispatch for
* @param adapter The {@link android.nfc.NfcAdapter} to setup the foreground dispatch for
*/
public static void setupForegroundDispatch(final Activity activity, NfcAdapter adapter) {
if (adapter != null) {
final Intent intent = new Intent(activity.getApplicationContext(), activity.getClass());
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
final PendingIntent pendingIntent =
PendingIntent.getActivity(activity.getApplicationContext(), 0, intent, 0);
IntentFilter[] filters = new IntentFilter[1];
String[][] techList = new String[][] {};
// Notice that this is the same filter we define in our manifest.
// This ensures we're not stealing events for other types of NDEF.
filters[0] = new IntentFilter();
IntentFilter filter = filters[0];
filter.addAction(NfcAdapter.ACTION_NDEF_DISCOVERED);
filter.addCategory(Intent.CATEGORY_DEFAULT);
try {
filter.addDataType(MIME_VCARD);
filter.addDataType(MIME_XVCARD);
}
catch (IntentFilter.MalformedMimeTypeException e) {
throw new RuntimeException("Mime type not supported.");
}
adapter.enableForegroundDispatch(activity, pendingIntent, filters, techList);
}
}
/**
* Stops the dispatching of NFC events for a foreground Activity.
*
* @param activity The {@link android.app.Activity} to stop the foreground dispatch for
* @param adapter The {@link android.nfc.NfcAdapter} to stop the foreground dispatch for
*/
public static void stopForegroundDispatch(final Activity activity, NfcAdapter adapter) {
if (adapter != null) {
adapter.disableForegroundDispatch(activity);
}
}
/** Adds the fragment including the Dropbox menu options */
protected void addDropboxMenuFragment() {
FragmentManager fragmentManager = getSupportFragmentManager();
Fragment dropboxOptionsFrag =
fragmentManager.findFragmentByTag(DropboxOptionsFragment.class.getSimpleName());
if (dropboxOptionsFrag == null) {
// Add an instance of the Fragment
Log.d(TAG, "Fragment doesn't exist, adding it.");
FragmentTransaction transaction = fragmentManager.beginTransaction();
dropboxOptionsFrag = new DropboxOptionsFragment();
transaction.add(dropboxOptionsFrag, DropboxOptionsFragment.class.getSimpleName());
transaction.commitAllowingStateLoss();
}
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.main, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
// Handle item selection
switch (item.getItemId()) {
case R.id.action_insert:
showManualInsertNewContactDialog();
return true;
case R.id.action_draw:
drawWinner();
return true;
case R.id.action_winners:
showWinners();
return true;
case R.id.action_export:
exportDB();
return true;
case R.id.action_clear_db:
showClearDbConfirmation();
return true;
case R.id.action_clear_winners:
showClearWinnersConfirmation();
return true;
default:
return super.onOptionsItemSelected(item);
}
}
/**
* Shows a dialog that asks the user the confirmation to clear the
* winners' status from the database.
*/
private void showClearWinnersConfirmation() {
DialogFragment newFragment = new ClearWinnersDialog();
newFragment.show(getSupportFragmentManager(), "clearwinnersdialog");
}
/**
* Draws a winner from the stored contacts in the ContentProvider
* that haven't already won.
*/
public void drawWinner() {
ContentResolver cr = getContentResolver();
Cursor c = cr.query(NFCMLContent.Geeks.CONTENT_URI, NFCMLContent.Geeks.PROJECTION,
NFCMLContent.Geeks.Columns.TIMEWINNER.getName() + "=0",
null, null);
int count = c != null ? c.getCount() : 0;
if (count == 0) {
// There is nobody to draw from
if (c != null) {
c.close();
}
showCroutonNao(getString(R.string.no_more_nonwinning_contacts), Style.ALERT);
return;
}
int winnerPosition = (new Random()).nextInt(count);
c.moveToPosition(winnerPosition);
String winnerEmail = c.getString(NFCMLContent.Geeks.Columns.EMAIL.getIndex());
String winnerName = c.getString(NFCMLContent.Geeks.Columns.NAME.getIndex());
if (DEBUG) {
Log.d(TAG, String.format("The winner is %s (position: %d)",
DataHelper.cleanupSeparators(winnerEmail), c.getPosition()));
}
c.close();
showWinner(winnerPosition, winnerEmail, winnerName);
}
/**
* Shows the winner with the specified email and name,
* after validating that data.
*
* @param winnerPosition The position in the drawing cursor of the winner
* @param winnerEmail The email (can be a list) of the winner
* @param winnerName The name (can be a list) of the winner
*/
private boolean showWinner(int winnerPosition, String winnerEmail, String winnerName) {
if (winnerEmail == null) {
showCroutonNao(getString(R.string.error_cant_draw), Style.ALERT);
Log.e(TAG, "Unable to retrieve the contact email for position " + winnerPosition);
return false;
}
int separatorPos = winnerEmail.indexOf(DataHelper.VALUES_SEPARATOR, 1); // Skip the heading separator
if (separatorPos >= 0) {
winnerEmail = winnerEmail.substring(0, separatorPos); // Only show the first email
}
winnerEmail = DataHelper.cleanupSeparators(winnerEmail);
if (winnerName == null) {
showCroutonNao(getString(R.string.error_cant_draw), Style.ALERT);
Log.e(TAG, "Unable to retrieve the contact name for position " + winnerPosition);
return false;
}
separatorPos = winnerName.indexOf(DataHelper.VALUES_SEPARATOR, 1); // Skip the heading separator
if (separatorPos >= 0) {
winnerName = winnerName.substring(0, separatorPos); // Only show the first name
}
winnerName = DataHelper.cleanupSeparators(winnerName);
// Store that this contact has won
if (!DataHelper.markWinner(this, winnerEmail, true)) {
showCroutonNao(getString(R.string.error_cant_draw), Style.ALERT);
Log.e(TAG, "Unable to mark the winner at position " + winnerPosition);
return false;
}
// Open the WinnerActivity to show who's won
Intent winnerActivityIntent = new Intent(this, WinnerActivity.class);
winnerActivityIntent.putExtra(WinnerActivity.EXTRA_WINNER_NAME, winnerName);
winnerActivityIntent.putExtra(WinnerActivity.EXTRA_WINNER_EMAIL, winnerEmail);
startActivity(winnerActivityIntent);
return true;
}
/**
* Shows a dialog containing a list of everybody that has won so far.
*/
private void showWinners() {
DialogFragment newFragment = new WinnersListDialog();
newFragment.show(getSupportFragmentManager(), "winnersDialog");
}
/**
* Exports the database to a CSV file.
*/
private void exportDB() {
new ExportDbAsyncTask(this).execute();
}
/** Shows a dialog to let the user to manually insert a contact into DB */
private void showManualInsertNewContactDialog() {
DialogFragment newFragment = new InsertContactDialog();
newFragment.show(getSupportFragmentManager(), "insertdialog");
}
/** Shows the alert dialog asking the user the confirmation to delete all the contacts from DB */
private void showClearDbConfirmation() {
DialogFragment newFragment = new ClearDBAlertDialog();
newFragment.show(getSupportFragmentManager(), "cleardialog");
}
/**
* Utility method to quickly swap an already showing crouton with a new one,
* whitout waiting for the default timeout.
*
* @param text The text to display in the new crouton
* @param style the new crouton style
*/
public void showCroutonNao(CharSequence text, Style style) {
if (mCurrentCrouton != null) {
mCurrentCrouton.cancel();
}
mCurrentCrouton = Crouton.makeText(MainActivity.this, text, style);
mCurrentCrouton.show();
}
/** Updates the number of participants to the lottery on the UI */
public void updateParticipantsCount() {
ContentResolver cr = getContentResolver();
Cursor result = cr.query(
NFCMLContent.Geeks.CONTENT_URI,
null,
null,
null,
null
);
int count = result != null ? result.getCount() : 0;
if (result != null) {
result.close();
}
if (count > 0) {
mTxtStatus.setText(getResources().getQuantityString(R.plurals.label_participants_count, count, count));
}
else {
mTxtStatus.setText(getString(R.string.label_participants_empty));
}
}
@Override
public void onStartRestoreOperation() {
Log.d(TAG, "Starting restore. Showing indeterminate progress...");
runOnUiThread(new Runnable() {
@Override
public void run() {
setShowsRefreshUi(true);
}
});
}
@Override
public void onRemoteDBRestored(final boolean success) {
Log.d(TAG, "Restore ended with status " + success + ". Hiding indeterminate progress");
runOnUiThread(new Runnable() {
@Override
public void run() {
if (success) {
showCroutonNao(getString(R.string.confirm_dropbox_restore), Style.CONFIRM);
}
else {
showCroutonNao(getString(R.string.dropbox_restore_failed), Style.ALERT);
}
setShowsRefreshUi(false);
updateParticipantsCount();
}
});
}
/**
* Sets wether this Activity shows the refresh UI (progressbar).
*
* @param hasRefreshUi True to show the Refresh UI, false to hide it.
*/
protected void setShowsRefreshUi(boolean hasRefreshUi) {
if (mHasRefreshUi != hasRefreshUi) {
mHasRefreshUi = hasRefreshUi;
setProgressBarIndeterminateVisibility(mHasRefreshUi);
supportInvalidateOptionsMenu();
}
}
/**
* Posts a Runnable on the Activity's Handler.
*
* @param runnable The Runnable to post on the Handler
*/
public void post(Runnable runnable) {
mHandler.post(runnable);
}
private class ExportDbAsyncTask extends AsyncTask<Void, Void, String> {
WeakReference<MainActivity> mActivity;
private ExportDbAsyncTask(MainActivity activity) {
mActivity = new WeakReference<MainActivity>(activity);
}
@Override
protected String doInBackground(Void... params) {
// Just launch the operation in the worker thread and dispatch the result
// to the UI thread, where it will be handled
final MainActivity mainActivity = mActivity.get();
if (DEBUG) Log.d(TAG, "ExportDbAsyncTask is starting its background job");
if (mainActivity != null) {
return DBExporter.exportDB(mainActivity);
}
else {
Log.e(TAG, "The Activity was destroyed before the AsyncTask begun working");
return null;
}
}
@Override
protected void onPostExecute(String outputFilePath) {
if (DEBUG) Log.d(TAG, "ExportDbAsyncTask has completed its background job");
super.onPostExecute(outputFilePath);
final MainActivity mainActivity = mActivity.get();
if (mainActivity != null) {
if (outputFilePath != null) {
mainActivity.showCroutonNao(getString(R.string.success_db_export, outputFilePath), Style.CONFIRM);
}
else {
mainActivity.showCroutonNao(getString(R.string.error_db_export), Style.ALERT);
}
}
else {
Log.w(TAG, "The Activity was destroyed while the AsyncTask was working");
}
}
}
}