package edu.vanderbilt.cs282.feisele.lab06.ui; import java.net.MalformedURLException; import java.net.URL; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import android.app.ProgressDialog; import android.content.AsyncQueryHandler; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; import android.database.Cursor; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; import android.os.IBinder; import android.os.RemoteException; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentManager; import android.support.v4.app.FragmentStatePagerAdapter; import android.support.v4.app.LoaderManager; import android.support.v4.content.CursorLoader; import android.support.v4.content.Loader; import android.support.v4.view.ViewPager; import android.text.Editable; import android.view.Menu; import android.view.MenuInflater; import android.view.View; import android.widget.EditText; import android.widget.Toast; import edu.vanderbilt.cs282.feisele.lab06.DownloadCallback; import edu.vanderbilt.cs282.feisele.lab06.DownloadRequest; import edu.vanderbilt.cs282.feisele.lab06.R; import edu.vanderbilt.cs282.feisele.lab06.lifecycle.LLActivity; import edu.vanderbilt.cs282.feisele.lab06.provider.DownloadContentProviderSchema.ImageTable; import edu.vanderbilt.cs282.feisele.lab06.provider.DownloadContentProviderSchema.Order; import edu.vanderbilt.cs282.feisele.lab06.provider.DownloadContentProviderSchema.Selection; import edu.vanderbilt.cs282.feisele.lab06.service.DownloadService; /** * * An activity which prompts the user for an image to download. <h2>Program * Description</h2> * <p> * This assignment builds upon the various Android concurrency models from the * previous assignmentsThis assignment gives you experience with several * variants of an Android ContentProvider to download bitmap images from a web * server and display them via an Activity that communicates to the * ContentProvider via a ContentResolver. This Activity has a similar user * interface as previous assignments and works as follows: * * <ol> * <li>The Activity provides a menu of buttons and displays a default image * (configured via the XML assets files)</li> * <li>The user is prompted to enter the URL for a new bitmap image</li> * <li>After entering the desired URL, the user can select one of several * buttons that provide different ways to download the image.</li> * <li>After the URL download has completed it will be displayed in an ImageView * </li> * <li>The user can reset the image to its default contents by clicking the * "Reset Image" button.</li> * * </ol> * <p> * note: the default image is configured via an XML resource file and the * default image itself is part of the project's assets. * * * * * <h2>Fault Handling</h2> * If there is a problem in the entered URL a toast is displayed indicating the * problem. * * <h2>Details</h2> * <ul> * <li> * It contains a DownloadActivity class that inherits from Activity and uses the * XML layout containing a TextView object that prompts for the URL of the * bitmap file and stores the entered URL in an EditText object. * <li> * It uses five Button objects with the labels "Download File", * "Query via query()", "Query via CursorLoader", "Query via AsyncQueryHandler", * and "Reset Image" to run the corresponding hook methods that use the URL * provided by the user to download and display the designated bitmap file using * the appropriate methods. * * <li> * The service components must run in a separate process than the * DownloadActivity component. * <li> * The Button objects that initiate the downloading of the bitmap file must be * connected to the corresponding DownloadActivity.run*() methods via the * appropriate android:onClick="..." attributes. * </ul> * * * @author "Fred Eisele" <phreed@gmail.com> * */ public class DownloadActivity extends LLActivity { static private final Logger logger = LoggerFactory .getLogger("class.activity.download"); static final int NUM_ITEMS = 10; private static final int IMAGE_LOADER_ID = 0x01; private CursorPagerAdapter<DownloadFragment> adapter; private ViewPager pager; private EditText urlEditText = null; private ProgressDialog progress; private Uri activeUri = null; public String getActiveUri() { if (this.activeUri == null) return ""; return this.activeUri.toString(); } private LoaderManager.LoaderCallbacks<Cursor> imageCursorLoader = null; /** * An extension to the basic connection which holds information about the * state of the connection and the service binding. * <p> * This cannot be implemented as a generic as there is no interface defining * the asInterface() method on the stub. (or at least I don't know how) */ static abstract public class DownloadServiceConnection<T> implements ServiceConnection { public T service; public boolean isBound; public void onServiceConnected(ComponentName className, IBinder iservice) { logger.debug("call service connected"); this.isBound = true; } public void onServiceDisconnected(ComponentName name) { this.isBound = false; } }; /** * provide implementation for the DownloadCall class. */ static private DownloadServiceConnection<DownloadRequest> asyncConnection = new DownloadServiceConnection<DownloadRequest>() { @Override public void onServiceConnected(ComponentName className, IBinder iservice) { super.onServiceConnected(className, iservice); this.service = DownloadRequest.Stub.asInterface(iservice); } }; /** * The fragment is used to preserve state across various changes. * <p> * In this implementation fragment is not intimately tied to a single * activity instance. When the activity is restarted a new activity instance * is (may be) created. The fragment persists across this restart and it * must be attached to the new activity instance. For this reason the * convenient "<fragment>" xml element cannot be used. A ViewGroup (in this * case a FrameLayout is used. * <p> * * @see http * ://developer.android.com/training/basics/fragments/fragment-ui.html * #AddAtRuntime * * @param savedInstanceState */ @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main_download); this.pager = (ViewPager) findViewById(R.id.image_pager); this.adapter = new CursorPagerAdapter<DownloadFragment>( getSupportFragmentManager(), DownloadFragment.class, null); this.pager.setAdapter(this.adapter); this.urlEditText = (EditText) findViewById(R.id.edit_image_url); this.explicitlyBindService(DownloadActivity.asyncConnection, DownloadService.class); this.imageCursorLoader = new MyCursorLoader(this); if (savedInstanceState != null) { this.activeUri = Uri.parse(savedInstanceState .getString(ACTIVE_URL_KEY)); } final Bundle bundle = new Bundle(); bundle.putString(ACTIVE_URL_KEY, this.getActiveUri()); final LoaderManager lm = this.getSupportLoaderManager(); lm.initLoader(IMAGE_LOADER_ID, bundle, this.imageCursorLoader); } @Override public boolean onCreateOptionsMenu(Menu menu) { MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.downloader_menu, menu); return true; } final static String PROGRESS_RUNNING_STATE_KEY = "progress_running_state_key"; final static String ACTIVE_URL_KEY = "active_url_key"; /** * If the download is ongoing then the progress indicator will need to be * started. Set a flag in the fragment indicating that there is a pending * download as well. */ @Override public void onRestoreInstanceState(Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); final boolean wasProgressRunning = savedInstanceState .getBoolean(PROGRESS_RUNNING_STATE_KEY); if (wasProgressRunning) logger.trace("progress was running "); } @Override public void onSaveInstanceState(Bundle savedInstanceState) { savedInstanceState.putBoolean(PROGRESS_RUNNING_STATE_KEY, this.isProgressRunning()); savedInstanceState.putString(ACTIVE_URL_KEY, this.getActiveUri()); super.onSaveInstanceState(savedInstanceState); logger.debug("onSaveInstanceState"); } /** * Shut things down that aren't needed. */ @Override public void onStop() { super.onStop(); this.stopProgress(); if (DownloadActivity.asyncConnection.isBound) this.unbindService(DownloadActivity.asyncConnection); } /** * Generic helper method for binding to a service. * * @param <T> */ private void explicitlyBindService(ServiceConnection conn, Class<? extends DownloadService> clazz) { logger.debug("bining to service explicitly {} {}", conn, clazz); final Intent intent = new Intent(this, clazz); this.bindService(intent, conn, Context.BIND_AUTO_CREATE); } /** * User cursor loader to get the latest image from the content provider. */ private static class MyCursorLoader implements LoaderManager.LoaderCallbacks<Cursor> { private final DownloadActivity master; public MyCursorLoader(DownloadActivity master) { this.master = master; } @Override public Loader<Cursor> onCreateLoader(int id, Bundle bundle) { logger.debug("loader on create : {} {}", id, bundle); final String activeUrl = bundle.getString(ACTIVE_URL_KEY); final CursorLoader cursorLoader = new CursorLoader(this.master, ImageTable.CONTENT_URI, null, Selection.BY_URI.code, new String[] { activeUrl }, Order.BY_ID.ascending()); return cursorLoader; } @Override public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) { logger.debug("loader finished: {}", loader.getId()); switch (loader.getId()) { case IMAGE_LOADER_ID: logger.debug("loaded image"); this.master.adapter.swapCursor(cursor); } } @Override public void onLoaderReset(Loader<Cursor> loader) { logger.debug("loader reset"); switch (loader.getId()) { case IMAGE_LOADER_ID: this.master.adapter.swapCursor(null); } } } /** * (@see * http://tumble.mlcastle.net/post/25875136857/bridging-cursorloaders-and * -viewpagers-on-android) * * @param <F> */ public class CursorPagerAdapter<F extends Fragment> extends FragmentStatePagerAdapter { private final Class<F> fragmentClass; private Cursor cursor; /** * Provide the FragmentManager and initial Cursor (which is usually * null), the constructor also takes the Class object for the type of * Fragment you wish to create, and the projection you passed to your * CursorLoader. The projection will be used to automatically fill in * your Fragment’s arguments with the data from the Cursor. * * @param fm * @param fragmentClass * @param cursor */ public CursorPagerAdapter(final FragmentManager fm, final Class<F> fragmentClass, Cursor cursor) { super(fm); this.fragmentClass = fragmentClass; this.cursor = cursor; } /** * The cursor can return values of various types. These should be cast * into a similar form for the arguments. */ @Override public F getItem(int position) { if (cursor == null) { return null; } cursor.moveToPosition(position); final F frag; try { frag = this.fragmentClass.newInstance(); } catch (Exception ex) { throw new RuntimeException(ex); } frag.onAttach(DownloadActivity.this); final Bundle args = new Bundle(); final String[] projection = cursor.getColumnNames(); for (int ix = 0; ix < projection.length; ++ix) { switch (cursor.getType(ix)) { case Cursor.FIELD_TYPE_NULL: break; case Cursor.FIELD_TYPE_FLOAT: args.putFloat(projection[ix], cursor.getFloat(ix)); break; case Cursor.FIELD_TYPE_BLOB: break; case Cursor.FIELD_TYPE_INTEGER: args.putInt(projection[ix], cursor.getInt(ix)); break; case Cursor.FIELD_TYPE_STRING: args.putString(projection[ix], cursor.getString(ix)); break; } } frag.setArguments(args); return frag; } /** * Inform the pager how many items there are. */ @Override public int getCount() { if (cursor == null) { return 0; } return cursor.getCount(); } /** * This is pure voodoo to get the reload effect. * <p> * getItemPosition() is called when the host view is attempting to * determine if an item's position has changed such as following * notifyDataSetChanged(). Returning POSITION_NONE indicates the object * is no longer present in the adapter. When notifyDataSetChanged() is * called, getItemPosition() is called for each object. Returning * POSITION_NONE indicates that the position of the object nowhere, * hence the view pager will remove each view and reload. */ @Override public int getItemPosition(Object object) { return POSITION_NONE; } /** * Used to indicate that the underlying data has changed. The * notifyDataSetChanged() causes the views displayed by the fragments to * refresh. * * @param cursor */ public void swapCursor(Cursor cursor) { if (this.cursor == cursor) { return; } logger.debug("cursor swapped"); this.cursor = cursor; this.notifyDataSetChanged(); } } /** * Initialize and configure the progress dialog. Record the fact that a * download is expected in the fragment. */ private void startProgress(CharSequence msg) { logger.debug("startProgress"); this.progress = new ProgressDialog(this); this.progress.setTitle(R.string.dialog_progress_title); this.progress.setMessage(msg); this.progress.setProgressStyle(ProgressDialog.STYLE_SPINNER); this.progress.setProgress(0); this.progress.show(); } /** * The progress can be null when the activity is stopped. The progress is * not dismissed unless it is showing. Record the fact that progress in * complete in the fragment. */ private void stopProgress() { logger.debug("stopProgress"); if (this.isProgressRunning()) this.progress.dismiss(); } /** * Check to see if the progress indicator is active. * * @return */ private boolean isProgressRunning() { if (this.progress == null) return false; if (!this.progress.isShowing()) return false; return true; } /** * Progress dialog can be shut down the */ public void onComplete(final String msg) { logger.debug("onComplete"); this.runOnUiThread(new Runnable() { final DownloadActivity master = DownloadActivity.this; public void run() { master.activeUri = Uri.parse(msg); master.stopProgress(); } }); } /** * Should any of the downloads fail produce a warning indicator on the url * field. As this is most likely called from the background thread * performing the download the field update is forced to the ui thread. */ public void onFault(final CharSequence msg) { this.runOnUiThread(new Runnable() { final CharSequence msg_ = msg; final DownloadActivity master = DownloadActivity.this; public void run() { logger.debug("onFault"); Toast.makeText(master, msg, Toast.LENGTH_LONG).show(); final Drawable dr = master.getResources().getDrawable( R.drawable.indicator_input_warn); dr.setBounds(0, 0, dr.getIntrinsicWidth(), dr.getIntrinsicHeight()); master.urlEditText.setError(msg_, dr); master.stopProgress(); } }); } /** * Extract the url from the edit text widget. Check that the string in the * widget is a proper url. If the field is empty then use the value provided * as the hint. If the field is invalid * <ul> * <li>return a null indicating that the action should not be performed.</li> * <li>generate a toast informing the operator of his error</li> * <li>mark the field as having an error</li> * </ul> * * @return */ private Uri getValidUrlFromWidget() { final Editable urlEditable = this.urlEditText.getText(); if (urlEditable.length() < 1) { return getUrlFromHint(this.urlEditText.getHint()); } final String uriStr = urlEditable.toString(); if (uriStr == null) { return getUrlFromHint(this.urlEditText.getHint()); } try { /** a cheap parse, not exactly correct, I'll get this next time */ new URL(uriStr); return Uri.parse(uriStr); } catch (MalformedURLException e) { logger.warn("bad uri string"); } final CharSequence errorMsg = this.getResources().getText( R.string.error_malformed_url); this.urlEditText.setError(errorMsg); Toast.makeText(this, errorMsg, Toast.LENGTH_LONG).show(); return null; } /** * If the operator has not actually entered a uri then get the one provided * as a hint. * * @param seq * @return */ private Uri getUrlFromHint(final CharSequence seq) { final String uriStr = seq.toString(); if (uriStr == null) { return Uri.parse(this.getResources() .getText(R.string.prompt_image_url).toString()); } return Uri.parse(uriStr); } /** * Load the default image from the assets. * * @param view */ public void resetImage(View view) { logger.debug("resetDatabase"); final AsyncQueryHandler handler = new AsyncQueryHandler( this.getContentResolver()) { @Override public void onDeleteComplete(int token, Object cookie, int result) { logger.debug("reset complete : {} items deleted", result); final DownloadActivity master = (DownloadActivity) cookie; master.runOnUiThread(new Runnable() { @Override public void run() { logger.debug("default cursor swap"); master.adapter.swapCursor(ImageTable.DEFAULT_CURSOR); } }); } }; handler.startDelete(1, this, ImageTable.CONTENT_URI, Selection.ALL.code, null); } /** * Download Images * <p> * Activity uses the Async AIDL model from assignment 5 to request a Bound * Service download the bitmaps in the designated URL and store them in the * application's internal storage. The Service should then create a URI for * the file that indicates file metadata (e.g., the timestamp for when the * file was downloaded represented as a long) and insert the corresponding * URI for the file into the ContentProvider (defined in your * AndroidManifest.xml file) along with metadata about the file . The * callback AIDL method returns the URI to the Activity, which displays the * URI as a Toast.</dd> * * @param view */ public void runDownload(View view) { logger.debug("runDownloadAsyncAidl"); final Uri uri = this.getValidUrlFromWidget(); if (uri == null) return; if (!DownloadActivity.asyncConnection.isBound) { logger.warn("async service not bound"); return; } logger.debug("download async aidl"); try { DownloadActivity.asyncConnection.service.downloadImage(uri, callback); } catch (RemoteException ex) { logger.error("download async aidl", ex); } this.startProgress(this.getResources().getText( R.string.message_progress_async)); } private final DownloadCallback.Stub callback = new DownloadCallback.Stub() { private DownloadActivity master = DownloadActivity.this; public void sendPath(String url) throws RemoteException { master.onComplete(url); } public void sendFault(String msg) throws RemoteException { master.onFault(msg); } }; /** * Query via query() * <p> * The DownloadActivity spawns a Thread (or an AsyncTask) that calls query() * on the ContentResolver to request that the associated ContentProvider to * provide a Cursor containing all the file(s) that match the URI back to * thread, which opens the file(s) and causes the bitmap(s) to be displayed * on the screen. * * @param view */ public void runQueryViaQuery(View view) { logger.debug("run query via query()"); final Runnable makeQuery = new Runnable() { private final DownloadActivity master = DownloadActivity.this; public void run() { final Cursor cursor = master.getContentResolver().query( ImageTable.CONTENT_URI, null, Selection.BY_URI.code, new String[] { master.getActiveUri() }, Order.BY_ID.ascending()); master.runOnUiThread(new Runnable() { @Override public void run() { logger.debug("query cursor size=<{}>", cursor.getCount()); master.adapter.swapCursor(cursor); master.adapter.notifyDataSetChanged(); } }); } }; new Thread(makeQuery).start(); } /** * Query via CursorLoader * <p> * The DownloadActivity uses a CursorLoader to return a Cursor containing * all the file(s) that match the URI back to thread, which opens the * file(s) and causes the bitmap(s) to be displayed on the screen back to * DownloadActivity as an onLoadFinished() callback, which opens the file(s) * and causes the bitmap(s) to be displayed on the screen. * * @param view */ public void runQueryViaLoader(View view) { logger.debug("run query via content loader"); final Bundle bundle = new Bundle(); bundle.putString(ACTIVE_URL_KEY, this.getActiveUri()); final LoaderManager lm = this.getSupportLoaderManager(); lm.restartLoader(IMAGE_LOADER_ID, bundle, this.imageCursorLoader); } /** * Query via AsyncQueryHandler * <p> * The DownloadActivity uses an AsyncQueryHandler to return a Cursor * containing all the file(s) that match the URI back to thread, back to * DownloadActivity as an onQueryComplete() callback, which opens the * file(s) and causes the bitmap(s) to be displayed on the screen. * * @param view */ public void runQueryViaHandler(View view) { logger.debug("run query via async query handler"); final AsyncQueryHandler handler = new AsyncQueryHandler( this.getContentResolver()) { @Override public void onQueryComplete(int token, Object cookie, final Cursor cursor) { final DownloadActivity master = (DownloadActivity) cookie; master.runOnUiThread(new Runnable() { @Override public void run() { master.adapter.swapCursor(cursor); } }); } }; handler.startQuery(1, this, ImageTable.CONTENT_URI, null, Selection.BY_URI.code, new String[] { this.getActiveUri() }, Order.BY_ID.ascending()); } }