qr.cloud.qrpedia.MessageViewerActivity.java Source code

Java tutorial

Introduction

Here is the source code for qr.cloud.qrpedia.MessageViewerActivity.java

Source

/*
 * Copyright (c) 2014 Simon Robinson
 *
 * 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 qr.cloud.qrpedia;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import org.json.JSONException;
import org.json.JSONObject;

import qr.cloud.db.ContentProviderAuthority;
import qr.cloud.qrpedia.CloudEntityListFragment.FragmentInteractionListener;
import qr.cloud.util.LocationRetriever;
import qr.cloud.util.LocationRetriever.LocationResult;
import qr.cloud.util.QRCloudUtils;
import qr.cloud.util.Typefaces;
import qr.cloud.util.UPCDatabaseRestClient;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.location.Location;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentManager;
import android.support.v4.app.FragmentPagerAdapter;
import android.support.v4.app.FragmentTransaction;
import android.support.v4.view.ViewPager;
import android.text.TextUtils;
import android.text.util.Linkify;
import android.util.Log;
import android.util.SparseArray;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.RotateAnimation;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;

import com.actionbarsherlock.app.ActionBar;
import com.actionbarsherlock.app.ActionBar.Tab;
import com.actionbarsherlock.app.SherlockFragmentActivity;
import com.actionbarsherlock.view.Menu;
import com.actionbarsherlock.view.MenuItem;
import com.beoui.geocell.GeocellUtils;
import com.beoui.geocell.model.Point;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GooglePlayServicesClient;
import com.google.android.gms.location.LocationClient;
import com.google.cloud.backend.android.CloudBackendSherlockFragmentActivity;
import com.google.cloud.backend.android.CloudCallbackHandler;
import com.google.cloud.backend.android.CloudEntity;
import com.google.cloud.backend.android.CloudQuery;
import com.google.cloud.backend.android.CloudQuery.Scope;
import com.google.cloud.backend.android.F;
import com.google.cloud.backend.android.F.Op;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.client.result.ParsedResultType;
import com.loopj.android.http.JsonHttpResponseHandler;

// theme: http://jgilfelt.github.io/android-actionbarstylegenerator/#name=qrpedia&compat=sherlock&theme=light_dark&actionbarstyle=solid&texture=0&hairline=0&backColor=377fd2%2C100&secondaryColor=377fd2%2C100&tabColor=f2f2f2%2C100&tertiaryColor=fff%2C100&accentColor=3d98ff%2C100&cabBackColor=377fd2%2C100&cabHighlightColor=3d98ff%2C100
// (also a good colour: #83b0fe)

// TODO: if there are fewer results than the display limit, don't do a server query (just cache others)
// TODO: on HTC Sensation, first scan after setting account doesn't work
// TODO: save list position when refreshing items
// TODO: remove/refresh item when reporting?
// TODO: pause preview while splash is shown?
// TODO: set empty text fonts in all lists - will need to set a custom empty view though (directly on the listview)
// TODO: show animated gif on first message added to a barcode, and animated +1 when tapping
// TODO: add photo next to item
// TODO: add report review view for certain users
// TODO: fix link click row highlighting

public class MessageViewerActivity extends CloudBackendSherlockFragmentActivity
        implements FragmentInteractionListener, GooglePlayServicesClient.ConnectionCallbacks,
        GooglePlayServicesClient.OnConnectionFailedListener {

    private static final String TAG = "MessageViewer";

    private static final int NON_URGENT_QUERY_DELAY = 2500; // milliseconds to delay non-ui queries

    TabsAdapter mTabsAdapter;
    ViewPager mViewPager;
    TextView mCodeContents;

    MenuItem mRefreshButtonItem;
    RotateAnimation mRotateAnimation;

    Location mLocation;
    LocationClient mLocationClient; // use the Google Play Services location client by default
    boolean mWaitingForGooglePlayLocation;
    boolean mGooglePlayLocationConnected; // not available until initialised
    LocationRetriever mLocationListener; // fall back to manual (but inaccurate) calculation if necessary
    long mManualLocationRequestTime;
    int mMinimumLocationRefreshWaitTime;
    boolean mLocationTabEnabled = true; // set this to false to prevent pre-loading of the location tab

    // for tracking whether we're allowed to use the app or not
    enum UserState {
        UNKNOWN, CHECKING, ALLOWED, BANNED
    }

    // for tracking whether this is a current or old version of the app
    enum ApplicationState {
        UNKNOWN, CHECKING, OLD, CURRENT
    }

    static UserState mUserState = UserState.UNKNOWN;
    static ApplicationState mApplicationState = ApplicationState.UNKNOWN;

    String mCodeHash = null; // default to no code loaded
    String mProductDetails = null; // for loading product details
    BarcodeFormat mBarcodeFormat = BarcodeFormat.UPC_A; // default to products
    ParsedResultType mBarcodeType = ParsedResultType.TEXT; // default to text

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_message_viewer);

        // set up tabs and collapse the action bar
        ActionBar actionBar = getSupportActionBar();
        actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_TABS);
        actionBar.setDisplayShowTitleEnabled(false);
        if (QRCloudUtils.actionBarIsSplit(MessageViewerActivity.this)) {
            actionBar.setDisplayShowHomeEnabled(false); // TODO: also need to show inverted/rearranged icons
        }

        // load Google Play Services location client
        mWaitingForGooglePlayLocation = true;
        mGooglePlayLocationConnected = false;
        mLocationClient = new LocationClient(MessageViewerActivity.this, MessageViewerActivity.this,
                MessageViewerActivity.this);

        // refresh interval for location queries and load whether location has been updated, and product details
        mMinimumLocationRefreshWaitTime = getResources().getInteger(R.integer.minimum_location_refresh_time);
        if (savedInstanceState != null) {
            mLocationTabEnabled = savedInstanceState.getBoolean(getString(R.string.key_location_tab_visited));
            mProductDetails = savedInstanceState.getString(getString(R.string.key_product_details));
            double savedLat = savedInstanceState.getDouble(QRCloudUtils.DATABASE_PROP_LATITUDE);
            double savedLon = savedInstanceState.getDouble(QRCloudUtils.DATABASE_PROP_LONGITUDE);
            if (savedLat != 0.0d && savedLon != 0.0d) {
                mLocation = new Location(QRCloudUtils.DATABASE_PROP_GEOCELL); // just need any string to initialise
                mLocation.setLatitude(savedLat);
                mLocation.setLongitude(savedLon);
            }
            mManualLocationRequestTime = savedInstanceState.getLong(getString(R.string.key_location_request_time));
            long currentTime = System.currentTimeMillis();
            if (currentTime - mManualLocationRequestTime < LocationRetriever.LOCATION_WAIT_TIME) {
                // we've started but probably not finished getting the location (manual method) - try again
                requestManualLocationAndUpdateTab();
            }
        }

        // get the code hash and details (must be after getting mProductDetails to stop multiple queries)
        final Intent launchIntent = getIntent();
        // Bundle barcodeDetailsBundle = null;
        if (launchIntent != null) {
            final String codeContents = launchIntent.getStringExtra(QRCloudUtils.DATABASE_PROP_CONTENTS);
            if (codeContents != null) {
                // we need the hash for database lookups
                mCodeHash = QRCloudUtils.sha1Hash(codeContents);
                parseCodeDetailsAndUpdate(launchIntent, codeContents, savedInstanceState == null);
            }
        }
        if (mCodeHash == null) {
            finish();
        }

        // set up animation for the refresh button
        mRotateAnimation = new RotateAnimation(0f, 360f, Animation.RELATIVE_TO_SELF, 0.5f,
                Animation.RELATIVE_TO_SELF, 0.5f);
        mRotateAnimation.setDuration(600);
        mRotateAnimation.setRepeatCount(Animation.INFINITE);
        mRotateAnimation.setRepeatMode(Animation.RESTART);

        // set up tab paging
        mViewPager = (ViewPager) findViewById(R.id.message_sort_pager);
        mViewPager.setOffscreenPageLimit(3); // tried to save data, but 1 is the minimum - just pre-cache everything

        // load the tabs (see: http://stackoverflow.com/a/12090317/1993220)
        mTabsAdapter = new TabsAdapter(this, mViewPager);
        mTabsAdapter.addTab(actionBar.newTab().setIcon(R.drawable.ic_action_clock_inverse),
                CloudEntityListFragment.class, getSortBundle(CloudEntity.PROP_CREATED_AT), false);
        mTabsAdapter.addTab(actionBar.newTab().setIcon(R.drawable.ic_action_location_inverse),
                CloudEntityListFragment.class, getSortBundle(QRCloudUtils.DATABASE_PROP_GEOCELL), true);
        mTabsAdapter.addTab(actionBar.newTab().setIcon(R.drawable.ic_action_star_10_inverse),
                CloudEntityListFragment.class, getSortBundle(QRCloudUtils.DATABASE_PROP_RATING), false);
        mTabsAdapter.addTab(actionBar.newTab().setIcon(R.drawable.ic_action_user_inverse),
                CloudEntityListFragment.class,
                getFilterBundle(F.Op.EQ.name(), CloudEntity.PROP_CREATED_BY, getCredentialAccountName()), false);
        // mTabsAdapter
        // .addTab(actionBar.newTab().setIcon(
        // mBarcodeFormat == BarcodeFormat.QR_CODE ? R.drawable.ic_action_qrcode_inverse
        // : R.drawable.ic_action_barcode_inverse), CodeViewerFragment.class, barcodeDetailsBundle);
    }

    private Bundle getSortBundle(String sortProperty) {
        Bundle sortBundle = new Bundle();
        sortBundle.putString(QRCloudUtils.FRAGMENT_SORT_TYPE, sortProperty);
        return sortBundle;
    }

    private Bundle getFilterBundle(String operator, String property, String name) {
        Bundle sortBundle = new Bundle();
        sortBundle.putString(QRCloudUtils.FRAGMENT_EXTRA_FILTER_OPERATOR, operator);
        sortBundle.putString(QRCloudUtils.FRAGMENT_EXTRA_FILTER_PROPERTY, property);
        sortBundle.putString(QRCloudUtils.FRAGMENT_EXTRA_FILTER_VALUE, name);
        return sortBundle;
    }

    // private Bundle getBarcodeDetailsBundle(BarcodeFormat format, int title, String contents, ParsedResultType type) {
    // Bundle sortBundle = new Bundle();
    // sortBundle.putString(QRCloudUtils.DATABASE_PROP_FORMAT, format.name());
    // sortBundle.putInt(getString(R.string.key_display_title), title);
    // sortBundle.putString(getString(R.string.key_display_contents), contents);
    // sortBundle.putString(QRCloudUtils.DATABASE_PROP_TYPE, type.name());
    // return sortBundle;
    // }

    @Override
    public void onSaveInstanceState(Bundle savedInstanceState) {
        savedInstanceState.putBoolean(getString(R.string.key_location_tab_visited), mLocationTabEnabled);
        savedInstanceState.putString(getString(R.string.key_product_details), mProductDetails);
        if (mLocation != null) {
            savedInstanceState.putDouble(QRCloudUtils.DATABASE_PROP_LATITUDE, mLocation.getLatitude());
            savedInstanceState.putDouble(QRCloudUtils.DATABASE_PROP_LONGITUDE, mLocation.getLongitude());
        }
        savedInstanceState.putLong(getString(R.string.key_location_request_time), mManualLocationRequestTime);
        super.onSaveInstanceState(savedInstanceState);
    }

    @Override
    protected void onPostCreate() {
        SharedPreferences sharedPreferences = getCloudBackend().getSharedPreferences();
        long banCheckTime = sharedPreferences.getLong(getString(R.string.check_ban_time), 0);
        long versionCheckTime = sharedPreferences.getLong(getString(R.string.check_ban_time), 0);
        boolean questionnaireCompleted = sharedPreferences
                .getBoolean(getString(R.string.check_questionnaire_completed), false);
        int codesScanned = sharedPreferences.getInt(getString(R.string.check_codes_scanned), 0);
        long currentTime = System.currentTimeMillis();

        // show the questionnaire if they haven't already completed it on another device
        if (codesScanned >= 4 && !questionnaireCompleted) {
            // delay so we let the UI queries load first
            new Handler().postDelayed(new Runnable() {
                @Override
                public void run() {
                    CloudCallbackHandler<List<CloudEntity>> userQuestionnaireHandler = new CloudCallbackHandler<List<CloudEntity>>() {
                        @Override
                        public void onComplete(List<CloudEntity> results) {
                            if (results == null) {
                                return; // nothing we can do
                            }
                            if (results.size() == 0) {
                                startActivity(new Intent(MessageViewerActivity.this, QuestionnaireActivity.class));
                            } else {
                                // so we don't do this query again
                                SharedPreferences sharedPreferences = getCloudBackend().getSharedPreferences();
                                Editor editor = sharedPreferences.edit();
                                editor.putBoolean(getString(R.string.check_questionnaire_completed), true);
                                editor.commit();
                            }
                        }

                        @Override
                        public void onError(IOException exception) {
                            // nothing we can do
                        }
                    };

                    CloudQuery cloudQuery = new CloudQuery(QRCloudUtils.DATABASE_KIND_QUESTIONNAIRE);
                    cloudQuery.setFilter(F.eq(CloudEntity.PROP_CREATED_BY, getPreferencesAccountName()));
                    cloudQuery.setLimit(1);
                    cloudQuery.setScope(Scope.PAST);

                    getCloudBackend().list(cloudQuery, userQuestionnaireHandler);
                }
            }, NON_URGENT_QUERY_DELAY);
        }

        // TODO: is this the reason why it can be slow to load codes initially?
        // check for banned users
        if (mUserState == UserState.UNKNOWN
                || currentTime - banCheckTime > getResources().getInteger(R.integer.ban_check_interval)) {

            mUserState = UserState.CHECKING;

            // save the new query time (regardless of failures)
            SharedPreferences.Editor editor = sharedPreferences.edit();
            editor.putLong(getString(R.string.check_ban_time), currentTime);
            editor.commit();

            // delay so we let the UI queries load first
            new Handler().postDelayed(new Runnable() {
                @Override
                public void run() {
                    CloudCallbackHandler<List<CloudEntity>> userStateHandler = new CloudCallbackHandler<List<CloudEntity>>() {
                        @Override
                        public void onComplete(List<CloudEntity> results) {
                            if (results == null) {
                                return; // nothing we can do (could change state to unknown again?)
                            }
                            mUserState = results.size() > 0 ? UserState.BANNED : UserState.ALLOWED;
                            showBannedMessage();
                        }

                        @Override
                        public void onError(IOException exception) {
                            // nothing we can do (could change state to unknown again?)
                        }
                    };

                    CloudQuery cloudQuery = new CloudQuery(QRCloudUtils.DATABASE_KIND_BANS);
                    cloudQuery.setFilter(F.eq(QRCloudUtils.DATABASE_PROP_USER, getPreferencesAccountName()));
                    cloudQuery.setLimit(1);
                    cloudQuery.setScope(Scope.PAST);

                    getCloudBackend().list(cloudQuery, userStateHandler);
                }
            }, NON_URGENT_QUERY_DELAY);
        } else {
            showBannedMessage();
        }

        // check for new application versions (note that only certain versions are forced; others are just bug fixes)
        if (mApplicationState == ApplicationState.UNKNOWN
                || currentTime - versionCheckTime > getResources().getInteger(R.integer.version_check_interval)) {

            mApplicationState = ApplicationState.CHECKING;

            // save the new time (regardless of failures)
            SharedPreferences.Editor editor = sharedPreferences.edit();
            editor.putLong(getString(R.string.check_version_time), currentTime);
            editor.commit();

            int currentAppVersion = -1;
            try {
                PackageManager manager = getPackageManager();
                PackageInfo info = manager.getPackageInfo(getPackageName(), 0);
                currentAppVersion = info.versionCode;
            } catch (Exception e) {
                // nothing we can do
            }

            // check if an upgrade is available
            if (currentAppVersion >= 0) {

                // delay so we let the UI queries load first
                final int currentVersion = currentAppVersion;
                new Handler().postDelayed(new Runnable() {
                    @Override
                    public void run() {

                        CloudCallbackHandler<List<CloudEntity>> applicationStateHandler = new CloudCallbackHandler<List<CloudEntity>>() {
                            @Override
                            public void onComplete(List<CloudEntity> results) {
                                if (results == null) {
                                    return; // nothing we can do (could change state to unknown again?)
                                }
                                mApplicationState = results.size() > 0 ? ApplicationState.OLD
                                        : ApplicationState.CURRENT;
                                showUpdateMessage();
                            }

                            @Override
                            public void onError(IOException exception) {
                                // nothing we can do (could change state to unknown again?)
                            }
                        };

                        CloudQuery cloudQuery = new CloudQuery(QRCloudUtils.DATABASE_KIND_VERSIONS);
                        cloudQuery.setFilter(F.gt(QRCloudUtils.DATABASE_PROP_VERSION, currentVersion));
                        cloudQuery.setLimit(1);
                        cloudQuery.setScope(Scope.PAST);

                        getCloudBackend().list(cloudQuery, applicationStateHandler);
                    }
                }, NON_URGENT_QUERY_DELAY);
            }
        } else {
            showUpdateMessage();
        }
    }

    @Override
    protected void onStart() {
        super.onStart();
        if (mLocationClient != null) {
            mLocationClient.connect(); // register for location updates
        }
    }

    @Override
    protected void onStop() {
        // disconnect and invalidate the location client
        if (mLocationClient != null) {
            mLocationClient.disconnect();
        }
        super.onStop();
    }

    private void showUpdateMessage() {
        if (mApplicationState == ApplicationState.OLD) {
            AlertDialog.Builder builder = new AlertDialog.Builder(MessageViewerActivity.this);
            builder.setTitle(R.string.new_version_title);
            builder.setMessage(getString(R.string.new_version_message));
            builder.setIcon(android.R.drawable.ic_dialog_alert);
            builder.setNegativeButton(R.string.btn_exit, new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int whichButton) {
                    Intent finishIntent = new Intent();
                    finishIntent.putExtra(QRCloudUtils.DATABASE_KIND_VERSIONS, true);
                    setResult(Activity.RESULT_OK, finishIntent);
                    finish();
                }
            });
            builder.setPositiveButton(R.string.btn_update, new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int whichButton) {
                    Intent finishIntent = new Intent();
                    finishIntent.putExtra(QRCloudUtils.DATABASE_KIND_VERSIONS, true);
                    setResult(Activity.RESULT_OK, finishIntent);
                    finish();

                    // must post delayed or the camera crashes
                    new Handler().postDelayed(new Runnable() {
                        @Override
                        public void run() {
                            try {
                                Intent intent = new Intent(Intent.ACTION_VIEW);
                                intent.setData(
                                        Uri.parse("market://details?id=" + getString(R.string.market_package)));
                                startActivity(intent);
                            } catch (ActivityNotFoundException e) {
                                Toast.makeText(getApplicationContext(), R.string.new_version_failure,
                                        Toast.LENGTH_SHORT).show();
                            }
                        }
                    }, 250);
                }
            });
            builder.show();
        }
    }

    private void showBannedMessage() {
        if (mUserState == UserState.BANNED) {
            AlertDialog.Builder builder = new AlertDialog.Builder(MessageViewerActivity.this);
            builder.setTitle(R.string.banned_user_title);
            builder.setMessage(getString(R.string.banned_user_message));
            builder.setIcon(android.R.drawable.ic_dialog_alert);
            builder.setNegativeButton(R.string.btn_exit, new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int whichButton) {
                    Intent finishIntent = new Intent();
                    finishIntent.putExtra(QRCloudUtils.DATABASE_KIND_BANS, true);
                    setResult(Activity.RESULT_OK, finishIntent);
                    finish();
                }
            });
            builder.setPositiveButton(R.string.btn_contact, new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int whichButton) {
                    Intent finishIntent = new Intent();
                    finishIntent.putExtra(QRCloudUtils.DATABASE_KIND_BANS, true);
                    setResult(Activity.RESULT_OK, finishIntent);
                    finish();

                    // must post delayed or the camera crashes
                    new Handler().postDelayed(new Runnable() {
                        @Override
                        public void run() {
                            try {
                                Intent emailIntent = new Intent(android.content.Intent.ACTION_SEND);
                                emailIntent.setType("plain/text");
                                emailIntent.putExtra(android.content.Intent.EXTRA_EMAIL,
                                        new String[] { getString(R.string.contact_email) });
                                emailIntent.putExtra(android.content.Intent.EXTRA_SUBJECT,
                                        getString(R.string.banned_user_email_title));
                                startActivity(Intent.createChooser(emailIntent, getString(R.string.btn_contact)));
                            } catch (ActivityNotFoundException e) {
                                Toast.makeText(getApplicationContext(), R.string.contact_email_failure,
                                        Toast.LENGTH_SHORT).show();
                            }
                        }
                    }, 250);
                }
            });
            builder.show();
        }
    }

    @Override
    protected void onDestroy() {
        if (mLocationListener != null) {
            mLocationListener.cancelGetLocation();
        }
        mLocationTabHandler.removeCallbacks(mLocationTabRunnable);
        mRefreshCompletedHandler.removeCallbacks(mRefreshCompletedRunnable);
        super.onDestroy();
    }

    private void parseCodeDetailsAndUpdate(Intent launchIntent, String codeContents, boolean updateCode) {

        // need the format to send when composing a message
        try {
            mBarcodeFormat = BarcodeFormat.valueOf(launchIntent.getStringExtra(QRCloudUtils.DATABASE_PROP_FORMAT));
        } catch (IllegalArgumentException e) {
        }

        // store the type for later analysis
        try {
            mBarcodeType = ParsedResultType.valueOf(launchIntent.getStringExtra(QRCloudUtils.DATABASE_PROP_TYPE));
        } catch (IllegalArgumentException e) {
        }

        // insert/update into the codes database
        if (updateCode) {
            updateCode(mCodeHash, codeContents, mBarcodeFormat, mBarcodeType);
        }

        // show the code details in a tab
        int barcodeTitle = launchIntent.getIntExtra(getString(R.string.key_display_title), R.string.result_text);

        // note: we ignore formatted contents for URLs, as this was breaking case-sensitive links
        String formattedBarcodeContents = launchIntent.getStringExtra(getString(R.string.key_display_contents));
        if (mBarcodeType == ParsedResultType.URI) {
            if (formattedBarcodeContents.matches("(?i)^http[s]?\\://[a-z\\-]+\\.qrwp\\.org.*$")) { // case-insensitive
                barcodeTitle = R.string.result_qrpedia; // special display for QRpedia codes
            }
            formattedBarcodeContents = codeContents;
        }

        // barcodeDetailsBundle = getBarcodeDetailsBundle(mBarcodeFormat, barcodeTitle, formattedBarcodeContents,
        // barcodeType);

        // show the code details
        TextView titleText = (TextView) findViewById(R.id.code_info_title);
        titleText.setText(getString(R.string.result_type, getString(barcodeTitle)));
        mCodeContents = (TextView) findViewById(R.id.code_info_contents);
        mCodeContents.setText(formattedBarcodeContents);

        // request the product details if applicable
        if (mBarcodeFormat == BarcodeFormat.UPC_A || mBarcodeFormat == BarcodeFormat.UPC_E
                || mBarcodeFormat == BarcodeFormat.UPC_EAN_EXTENSION || mBarcodeFormat == BarcodeFormat.EAN_8
                || mBarcodeFormat == BarcodeFormat.EAN_13) {
            if (mProductDetails != null) {
                setCodeDetails(mProductDetails);
            } else {
                try {
                    requestBarcodeDetails(codeContents);
                } catch (JSONException e) {
                    // problem getting code details - ignore
                }
            }
        } else if (mBarcodeType == ParsedResultType.EMAIL_ADDRESS || mBarcodeType == ParsedResultType.URI
                || mBarcodeType == ParsedResultType.TEL || mBarcodeType == ParsedResultType.GEO) {
            // linkify where appropriate
            Linkify.addLinks(mCodeContents, Linkify.ALL);
        }

        // set the correct fonts
        titleText.setTypeface(Typefaces.get(MessageViewerActivity.this, getString(R.string.default_font_bold)));
        mCodeContents.setTypeface(Typefaces.get(MessageViewerActivity.this, getString(R.string.default_font)));
    }

    private void requestBarcodeDetails(String barcode) throws JSONException {
        UPCDatabaseRestClient.get(barcode, null, new JsonHttpResponseHandler() {
            @Override
            public void onSuccess(int a, JSONObject result) {
                if (result != null) {
                    try {
                        // for UPC database
                        // if (result.getBoolean("valid")) {
                        // setCodeDetails(result.getString("description"));
                        // }

                        // for EAN database
                        JSONObject product = result.getJSONObject("product");
                        if (product != null) {
                            JSONObject attributes = product.getJSONObject("attributes");
                            if (attributes != null) {
                                setCodeDetails(attributes.getString("product"));
                            }
                        }
                    } catch (JSONException e) {
                        // parse error - ignore
                    }
                }
            }
        });
    }

    private void setCodeDetails(String description) {
        mProductDetails = description;
        if (!TextUtils.isEmpty(mProductDetails)) {
            mCodeContents.setText(mCodeContents.getText() + " - " + mProductDetails);
        }
    }

    private void updateCode(final String codeHash, final String codeContents, final BarcodeFormat barcodeFormat,
            final ParsedResultType barcodeType) {

        // delay so we let the UI queries load first
        new Handler().postDelayed(new Runnable() {
            @Override
            public void run() {
                CloudCallbackHandler<List<CloudEntity>> handler = new CloudCallbackHandler<List<CloudEntity>>() {
                    @Override
                    public void onComplete(List<CloudEntity> results) {
                        if (results.size() <= 0) {
                            // no record for this code exists - create a CloudEntity with the new code
                            CloudEntity newCode = new CloudEntity(QRCloudUtils.DATABASE_KIND_CODES);
                            newCode.put(QRCloudUtils.DATABASE_PROP_HASH, codeHash);
                            newCode.put(QRCloudUtils.DATABASE_PROP_CONTENTS, codeContents);
                            newCode.put(QRCloudUtils.DATABASE_PROP_FORMAT, barcodeFormat.name());
                            newCode.put(QRCloudUtils.DATABASE_PROP_TYPE, barcodeType.name());
                            newCode.put(QRCloudUtils.DATABASE_PROP_SCANS, 1); // this is the initial scan
                            newCode.put(QRCloudUtils.DATABASE_PROP_SOURCE, ContentProviderAuthority.DB_SOURCE);

                            // execute the insertion; nothing we can do on error, so ignore the result
                            getCloudBackend().insert(newCode, null);
                        } else {
                            CloudEntity existingEntity = results.get(0);
                            if (existingEntity != null) {
                                Object currentScanCount = existingEntity.get(QRCloudUtils.DATABASE_PROP_SCANS);
                                if (currentScanCount != null) {
                                    // update to increase the scan count; nothing to do on error, so no result handler
                                    existingEntity.put(QRCloudUtils.DATABASE_PROP_SCANS,
                                            Integer.valueOf(currentScanCount.toString()) + 1);
                                    getCloudBackend().update(existingEntity, null);
                                }
                            }
                        }
                    }

                    @Override
                    public void onError(IOException exception) {
                        // nothing else we can do
                    }
                };

                // now search for an existing record of this code
                CloudQuery cloudQuery = new CloudQuery(QRCloudUtils.DATABASE_KIND_CODES);
                cloudQuery.setFilter(F.eq(QRCloudUtils.DATABASE_PROP_HASH, codeHash));
                cloudQuery.setLimit(1);
                cloudQuery.setScope(Scope.PAST);
                getCloudBackend().list(cloudQuery, handler);
            }
        }, NON_URGENT_QUERY_DELAY);
    }

    @Override
    public void reportItem(CloudEntity itemToReport) {
        // create a response handler that will receive the result or an error
        CloudCallbackHandler<CloudEntity> handler = new CloudCallbackHandler<CloudEntity>() {
            @Override
            public void onComplete(final CloudEntity result) {
                // nothing else to do here
                // TODO: queue updating this and the other lists to remove the item?
            }

            @Override
            public void onError(final IOException exception) {
                if (QRCloudUtils.DEBUG) {
                    Log.d(TAG, "Reporting exception: " + exception.getMessage()); // nothing much to do here
                }
            }
        };

        itemToReport.put(QRCloudUtils.DATABASE_PROP_REPORTED, true);
        getCloudBackend().update(itemToReport, handler);
    }

    @Override
    public void clickItem(CloudEntity itemToClick) {
        if (itemToClick == null) {
            return; // they've clicked on the empty/loading item between adding and refreshing
        }
        Object currentRating = itemToClick.get(QRCloudUtils.DATABASE_PROP_RATING);
        if (currentRating == null) {
            return; // they've clicked on their message between adding and refreshing
        }

        // create a response handler that will receive the result or an error
        CloudCallbackHandler<CloudEntity> handler = new CloudCallbackHandler<CloudEntity>() {
            @Override
            public void onComplete(final CloudEntity result) {
                // nothing else to do here
                // TODO: re-sort the lists?
            }

            @Override
            public void onError(final IOException exception) {
                if (QRCloudUtils.DEBUG) {
                    Log.d(TAG, "Rating increase exception: " + exception.getMessage()); // nothing much to do here
                }
            }
        };

        itemToClick.put(QRCloudUtils.DATABASE_PROP_RATING, Integer.valueOf(currentRating.toString()) + 1);
        getCloudBackend().update(itemToClick, handler);
    }

    @Override
    public String loadItems(CloudQuery cloudQuery, F extraFilter, CloudCallbackHandler<List<CloudEntity>> handler,
            String previousQueryId) {
        if (previousQueryId != null) {
            getCloudBackend().unsubscribeFromQuery(previousQueryId);
        }
        F queryFilter = F.and(F.createFilter(Op.EQ.name(), QRCloudUtils.DATABASE_PROP_HASH, mCodeHash),
                F.createFilter(Op.EQ.name(), QRCloudUtils.DATABASE_PROP_REPORTED, false)); // F caching doesn't work
        cloudQuery.setFilter(extraFilter == null ? queryFilter : F.and(queryFilter, extraFilter));
        getCloudBackend().list(cloudQuery, handler);
        return cloudQuery.getQueryId();
    }

    // handler and runnable for refreshing the location tab
    Handler mLocationTabHandler = new Handler();
    Runnable mLocationTabRunnable = new Runnable() {
        @Override
        public void run() {
            CloudEntityListFragment locationTab = mTabsAdapter.getLocationTab();
            if (locationTab != null) {
                locationTab.refreshList(true);
            }
        }
    };

    @Override
    public String getLocationHash() {
        if (mLocationTabEnabled) {
            if (mWaitingForGooglePlayLocation) {
                return QRCloudUtils.GEOCELL_LOADING_MAGIC_VALUE;
            }
            if (mGooglePlayLocationConnected) {
                mLocation = mLocationClient.getLastLocation();
            }
            if (!mGooglePlayLocationConnected || mLocation == null) {
                // return the cached location if we're asking again within the time limit
                long currentTime = System.currentTimeMillis();
                if (currentTime - mManualLocationRequestTime < mMinimumLocationRefreshWaitTime) {
                    return mLocation == null ? null
                            : GeocellUtils.compute(new Point(mLocation.getLatitude(), mLocation.getLongitude()),
                                    QRCloudUtils.GEOCELL_QUERY_PRECISION);
                }
                return requestManualLocationAndUpdateTab();
            }
            return mLocation == null ? null
                    : GeocellUtils.compute(new Point(mLocation.getLatitude(), mLocation.getLongitude()),
                            QRCloudUtils.GEOCELL_QUERY_PRECISION);
        }
        return null;
    }

    private String requestManualLocationAndUpdateTab() {
        if (mWaitingForGooglePlayLocation) {
            return QRCloudUtils.GEOCELL_LOADING_MAGIC_VALUE;
        }
        if (mGooglePlayLocationConnected) {
            mLocation = mLocationClient.getLastLocation();
            mLocationTabHandler.removeCallbacks(mLocationTabRunnable);
            mLocationTabHandler.post(mLocationTabRunnable);
        }
        if (!mGooglePlayLocationConnected || mLocation == null) {
            // request a location update, returning a magic value so the list knows to keep waiting
            LocationResult locationResult = new LocationResult() {
                @Override
                public void gotLocation(Location location) {
                    mLocation = location;
                    mManualLocationRequestTime = System.currentTimeMillis(); // so we have time to load actual content
                    mLocationTabHandler.removeCallbacks(mLocationTabRunnable);
                    mLocationTabHandler.post(mLocationTabRunnable);
                }
            };

            mManualLocationRequestTime = System.currentTimeMillis();
            if (mLocationListener == null) {
                mLocationListener = new LocationRetriever();
            }
            if (mLocationListener.getLocation(MessageViewerActivity.this, locationResult)) {
                return QRCloudUtils.GEOCELL_LOADING_MAGIC_VALUE;
            }
        }
        return null;
    }

    @Override
    public Location getLocation() {
        return mLocation;
    }

    private void setRefreshIcon(MenuItem item) {
        if (item != null) {
            mRefreshButtonItem = item;
            mRefreshButtonItem.setActionView(R.layout.refresh_button);
            ImageView refreshIcon = (ImageView) mRefreshButtonItem.getActionView()
                    .findViewById(R.id.refresh_button);
            refreshIcon.startAnimation(mRotateAnimation);
        } else if (mRefreshButtonItem != null) {
            ImageView refreshIcon = (ImageView) mRefreshButtonItem.getActionView()
                    .findViewById(R.id.refresh_button);
            refreshIcon.clearAnimation();
            mRotateAnimation.cancel();
            mRefreshButtonItem.setActionView(null);
            mRefreshButtonItem = null;
        } else {
            mRotateAnimation.cancel(); // always try to cancel the animation (even if we've rotated)
        }
    }

    // handler and runnable for updating the refresh icon
    Handler mRefreshCompletedHandler = new Handler();
    Runnable mRefreshCompletedRunnable = new Runnable() {
        @Override
        public void run() {
            setRefreshIcon(null); // when the animation finishes, return the button to its original state
        }
    };

    @Override
    public void refreshCompleted() {
        mRefreshCompletedHandler.removeCallbacks(mRefreshCompletedRunnable);
        mRefreshCompletedHandler.post(mRefreshCompletedRunnable);
    }

    @Override
    public String getCurrentAccountName() {
        return getPreferencesAccountName();
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getSupportMenuInflater().inflate(R.menu.activity_message_viewer, menu);
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
        case R.id.menu_scan_again:
            Intent scanIntent = new Intent(MessageViewerActivity.this, ScannerActivity.class);
            scanIntent.putExtra(getString(R.string.key_started_scanning), true);
            startActivity(scanIntent);
            return true;

        case R.id.menu_add_content:
            Intent addMessageIntent = new Intent(MessageViewerActivity.this, MessageEditorActivity.class);
            addMessageIntent.putExtra(QRCloudUtils.DATABASE_PROP_HASH, mCodeHash);
            addMessageIntent.putExtra(QRCloudUtils.DATABASE_PROP_FORMAT, mBarcodeFormat.name());
            addMessageIntent.putExtra(QRCloudUtils.DATABASE_PROP_TYPE, mBarcodeType.name());
            startActivityForResult(addMessageIntent, MessageEditorActivity.ADD_MESSAGE_REQUEST);
            return true;

        case R.id.menu_refresh:
            // request a refresh
            Fragment selectedFragment = mTabsAdapter.getSelectedItem();
            if (selectedFragment != null && selectedFragment instanceof CloudEntityListFragment) {
                // start the button animation
                setRefreshIcon(item);
                if (!((CloudEntityListFragment) selectedFragment).refreshList(false)) {
                    // the refresh was denied (too soon), so post a message to remove the animation manually
                    mRefreshCompletedHandler.removeCallbacks(mRefreshCompletedRunnable);
                    mRefreshCompletedHandler.postDelayed(mRefreshCompletedRunnable, 600);
                } else {
                    // refresh starting
                }
            }
            return true;

        case R.id.menu_view_clippings:
            startActivity(new Intent(MessageViewerActivity.this, SavedTextListActivity.class));
            return true;
        }
        return false;
    }

    /*
     * Called by Location Services when the request to connect the client finishes successfully. At this point, you can
     * request the current location or start periodic updates
     */
    @Override
    public void onConnected(Bundle dataBundle) {
        mWaitingForGooglePlayLocation = false;
        mGooglePlayLocationConnected = true;
        if (mLocation == null) {
            requestManualLocationAndUpdateTab();
        }
    }

    /*
     * Called by Location Services if the attempt to Location Services fails.
     */
    @Override
    public void onConnectionFailed(ConnectionResult connectionResult) {
        // couldn't connect to Play Services to get the location
        // for better handling of this, see: http://developer.android.com/training/location/retrieve-current.html
        mWaitingForGooglePlayLocation = false;
        mGooglePlayLocationConnected = false;
        if (mLocation == null) {
            requestManualLocationAndUpdateTab();
        }
    }

    /*
     * Called by Location Services if the connection to the location client is disconnected or drops because of an
     * error.
     */
    @Override
    public void onDisconnected() {
        mWaitingForGooglePlayLocation = false;
        mGooglePlayLocationConnected = false;
        if (mLocation == null) {
            requestManualLocationAndUpdateTab();
        }
    }

    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        switch (requestCode) {
        case MessageEditorActivity.ADD_MESSAGE_REQUEST:
            if (resultCode == RESULT_OK && data != null && data.getExtras() != null) {
                String message = data.getStringExtra(QRCloudUtils.DATABASE_PROP_MESSAGE);
                boolean geoTagged = data.getBooleanExtra(QRCloudUtils.DATABASE_PROP_GEOCELL, false);
                if (message != null) {
                    for (CloudEntityListFragment fragment : mTabsAdapter.getAllListTabs()) {
                        fragment.addItem(message, geoTagged);
                    }
                }
            }
        default:
            super.onActivityResult(requestCode, resultCode, data);
        }
    }

    // see: http://stackoverflow.com/a/11730613/1993220
    public class TabsAdapter extends FragmentPagerAdapter
            implements ActionBar.TabListener, ViewPager.OnPageChangeListener {
        private final Context mContext;
        private final ActionBar mActionBar;
        private final FragmentManager mFragmentManager;
        private final ViewPager mViewPager;
        private final ArrayList<TabInfo> mTabs;
        private SparseArray<String> mFragmentTags;
        private int mSelectedItem = 0;

        private int mLocationTabPosition = -1;

        class TabInfo {
            private final Class<?> clss;
            private final Bundle args;
            private final boolean isLocation;

            TabInfo(Class<?> _class, Bundle _args, boolean isLocationTab) {
                clss = _class;
                args = _args;
                isLocation = isLocationTab;
            }
        }

        public TabsAdapter(SherlockFragmentActivity activity, ViewPager pager) {
            super(activity.getSupportFragmentManager());
            mContext = activity;
            mActionBar = activity.getSupportActionBar();
            mFragmentManager = activity.getSupportFragmentManager();
            mTabs = new ArrayList<TabInfo>();
            mFragmentTags = new SparseArray<String>();
            mViewPager = pager;
            mViewPager.setAdapter(this);
            mViewPager.setOnPageChangeListener(this);
        }

        public void addTab(ActionBar.Tab tab, Class<?> clss, Bundle args, boolean isLocationTab) {
            TabInfo info = new TabInfo(clss, args, isLocationTab);
            tab.setTag(info);
            tab.setTabListener(this);
            mTabs.add(info);
            mActionBar.addTab(tab);
            notifyDataSetChanged();
        }

        @Override
        public int getCount() {
            return mTabs.size();
        }

        public Fragment getSelectedItem() {
            String tag = mFragmentTags.get(mSelectedItem);
            if (tag == null) {
                return null;
            }
            return mFragmentManager.findFragmentByTag(tag);
        }

        public CloudEntityListFragment getLocationTab() {
            String tag = mFragmentTags.get(mLocationTabPosition);
            if (tag == null) {
                return null;
            }
            return (CloudEntityListFragment) mFragmentManager.findFragmentByTag(tag); // we know it's the right type
        }

        public ArrayList<CloudEntityListFragment> getAllListTabs() {
            ArrayList<CloudEntityListFragment> allTabs = new ArrayList<CloudEntityListFragment>();
            for (int i = 0, n = mFragmentTags.size(); i < n; i++) {
                String tag = mFragmentTags.get(i);
                if (tag == null) {
                    return null;
                }
                Fragment fragment = mFragmentManager.findFragmentByTag(tag);
                if (fragment instanceof CloudEntityListFragment) {
                    allTabs.add((CloudEntityListFragment) fragment);
                }
            }
            return allTabs;
        }

        @Override
        public Object instantiateItem(ViewGroup container, int position) {
            Object obj = super.instantiateItem(container, position);
            if (obj instanceof Fragment) {
                // record the fragment tag here to refer to it later - http://stackoverflow.com/a/12104399/1993220
                Fragment f = (Fragment) obj;
                String tag = f.getTag();
                mFragmentTags.put(position, tag);
                TabInfo info = mTabs.get(position);
                if (info.isLocation) {
                    mLocationTabPosition = position;
                }
            }
            return obj;
        }

        @Override
        public Fragment getItem(int position) {
            TabInfo info = mTabs.get(position);
            if (info.isLocation) {
                mLocationTabPosition = position;
            }
            return Fragment.instantiate(mContext, info.clss.getName(), info.args);
        }

        @Override
        public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
        }

        @Override
        public void onPageSelected(int position) {
            mSelectedItem = position;
            mActionBar.setSelectedNavigationItem(position);

            if (!mLocationTabEnabled) {
                Fragment selectedFragment = getSelectedItem();
                if (selectedFragment != null && selectedFragment instanceof CloudEntityListFragment) {
                    CloudEntityListFragment cloudFragment = (CloudEntityListFragment) selectedFragment;
                    if (cloudFragment.getGeoHashEnabled()) {
                        // the location tab is selected - force refresh the location query and save the id for future
                        mLocationTabEnabled = true;
                        cloudFragment.refreshList(true);
                    }
                }
            }
        }

        @Override
        public void onPageScrollStateChanged(int state) {
        }

        @Override
        public void onTabSelected(Tab tab, FragmentTransaction ft) {
            Object tag = tab.getTag();
            for (int i = 0, n = mTabs.size(); i < n; i++) {
                if (mTabs.get(i) == tag) {
                    mSelectedItem = i;
                    mViewPager.setCurrentItem(i);
                    break;
                }
            }
        }

        @Override
        public void onTabUnselected(Tab tab, FragmentTransaction ft) {
        }

        @Override
        public void onTabReselected(Tab tab, FragmentTransaction ft) {
        }
    }
}