diff --git a/src/com/android/email/provider/WidgetProvider.java b/src/com/android/email/provider/WidgetProvider.java index b2287c037..ba2ee6924 100644 --- a/src/com/android/email/provider/WidgetProvider.java +++ b/src/com/android/email/provider/WidgetProvider.java @@ -17,728 +17,27 @@ package com.android.email.provider; import com.android.email.Email; -import com.android.email.R; -import com.android.email.ResourceHelper; -import com.android.email.Utility; -import com.android.email.activity.MessageCompose; -import com.android.email.activity.Welcome; -import com.android.email.activity.setup.AccountSetupBasics; -import com.android.email.data.ThrottlingCursorLoader; -import com.android.email.provider.EmailContent.Account; -import com.android.email.provider.EmailContent.AccountColumns; -import com.android.email.provider.EmailContent.Mailbox; -import com.android.email.provider.EmailContent.Message; -import com.android.email.provider.EmailContent.MessageColumns; +import com.android.email.widget.EmailWidget; +import com.android.email.widget.WidgetManager; -import android.app.Activity; -import android.app.PendingIntent; import android.app.Service; import android.appwidget.AppWidgetManager; import android.appwidget.AppWidgetProvider; -import android.content.ContentResolver; -import android.content.ContentUris; import android.content.Context; import android.content.Intent; -import android.content.Loader; -import android.content.res.Resources; -import android.database.Cursor; -import android.graphics.Typeface; -import android.net.Uri; -import android.net.Uri.Builder; -import android.os.AsyncTask; import android.os.Bundle; -import android.text.Spannable; -import android.text.SpannableString; -import android.text.SpannableStringBuilder; -import android.text.TextUtils; -import android.text.format.DateUtils; -import android.text.style.AbsoluteSizeSpan; -import android.text.style.ForegroundColorSpan; -import android.text.style.StyleSpan; import android.util.Log; -import android.view.View; -import android.widget.RemoteViews; import android.widget.RemoteViewsService; import java.io.FileDescriptor; import java.io.PrintWriter; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; public class WidgetProvider extends AppWidgetProvider { - private static final String TAG = "WidgetProvider"; - - /** - * When handling clicks in a widget ListView, a single PendingIntent template is provided to - * RemoteViews, and the individual "on click" actions are distinguished via a "fillInIntent" - * on each list element; when a click is received, this "fillInIntent" is merged with the - * PendingIntent using Intent.fillIn(). Since this mechanism does NOT preserve the Extras - * Bundle, we instead encode information about the action (e.g. view, reply, etc.) and its - * arguments (e.g. messageId, mailboxId, etc.) in an Uri which is added to the Intent via - * Intent.setDataAndType() - * - * The mime type MUST be set in the Intent, even though we do not use it; therefore, it's value - * is entirely arbitrary. - * - * Our "command" Uri is NOT used by the system in any manner, and is therefore constrained only - * in the requirement that it be syntactically valid. - * - * We use the following convention for our commands: - * widget://command//[/] - */ - private static final String WIDGET_DATA_MIME_TYPE = "com.android.email/widget_data"; - private static final Uri COMMAND_URI = Uri.parse("widget://command"); - - // Command names and Uri's built upon COMMAND_URI - private static final String COMMAND_NAME_SWITCH_LIST_VIEW = "switch_list_view"; - private static final Uri COMMAND_URI_SWITCH_LIST_VIEW = - COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_SWITCH_LIST_VIEW).build(); - private static final String COMMAND_NAME_VIEW_MESSAGE = "view_message"; - private static final Uri COMMAND_URI_VIEW_MESSAGE = - COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_VIEW_MESSAGE).build(); - - private static final int TOTAL_COUNT_UNKNOWN = -1; - private static final int MAX_MESSAGE_LIST_COUNT = 25; - - private static final String[] NO_ARGUMENTS = new String[0]; - private static final String SORT_TIMESTAMP_DESCENDING = MessageColumns.TIMESTAMP + " DESC"; - private static final String SORT_ID_ASCENDING = AccountColumns.ID + " ASC"; - private static final String[] ID_NAME_PROJECTION = {Account.RECORD_ID, Account.DISPLAY_NAME}; - private static final int ID_NAME_COLUMN_ID = 0; - private static final int ID_NAME_COLUMN_NAME = 1; - - - // Map holding our instantiated widgets, accessed by widget id - private final static Map sWidgetMap - = new ConcurrentHashMap(); - private static AppWidgetManager sWidgetManager; - private static Context sContext; - private static ContentResolver sResolver; - - private static int sSenderFontSize; - private static int sSubjectFontSize; - private static int sDateFontSize; - private static int sDefaultTextColor; - private static int sLightTextColor; - private static String sSubjectSnippetDivider; - private static String sConfigureText; - - /** - * Types of views that we're prepared to show in the widget - all mail, unread mail, and starred - * mail; we rotate between them. Each ViewType is composed of a selection string and a title. - */ - /* package */ enum ViewType { - ALL_INBOX(Message.INBOX_SELECTION, NO_ARGUMENTS, R.string.widget_all_mail), - UNREAD(Message.UNREAD_SELECTION, NO_ARGUMENTS, R.string.widget_unread), - STARRED(Message.ALL_FAVORITE_SELECTION, NO_ARGUMENTS, R.string.widget_starred), - ACCOUNT(Message.PER_ACCOUNT_INBOX_SELECTION, new String[1], 0); - - /* package */ final String selection; - /* package */ final String[] selectionArgs; - private final int titleResource; - private String title; - - ViewType(String _selection, String[] _selectionArgs, int _titleResource) { - selection = _selection; - selectionArgs = _selectionArgs; - titleResource = _titleResource; - } - - public String getTitle(Context context) { - if (title == null && titleResource != 0) { - title = context.getString(titleResource); - } - return title; - } - - @Override - public String toString() { - StringBuilder sb = new StringBuilder("ViewType:selection="); - sb.append("\""); - sb.append(selection); - sb.append("\""); - if (selectionArgs != null && selectionArgs.length > 0) { - sb.append(",args=("); - for (String arg : selectionArgs) { - sb.append(arg); - sb.append(", "); - } - sb.append(")"); - } - - return sb.toString(); - } - } - - static class EmailWidget implements RemoteViewsService.RemoteViewsFactory { - // The widget identifier - private final int mWidgetId; - - // The cursor underlying the message list for this widget; this must only be modified while - // holding mCursorLock - private volatile Cursor mCursor; - // A lock on our cursor, which is used in the UI thread while inflating views, and by - // our Loader in the background - private final Object mCursorLock = new Object(); - // Number of records in the cursor - private int mCursorCount = TOTAL_COUNT_UNKNOWN; - // The widget's loader (derived from ThrottlingCursorLoader) - private ViewCursorLoader mLoader; - private final ResourceHelper mResourceHelper; - // Number of defined accounts - private int mAccountCount = TOTAL_COUNT_UNKNOWN; - - // The current view type (all mail, unread, or starred for now) - /*package*/ ViewType mViewType = ViewType.STARRED; - - // The projection to be used by the WidgetLoader - public static final String[] WIDGET_PROJECTION = new String[] { - EmailContent.RECORD_ID, MessageColumns.DISPLAY_NAME, MessageColumns.TIMESTAMP, - MessageColumns.SUBJECT, MessageColumns.FLAG_READ, MessageColumns.FLAG_FAVORITE, - MessageColumns.FLAG_ATTACHMENT, MessageColumns.MAILBOX_KEY, MessageColumns.SNIPPET, - MessageColumns.ACCOUNT_KEY, MessageColumns.FLAGS - }; - public static final int WIDGET_COLUMN_ID = 0; - public static final int WIDGET_COLUMN_DISPLAY_NAME = 1; - public static final int WIDGET_COLUMN_TIMESTAMP = 2; - public static final int WIDGET_COLUMN_SUBJECT = 3; - public static final int WIDGET_COLUMN_FLAG_READ = 4; - public static final int WIDGET_COLUMN_FLAG_FAVORITE = 5; - public static final int WIDGET_COLUMN_FLAG_ATTACHMENT = 6; - public static final int WIDGET_COLUMN_MAILBOX_KEY = 7; - public static final int WIDGET_COLUMN_SNIPPET = 8; - public static final int WIDGET_COLUMN_ACCOUNT_KEY = 9; - public static final int WIDGET_COLUMN_FLAGS = 10; - - public EmailWidget(int _widgetId) { - super(); - if (Email.DEBUG) { - Log.d(TAG, "Creating EmailWidget with id = " + _widgetId); - } - mWidgetId = _widgetId; - mLoader = new ViewCursorLoader(); - if (sSubjectSnippetDivider == null) { - // Initialize string, color, dimension resources - Resources res = sContext.getResources(); - sSubjectSnippetDivider = - res.getString(R.string.message_list_subject_snippet_divider); - sSenderFontSize = res.getDimensionPixelSize(R.dimen.widget_senders_font_size); - sSubjectFontSize = res.getDimensionPixelSize(R.dimen.widget_subject_font_size); - sDateFontSize = res.getDimensionPixelSize(R.dimen.widget_date_font_size); - sDefaultTextColor = res.getColor(R.color.widget_default_text_color); - sDefaultTextColor = res.getColor(R.color.widget_default_text_color); - sLightTextColor = res.getColor(R.color.widget_light_text_color); - sConfigureText = res.getString(R.string.widget_other_views); - } - mResourceHelper = ResourceHelper.getInstance(sContext); - } - - /** - * Task for updating widget data (eg: the header, view list items, etc...) - * If parameter to {@link #execute(Boolean...)} is true, the current - * view is validated against the current set of accounts. And if the current view - * is determined to be invalid, the view will automatically progress to the next - * valid view. - */ - final class WidgetUpdateTask extends AsyncTask { - @Override - protected Boolean doInBackground(Boolean... validateView) { - mAccountCount = EmailContent.count(sContext, EmailContent.Account.CONTENT_URI); - // If displaying invalid view, switch to the next view - return !validateView[0] || isViewValid(); - } - - @Override - protected void onPostExecute(Boolean isValidView) { - updateHeader(); - if (!isValidView) { - new WidgetViewSwitcher(EmailWidget.this).execute(); - } - } - } - - /** - * The ThrottlingCursorLoader does all of the heavy lifting in managing the data loading - * task; all we need is to register a listener so that we're notified when the load is - * complete. - */ - final class ViewCursorLoader extends ThrottlingCursorLoader { - protected ViewCursorLoader() { - super(sContext, Message.CONTENT_URI, WIDGET_PROJECTION, mViewType.selection, - mViewType.selectionArgs, SORT_TIMESTAMP_DESCENDING); - registerListener(0, new OnLoadCompleteListener() { - @Override - public void onLoadComplete(Loader loader, Cursor cursor) { - synchronized (mCursorLock) { - // Save away the cursor - mCursor = cursor; - // Reset the notification Uri to our Message table notifier URI - mCursor.setNotificationUri(sResolver, Message.NOTIFIER_URI); - // Save away the count (for display) - mCursorCount = mCursor.getCount(); - if (Email.DEBUG) { - Log.d(TAG, "onLoadComplete, count = " + cursor.getCount()); - } - } - RemoteViews views = - new RemoteViews(sContext.getPackageName(), R.layout.widget); - setupTitleAndCount(views); - sWidgetManager.partiallyUpdateAppWidget(mWidgetId, views); - sWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list); - } - }); - } - - /** - * Stop any pending load, reset selection parameters, and start loading - * Must be called from the UI thread - * @param viewType the current ViewType - */ - private void load(ViewType viewType) { - reset(); - setSelection(viewType.selection); - setSelectionArgs(viewType.selectionArgs); - startLoading(); - } - } - - /** - * Initialize to first appropriate view (depending on the number of accounts) - */ - private void init() { - // Just update the account count & header; no need to validate the view - new WidgetUpdateTask().execute(false); - new WidgetViewSwitcher(this).execute(); - } - - /** - * Reset cursor and cursor count, notify widget that list data is invalid, and start loading - * with our current ViewType - */ - private void loadView() { - synchronized(mCursorLock) { - mCursorCount = TOTAL_COUNT_UNKNOWN; - mCursor = null; - sWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list); - mLoader.load(mViewType); - } - } - - /** - * Switch to the next widget view (all -> account1 -> ... -> account n -> unread -> starred) - * This must be called on a background thread - */ - public synchronized void switchToNextView() { - switch(mViewType) { - // If we're in starred and there is more than one account, go to "all mail" - // Otherwise, fall through to the accounts themselves - case STARRED: - if (EmailContent.count(sContext, Account.CONTENT_URI) > 1) { - mViewType = ViewType.ALL_INBOX; - break; - } - //$FALL-THROUGH$ - case ALL_INBOX: - ViewType.ACCOUNT.selectionArgs[0] = "0"; - //$FALL-THROUGH$ - case ACCOUNT: - // Find the next account (or, if none, default to UNREAD) - String idString = ViewType.ACCOUNT.selectionArgs[0]; - Cursor c = sResolver.query(Account.CONTENT_URI, ID_NAME_PROJECTION, "_id>?", - new String[] {idString}, SORT_ID_ASCENDING); - try { - if (c.moveToFirst()) { - mViewType = ViewType.ACCOUNT; - mViewType.selectionArgs[0] = c.getString(ID_NAME_COLUMN_ID); - mViewType.title = c.getString(ID_NAME_COLUMN_NAME); - } else { - mViewType = ViewType.UNREAD; - } - } finally { - c.close(); - } - break; - case UNREAD: - mViewType = ViewType.STARRED; - break; - } - } - - /** - * Returns whether the current view is valid. The following rules determine if a view is - * considered valid: - * 1. If the view is either {@link ViewType#STARRED} or {@link ViewType#UNREAD}, always - * returns true. - * 2. If the view is {@link ViewType#ALL_INBOX}, returns true if more than - * one account is defined. Otherwise, returns false. - * 3. If the view is {@link ViewType#ACCOUNT}, returns true if the account - * is defined. Otherwise, returns false. - */ - private boolean isViewValid() { - switch(mViewType) { - case ALL_INBOX: - // "all inbox" is valid only if there is more than one account - return (EmailContent.count(sContext, Account.CONTENT_URI) > 1); - case ACCOUNT: - // Ensure current account still exists - String idString = ViewType.ACCOUNT.selectionArgs[0]; - Cursor c = sResolver.query(Account.CONTENT_URI, ID_NAME_PROJECTION, "_id=?", - new String[] {idString}, SORT_ID_ASCENDING); - try { - return c.moveToFirst(); - } finally { - c.close(); - } - } - return true; - } - - /** - * Convenience method for creating an onClickPendingIntent that executes a command via - * our command Uri. Used for the "next view" command; appends the widget id to the command - * Uri. - * - * @param views The RemoteViews we're inflating - * @param buttonId the id of the button view - * @param data the command Uri - */ - private void setCommandIntent(RemoteViews views, int buttonId, Uri data) { - Intent intent = new Intent(sContext, WidgetService.class); - intent.setDataAndType(ContentUris.withAppendedId(data, mWidgetId), - WIDGET_DATA_MIME_TYPE); - PendingIntent pendingIntent = PendingIntent.getService(sContext, 0, intent, - PendingIntent.FLAG_UPDATE_CURRENT); - views.setOnClickPendingIntent(buttonId, pendingIntent); - } - - /** - * Convenience method for creating an onClickPendingIntent that launches another activity - * directly. - * - * @param views The RemoteViews we're inflating - * @param buttonId the id of the button view - * @param intent The intent to be used when launching the activity - */ - private void setActivityIntent(RemoteViews views, int buttonId, Intent intent) { - PendingIntent pendingIntent = PendingIntent.getActivity(sContext, 0, intent, 0); - views.setOnClickPendingIntent(buttonId, pendingIntent); - } - - /** - * Convenience method for constructing a fillInIntent for a given list view element. - * Appends the command and any arguments to a base Uri. - * - * @param views the RemoteViews we are inflating - * @param viewId the id of the view - * @param baseUri the base uri for the command - * @param args any arguments to the command - */ - private void setFillInIntent(RemoteViews views, int viewId, Uri baseUri, String ... args) { - Intent intent = new Intent(); - Builder builder = baseUri.buildUpon(); - for (String arg: args) { - builder.appendPath(arg); - } - intent.setDataAndType(builder.build(), WIDGET_DATA_MIME_TYPE); - views.setOnClickFillInIntent(viewId, intent); - } - - private void setupTitleAndCount(RemoteViews views) { - // Set up the title (view type + count of messages) - views.setTextViewText(R.id.widget_title, mViewType.getTitle(sContext)); - views.setTextViewText(R.id.widget_tap, sConfigureText); - String count = ""; - if (mCursorCount != TOTAL_COUNT_UNKNOWN) { - count = Utility.getMessageCountForUi(sContext, mCursor.getCount(), false); - } - views.setTextViewText(R.id.widget_count, count); - } - /** - * Update the "header" of the widget (i.e. everything that doesn't include the scrolling - * message list) - */ - private void updateHeader() { - if (Email.DEBUG) { - Log.d(TAG, "updateWidget " + mWidgetId); - } - - // Get the widget layout - RemoteViews views = new RemoteViews(sContext.getPackageName(), R.layout.widget); - - // Set up the list with an adapter - Intent intent = new Intent(sContext, WidgetService.class); - intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mWidgetId); - intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME))); - views.setRemoteAdapter(mWidgetId, R.id.message_list, intent); - - setupTitleAndCount(views); - - if (mAccountCount == 0) { - // Hide compose icon & show "touch to configure" text - views.setViewVisibility(R.id.widget_compose, View.INVISIBLE); - views.setViewVisibility(R.id.message_list, View.GONE); - views.setViewVisibility(R.id.tap_to_configure, View.VISIBLE); - // Create click intent for "touch to configure" target - intent = Welcome.createOpenAccountInboxIntent(sContext, -1); - setActivityIntent(views, R.id.tap_to_configure, intent); - } else { - // Show compose icon & message list - views.setViewVisibility(R.id.widget_compose, View.VISIBLE); - views.setViewVisibility(R.id.message_list, View.VISIBLE); - views.setViewVisibility(R.id.tap_to_configure, View.GONE); - // Create click intent for "compose email" target - intent = MessageCompose.getMessageComposeIntent(sContext, -1); - setActivityIntent(views, R.id.widget_compose, intent); - } - // Create click intent for "view rotation" target - setCommandIntent(views, R.id.widget_logo, COMMAND_URI_SWITCH_LIST_VIEW); - - // Use a bare intent for our template; we need to fill everything in - intent = new Intent(sContext, WidgetService.class); - PendingIntent pendingIntent = - PendingIntent.getService(sContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); - views.setPendingIntentTemplate(R.id.message_list, pendingIntent); - - // And finally update the widget - sWidgetManager.updateAppWidget(mWidgetId, views); - } - - /** - * Add size and color styling to text - * - * @param text the text to style - * @param size the font size for this text - * @param color the color for this text - * @return a CharSequence quitable for use in RemoteViews.setTextViewText() - */ - private CharSequence addStyle(CharSequence text, int size, int color) { - SpannableStringBuilder builder = new SpannableStringBuilder(text); - builder.setSpan( - new AbsoluteSizeSpan(size), 0, text.length(), - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - if (color != 0) { - builder.setSpan(new ForegroundColorSpan(color), 0, text.length(), - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - } - return builder; - } - - /** - * Create styled text for our combination subject and snippet - * - * @param subject the message's subject (or null) - * @param snippet the message's snippet (or null) - * @param read whether or not the message is read - * @return a CharSequence suitable for use in RemoteViews.setTextViewText() - */ - private CharSequence getStyledSubjectSnippet (String subject, String snippet, - boolean read) { - SpannableStringBuilder ssb = new SpannableStringBuilder(); - boolean hasSubject = false; - if (!TextUtils.isEmpty(subject)) { - SpannableString ss = new SpannableString(subject); - ss.setSpan(new StyleSpan(read ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(), - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - ss.setSpan(new ForegroundColorSpan(sDefaultTextColor), 0, ss.length(), - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - ssb.append(ss); - hasSubject = true; - } - if (!TextUtils.isEmpty(snippet)) { - if (hasSubject) { - ssb.append(sSubjectSnippetDivider); - } - SpannableString ss = new SpannableString(snippet); - ss.setSpan(new ForegroundColorSpan(sLightTextColor), 0, snippet.length(), - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - ssb.append(ss); - } - return addStyle(ssb, sSubjectFontSize, 0); - } - - /* (non-Javadoc) - * @see android.widget.RemoteViewsService.RemoteViewsFactory#getViewAt(int) - */ - public RemoteViews getViewAt(int position) { - // Use the cursor to set up the widget - synchronized (mCursorLock) { - if (mCursor == null || mCursor.isClosed() || !mCursor.moveToPosition(position)) { - return getLoadingView(); - } - RemoteViews views = - new RemoteViews(sContext.getPackageName(), R.layout.widget_list_item); - boolean isUnread = mCursor.getInt(WIDGET_COLUMN_FLAG_READ) != 1; - int drawableId = R.drawable.widget_read_conversation_selector; - if (isUnread) { - drawableId = R.drawable.widget_unread_conversation_selector; - } - views.setInt(R.id.widget_message, "setBackgroundResource", drawableId); - - // Add style to sender - SpannableStringBuilder from = - new SpannableStringBuilder(mCursor.getString(WIDGET_COLUMN_DISPLAY_NAME)); - from.setSpan( - isUnread ? new StyleSpan(Typeface.BOLD) : new StyleSpan(Typeface.NORMAL), 0, - from.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - CharSequence styledFrom = addStyle(from, sSenderFontSize, sDefaultTextColor); - views.setTextViewText(R.id.widget_from, styledFrom); - - long timestamp = mCursor.getLong(WIDGET_COLUMN_TIMESTAMP); - // Get a nicely formatted date string (relative to today) - String date = DateUtils.getRelativeTimeSpanString(sContext, timestamp).toString(); - // Add style to date - CharSequence styledDate = addStyle(date, sDateFontSize, sDefaultTextColor); - views.setTextViewText(R.id.widget_date, styledDate); - - // Add style to subject/snippet - String subject = mCursor.getString(WIDGET_COLUMN_SUBJECT); - String snippet = mCursor.getString(WIDGET_COLUMN_SNIPPET); - CharSequence subjectAndSnippet = - getStyledSubjectSnippet(subject, snippet, !isUnread); - views.setTextViewText(R.id.widget_subject, subjectAndSnippet); - - int messageFlags = mCursor.getInt(WIDGET_COLUMN_FLAGS); - boolean hasInvite = (messageFlags & Message.FLAG_INCOMING_MEETING_INVITE) != 0; - views.setViewVisibility(R.id.widget_invite, hasInvite ? View.VISIBLE : View.GONE); - - boolean hasAttachment = mCursor.getInt(WIDGET_COLUMN_FLAG_ATTACHMENT) != 0; - views.setViewVisibility(R.id.widget_attachment, - hasAttachment ? View.VISIBLE : View.GONE); - - if (mViewType == ViewType.ACCOUNT) { - views.setViewVisibility(R.id.color_chip, View.INVISIBLE); - } else { - long accountId = mCursor.getLong(WIDGET_COLUMN_ACCOUNT_KEY); - int colorId = mResourceHelper.getAccountColorId(accountId); - if (colorId != ResourceHelper.UNDEFINED_RESOURCE_ID) { - // Color defined by resource ID, so, use it - views.setViewVisibility(R.id.color_chip, View.VISIBLE); - views.setImageViewResource(R.id.color_chip, colorId); - } else { - // Color not defined by resource ID, nothing we can do, so, hide the chip - views.setViewVisibility(R.id.color_chip, View.INVISIBLE); - } - } - - // Set button intents for view, reply, and delete - String messageId = mCursor.getString(WIDGET_COLUMN_ID); - String mailboxId = mCursor.getString(WIDGET_COLUMN_MAILBOX_KEY); - setFillInIntent(views, R.id.widget_message, COMMAND_URI_VIEW_MESSAGE, messageId, - mailboxId); - - return views; - } - } - - @Override - public int getCount() { - if (mCursor == null) return 0; - return Math.min(mCursor.getCount(), MAX_MESSAGE_LIST_COUNT); - } - - @Override - public long getItemId(int position) { - return position; - } - - @Override - public RemoteViews getLoadingView() { - RemoteViews view = new RemoteViews(sContext.getPackageName(), R.layout.widget_loading); - view.setTextViewText(R.id.loading_text, sContext.getString(R.string.widget_loading)); - return view; - } - - @Override - public int getViewTypeCount() { - // Regular list view and the "loading" view - return 2; - } - - @Override - public boolean hasStableIds() { - return true; - } - - @Override - public void onDataSetChanged() { - } - - private void onDeleted() { - if (mLoader != null) { - mLoader.stopLoading(); - } - sWidgetMap.remove(mWidgetId); - } - - @Override - public void onDestroy() { - if (mLoader != null) { - mLoader.stopLoading(); - } - sWidgetMap.remove(mWidgetId); - } - - @Override - public void onCreate() { - } - } - - private static synchronized void update(Context context, int[] appWidgetIds) { - for (int widgetId: appWidgetIds) { - getOrCreateWidget(context, widgetId).updateHeader(); - } - } - - /** - * Updates all active widgets. If no widgets are active, does nothing. - */ - public static synchronized void updateAllWidgets() { - // Ignore if the widget is not active - if (sContext != null && sWidgetMap.size() > 0) { - Collection widgetSet = sWidgetMap.values(); - for (EmailWidget widget: widgetSet) { - // Anything could have changed; update widget & validate the current view - widget.new WidgetUpdateTask().execute(true); - } - } - } - - /** - * Force a context for widgets (used by unit tests) - * @param context the Context to set - */ - /*package*/ static void setContextForTest(Context context) { - sContext = context; - sResolver = context.getContentResolver(); - sWidgetManager = AppWidgetManager.getInstance(context); - } - - /*package*/ static EmailWidget getOrCreateWidget(Context context, int widgetId) { - // Lazily initialize these - if (sContext == null) { - sContext = context.getApplicationContext(); - sWidgetManager = AppWidgetManager.getInstance(context); - sResolver = context.getContentResolver(); - } - EmailWidget widget = sWidgetMap.get(widgetId); - if (widget == null) { - if (Email.DEBUG) { - Log.d(TAG, "Creating EmailWidget for id #" + widgetId); - } - widget = new EmailWidget(widgetId); - widget.init(); - sWidgetMap.put(widgetId, widget); - } - return widget; - } - @Override public void onDisabled(Context context) { super.onDisabled(context); if (Email.DEBUG) { - Log.d(TAG, "onDisabled"); + Log.d(EmailWidget.TAG, "onDisabled"); } context.stopService(new Intent(context, WidgetService.class)); } @@ -747,65 +46,27 @@ public class WidgetProvider extends AppWidgetProvider { public void onEnabled(final Context context) { super.onEnabled(context); if (Email.DEBUG) { - Log.d(TAG, "onEnabled"); + Log.d(EmailWidget.TAG, "onEnabled"); } } @Override public void onReceive(final Context context, Intent intent) { String action = intent.getAction(); - if (AppWidgetManager.ACTION_APPWIDGET_UPDATE.equals(action)) { - Bundle extras = intent.getExtras(); - if (extras != null) { - final int[] appWidgetIds = extras.getIntArray(AppWidgetManager.EXTRA_APPWIDGET_IDS); - if (appWidgetIds != null && appWidgetIds.length > 0) { - update(context, appWidgetIds); - } + Bundle extras = intent.getExtras(); + if (AppWidgetManager.ACTION_APPWIDGET_UPDATE.equals(action) && extras != null) { + final int[] appWidgetIds = extras.getIntArray(AppWidgetManager.EXTRA_APPWIDGET_IDS); + if (appWidgetIds != null && appWidgetIds.length > 0) { + WidgetManager.getInstance().getOrCreateWidgets(context, appWidgetIds); } - } else if (AppWidgetManager.ACTION_APPWIDGET_DELETED.equals(action)) { - Bundle extras = intent.getExtras(); - if (extras != null && extras.containsKey(AppWidgetManager.EXTRA_APPWIDGET_ID)) { - final int widgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID); - // Find the widget in the map - EmailWidget widget = sWidgetMap.get(widgetId); - if (widget != null) { - // Stop loading and remove the widget from the map - widget.onDeleted(); - } - } - } - } - - /** - * Utility class to handle switching widget views; in the background, we access the database - * to determine account status, etc. In the foreground, we start up the Loader with new - * parameters - */ - /*package*/ static class WidgetViewSwitcher extends AsyncTask { - private final EmailWidget mWidget; - private boolean mLoadAfterSwitch = true; - - public WidgetViewSwitcher(EmailWidget widget) { - mWidget = widget; - } - - /*package*/ void disableLoadAfterSwitchForTest() { - mLoadAfterSwitch = false; - } - - @Override - protected Void doInBackground(Void... params) { - mWidget.switchToNextView(); - return null; - } - - @Override - protected void onPostExecute(Void param) { - if (isCancelled()) { - return; - } - if (mLoadAfterSwitch) { - mWidget.loadView(); + } else if (AppWidgetManager.ACTION_APPWIDGET_DELETED.equals(action) && extras != null + && extras.containsKey(AppWidgetManager.EXTRA_APPWIDGET_ID)) { + final int widgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID); + // Find the widget in the map + EmailWidget widget = WidgetManager.getInstance().get(widgetId); + if (widget != null) { + // Stop loading and remove the widget from the map + widget.onDeleted(); } } } @@ -813,7 +74,8 @@ public class WidgetProvider extends AppWidgetProvider { /** * We use the WidgetService for two purposes: * 1) To provide a widget factory for RemoteViews, and - * 2) To process our command Uri's (i.e. take actions on user clicks) + * 2) Catch our command Uri's (i.e. take actions on user clicks) and let EmailWidget + * handle them. */ public static class WidgetService extends RemoteViewsService { @Override @@ -822,68 +84,21 @@ public class WidgetProvider extends AppWidgetProvider { int widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1); if (widgetId == -1) return null; // Find the existing widget or create it - return getOrCreateWidget(this, widgetId); - } - - @Override - public void startActivity(Intent intent) { - // Since we're not calling startActivity from an Activity, we need the new task flag - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_MULTIPLE_TASK); - super.startActivity(intent); + return WidgetManager.getInstance().getOrCreateWidget(this, widgetId); } @Override public int onStartCommand(Intent intent, int flags, int startId) { - Uri data = intent.getData(); - if (data == null) return Service.START_NOT_STICKY; - List pathSegments = data.getPathSegments(); - // Our path segments are , [, ] - // First, a quick check of Uri validity - if (pathSegments.size() < 2) { - throw new IllegalArgumentException(); - } - String command = pathSegments.get(0); - // Ignore unknown action names - try { - final long arg1 = Long.parseLong(pathSegments.get(1)); - if (COMMAND_NAME_VIEW_MESSAGE.equals(command)) { - // "view", , - final long mailboxId = Long.parseLong(pathSegments.get(2)); - final long messageId = arg1; - Utility.runAsync(new Runnable() { - @Override - public void run() { - openMessage(mailboxId, messageId); - } - }); - } else if (COMMAND_NAME_SWITCH_LIST_VIEW.equals(command)) { - // "next_view", - EmailWidget widget = sWidgetMap.get((int)arg1); - if (widget != null) { - WidgetViewSwitcher switcher = new WidgetViewSwitcher(widget); - switcher.execute(); - } - } - } catch (NumberFormatException e) { - // Shouldn't happen as we construct all of the Uri's + if (intent.getData() != null) { + // EmailWidget creates intents, so it knows how to handle them. + EmailWidget.processIntent(this, intent); } return Service.START_NOT_STICKY; } - private void openMessage(long mailboxId, long messageId) { - Mailbox mailbox = Mailbox.restoreMailboxWithId(this, mailboxId); - if (mailbox == null) return; - startActivity(Welcome.createOpenMessageIntent(this, mailbox.mAccountKey, mailboxId, - messageId)); - } - @Override protected void dump(FileDescriptor fd, PrintWriter writer, String[] args) { - int n = 0; - for (EmailWidget widget : sWidgetMap.values()) { - writer.println("Widget #" + (++n)); - writer.println(" ViewType=" + widget.mViewType); - } + WidgetManager.getInstance().dump(fd, writer, args); } } -} + } diff --git a/src/com/android/email/service/EmailBroadcastProcessorService.java b/src/com/android/email/service/EmailBroadcastProcessorService.java index ea930922e..d91e5559c 100644 --- a/src/com/android/email/service/EmailBroadcastProcessorService.java +++ b/src/com/android/email/service/EmailBroadcastProcessorService.java @@ -22,7 +22,7 @@ import com.android.email.Preferences; import com.android.email.SecurityPolicy; import com.android.email.VendorPolicyLoader; import com.android.email.activity.setup.AccountSettingsXL; -import com.android.email.provider.WidgetProvider; +import com.android.email.widget.WidgetManager; import android.accounts.AccountManager; import android.app.IntentService; @@ -183,6 +183,6 @@ public class EmailBroadcastProcessorService extends IntentService { ExchangeUtils.startExchangeService(this); // Let all of the widgets update - WidgetProvider.updateAllWidgets(); + WidgetManager.getInstance().updateAllWidgets(); } } diff --git a/src/com/android/email/widget/EmailWidget.java b/src/com/android/email/widget/EmailWidget.java new file mode 100644 index 000000000..2182bd780 --- /dev/null +++ b/src/com/android/email/widget/EmailWidget.java @@ -0,0 +1,746 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * 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 com.android.email.widget; + +import com.android.email.Email; +import com.android.email.R; +import com.android.email.ResourceHelper; +import com.android.email.Utility; +import com.android.email.activity.MessageCompose; +import com.android.email.activity.Welcome; +import com.android.email.data.ThrottlingCursorLoader; +import com.android.email.provider.EmailContent; +import com.android.email.provider.EmailContent.Account; +import com.android.email.provider.EmailContent.AccountColumns; +import com.android.email.provider.EmailContent.Mailbox; +import com.android.email.provider.EmailContent.Message; +import com.android.email.provider.EmailContent.MessageColumns; +import com.android.email.provider.WidgetProvider.WidgetService; + +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.content.Intent; +import android.content.Loader; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Typeface; +import android.net.Uri; +import android.net.Uri.Builder; +import android.os.AsyncTask; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.text.format.DateUtils; +import android.text.style.AbsoluteSizeSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.util.Log; +import android.view.View; +import android.widget.RemoteViews; +import android.widget.RemoteViewsService; + +import java.util.List; +import java.util.concurrent.ExecutionException; + +import junit.framework.Assert; + +public class EmailWidget implements RemoteViewsService.RemoteViewsFactory { + public static final String TAG = "EmailWidget"; + + /** + * When handling clicks in a widget ListView, a single PendingIntent template is provided to + * RemoteViews, and the individual "on click" actions are distinguished via a "fillInIntent" + * on each list element; when a click is received, this "fillInIntent" is merged with the + * PendingIntent using Intent.fillIn(). Since this mechanism does NOT preserve the Extras + * Bundle, we instead encode information about the action (e.g. view, reply, etc.) and its + * arguments (e.g. messageId, mailboxId, etc.) in an Uri which is added to the Intent via + * Intent.setDataAndType() + * + * The mime type MUST be set in the Intent, even though we do not use it; therefore, it's value + * is entirely arbitrary. + * + * Our "command" Uri is NOT used by the system in any manner, and is therefore constrained only + * in the requirement that it be syntactically valid. + * + * We use the following convention for our commands: + * widget://command//[/] + */ + private static final String WIDGET_DATA_MIME_TYPE = "com.android.email/widget_data"; + + private static final Uri COMMAND_URI = Uri.parse("widget://command"); + + // Command names and Uri's built upon COMMAND_URI + private static final String COMMAND_NAME_SWITCH_LIST_VIEW = "switch_list_view"; + private static final Uri COMMAND_URI_SWITCH_LIST_VIEW = + COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_SWITCH_LIST_VIEW).build(); + private static final String COMMAND_NAME_VIEW_MESSAGE = "view_message"; + private static final Uri COMMAND_URI_VIEW_MESSAGE = + COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_VIEW_MESSAGE).build(); + + + private static final int TOTAL_COUNT_UNKNOWN = -1; + private static final int MAX_MESSAGE_LIST_COUNT = 25; + + private static final String SORT_TIMESTAMP_DESCENDING = MessageColumns.TIMESTAMP + " DESC"; + private static final String SORT_ID_ASCENDING = AccountColumns.ID + " ASC"; + private static final String[] ID_NAME_PROJECTION = {Account.RECORD_ID, Account.DISPLAY_NAME}; + private static final int ID_NAME_COLUMN_ID = 0; + private static final int ID_NAME_COLUMN_NAME = 1; + + private static String sSubjectSnippetDivider; + private static String sConfigureText; + private static int sSenderFontSize; + private static int sSubjectFontSize; + private static int sDateFontSize; + private static int sDefaultTextColor; + private static int sLightTextColor; + + private final Context mContext; + private final ContentResolver mResolver; + private final AppWidgetManager mWidgetManager; + + // The widget identifier + private final int mWidgetId; + + // The cursor underlying the message list for this widget; this must only be modified while + // holding mCursorLock + private volatile Cursor mCursor; + // A lock on our cursor, which is used in the UI thread while inflating views, and by + // our Loader in the background + private final Object mCursorLock = new Object(); + // Number of records in the cursor + private int mCursorCount = TOTAL_COUNT_UNKNOWN; + // The widget's loader (derived from ThrottlingCursorLoader) + private ViewCursorLoader mLoader; + private final ResourceHelper mResourceHelper; + // Number of defined accounts + private int mAccountCount = TOTAL_COUNT_UNKNOWN; + + // The current view type (all mail, unread, or starred for now) + /*package*/ ViewType mViewType = ViewType.STARRED; + + // The projection to be used by the WidgetLoader + private static final String[] WIDGET_PROJECTION = new String[] { + EmailContent.RECORD_ID, MessageColumns.DISPLAY_NAME, MessageColumns.TIMESTAMP, + MessageColumns.SUBJECT, MessageColumns.FLAG_READ, MessageColumns.FLAG_FAVORITE, + MessageColumns.FLAG_ATTACHMENT, MessageColumns.MAILBOX_KEY, MessageColumns.SNIPPET, + MessageColumns.ACCOUNT_KEY, MessageColumns.FLAGS + }; + private static final int WIDGET_COLUMN_ID = 0; + private static final int WIDGET_COLUMN_DISPLAY_NAME = 1; + private static final int WIDGET_COLUMN_TIMESTAMP = 2; + private static final int WIDGET_COLUMN_SUBJECT = 3; + private static final int WIDGET_COLUMN_FLAG_READ = 4; + @SuppressWarnings("unused") + private static final int WIDGET_COLUMN_FLAG_FAVORITE = 5; + private static final int WIDGET_COLUMN_FLAG_ATTACHMENT = 6; + private static final int WIDGET_COLUMN_MAILBOX_KEY = 7; + private static final int WIDGET_COLUMN_SNIPPET = 8; + private static final int WIDGET_COLUMN_ACCOUNT_KEY = 9; + private static final int WIDGET_COLUMN_FLAGS = 10; + + public EmailWidget(Context context, int _widgetId) { + super(); + if (Email.DEBUG) { + Log.d(TAG, "Creating EmailWidget with id = " + _widgetId); + } + mContext = context.getApplicationContext(); + mResolver = mContext.getContentResolver(); + mWidgetManager = AppWidgetManager.getInstance(mContext); + + mWidgetId = _widgetId; + mLoader = new ViewCursorLoader(); + if (sSubjectSnippetDivider == null) { + // Initialize string, color, dimension resources + Resources res = mContext.getResources(); + sSubjectSnippetDivider = + res.getString(R.string.message_list_subject_snippet_divider); + sSenderFontSize = res.getDimensionPixelSize(R.dimen.widget_senders_font_size); + sSubjectFontSize = res.getDimensionPixelSize(R.dimen.widget_subject_font_size); + sDateFontSize = res.getDimensionPixelSize(R.dimen.widget_date_font_size); + sDefaultTextColor = res.getColor(R.color.widget_default_text_color); + sDefaultTextColor = res.getColor(R.color.widget_default_text_color); + sLightTextColor = res.getColor(R.color.widget_light_text_color); + sConfigureText = res.getString(R.string.widget_other_views); + } + mResourceHelper = ResourceHelper.getInstance(mContext); + } + + public void updateWidget(boolean validateView) { + new WidgetUpdateTask().execute(validateView); + } + + /** + * Task for updating widget data (eg: the header, view list items, etc...) + * If parameter to {@link #execute(Boolean...)} is true, the current + * view is validated against the current set of accounts. And if the current view + * is determined to be invalid, the view will automatically progress to the next + * valid view. + */ + private final class WidgetUpdateTask extends AsyncTask { + @Override + protected Boolean doInBackground(Boolean... validateView) { + mAccountCount = EmailContent.count(mContext, EmailContent.Account.CONTENT_URI); + // If displaying invalid view, switch to the next view + return !validateView[0] || isViewValid(); + } + + @Override + protected void onPostExecute(Boolean isValidView) { + updateHeader(); + if (!isValidView) { + switchView(); + } + } + } + + /** + * The ThrottlingCursorLoader does all of the heavy lifting in managing the data loading + * task; all we need is to register a listener so that we're notified when the load is + * complete. + */ + private final class ViewCursorLoader extends ThrottlingCursorLoader { + protected ViewCursorLoader() { + super(mContext, Message.CONTENT_URI, WIDGET_PROJECTION, mViewType.selection, + mViewType.selectionArgs, SORT_TIMESTAMP_DESCENDING); + registerListener(0, new OnLoadCompleteListener() { + @Override + public void onLoadComplete(Loader loader, Cursor cursor) { + synchronized (mCursorLock) { + // Save away the cursor + mCursor = cursor; + // Reset the notification Uri to our Message table notifier URI + mCursor.setNotificationUri(mResolver, Message.NOTIFIER_URI); + // Save away the count (for display) + mCursorCount = mCursor.getCount(); + if (Email.DEBUG) { + Log.d(TAG, "onLoadComplete, count = " + cursor.getCount()); + } + } + RemoteViews views = + new RemoteViews(mContext.getPackageName(), R.layout.widget); + setupTitleAndCount(views); + mWidgetManager.partiallyUpdateAppWidget(mWidgetId, views); + mWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list); + } + }); + } + + /** + * Stop any pending load, reset selection parameters, and start loading + * Must be called from the UI thread + * @param viewType the current ViewType + */ + private void load(ViewType viewType) { + reset(); + setSelection(viewType.selection); + setSelectionArgs(viewType.selectionArgs); + startLoading(); + } + } + + /** + * Initialize to first appropriate view (depending on the number of accounts) + */ + public void init() { + // Just update the account count & header; no need to validate the view + updateWidget(false); + switchView(); // TODO Do we really need this?? + } + + /** + * Reset cursor and cursor count, notify widget that list data is invalid, and start loading + * with our current ViewType + */ + private void loadView() { + synchronized(mCursorLock) { + mCursorCount = TOTAL_COUNT_UNKNOWN; + mCursor = null; + mWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list); + mLoader.load(mViewType); + } + } + + /** + * Switch to the next widget view (all -> account1 -> ... -> account n -> unread -> starred) + * + * This must be called on a background thread. Use {@link #switchView} on the UI thread. + */ + private synchronized void switchToNextView() { + switch(mViewType) { + // If we're in starred and there is more than one account, go to "all mail" + // Otherwise, fall through to the accounts themselves + case STARRED: + if (EmailContent.count(mContext, Account.CONTENT_URI) > 1) { + mViewType = ViewType.ALL_INBOX; + break; + } + //$FALL-THROUGH$ + case ALL_INBOX: + ViewType.ACCOUNT.selectionArgs[0] = "0"; + //$FALL-THROUGH$ + case ACCOUNT: + // Find the next account (or, if none, default to UNREAD) + String idString = ViewType.ACCOUNT.selectionArgs[0]; + Cursor c = mResolver.query(Account.CONTENT_URI, ID_NAME_PROJECTION, "_id>?", + new String[] {idString}, SORT_ID_ASCENDING); + try { + if (c.moveToFirst()) { + mViewType = ViewType.ACCOUNT; + mViewType.selectionArgs[0] = c.getString(ID_NAME_COLUMN_ID); + mViewType.setTitle(c.getString(ID_NAME_COLUMN_NAME)); + } else { + mViewType = ViewType.UNREAD; + } + } finally { + c.close(); + } + break; + case UNREAD: + mViewType = ViewType.STARRED; + break; + } + } + + /** + * Returns whether the current view is valid. The following rules determine if a view is + * considered valid: + * 1. If the view is either {@link ViewType#STARRED} or {@link ViewType#UNREAD}, always + * returns true. + * 2. If the view is {@link ViewType#ALL_INBOX}, returns true if more than + * one account is defined. Otherwise, returns false. + * 3. If the view is {@link ViewType#ACCOUNT}, returns true if the account + * is defined. Otherwise, returns false. + */ + private boolean isViewValid() { + switch(mViewType) { + case ALL_INBOX: + // "all inbox" is valid only if there is more than one account + return (EmailContent.count(mContext, Account.CONTENT_URI) > 1); + case ACCOUNT: + // Ensure current account still exists + String idString = ViewType.ACCOUNT.selectionArgs[0]; + Cursor c = mResolver.query(Account.CONTENT_URI, ID_NAME_PROJECTION, "_id=?", + new String[] {idString}, SORT_ID_ASCENDING); + try { + return c.moveToFirst(); + } finally { + c.close(); + } + } + return true; + } + + /** + * Convenience method for creating an onClickPendingIntent that executes a command via + * our command Uri. Used for the "next view" command; appends the widget id to the command + * Uri. + * + * @param views The RemoteViews we're inflating + * @param buttonId the id of the button view + * @param data the command Uri + */ + private void setCommandIntent(RemoteViews views, int buttonId, Uri data) { + Intent intent = new Intent(mContext, WidgetService.class); + intent.setDataAndType(ContentUris.withAppendedId(data, mWidgetId), WIDGET_DATA_MIME_TYPE); + PendingIntent pendingIntent = PendingIntent.getService(mContext, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT); + views.setOnClickPendingIntent(buttonId, pendingIntent); + } + + /** + * Convenience method for creating an onClickPendingIntent that launches another activity + * directly. + * + * @param views The RemoteViews we're inflating + * @param buttonId the id of the button view + * @param intent The intent to be used when launching the activity + */ + private void setActivityIntent(RemoteViews views, int buttonId, Intent intent) { + PendingIntent pendingIntent = + PendingIntent.getActivity(mContext, 0, intent, 0); + views.setOnClickPendingIntent(buttonId, pendingIntent); + } + + /** + * Convenience method for constructing a fillInIntent for a given list view element. + * Appends the command and any arguments to a base Uri. + * + * @param views the RemoteViews we are inflating + * @param viewId the id of the view + * @param baseUri the base uri for the command + * @param args any arguments to the command + */ + private void setFillInIntent(RemoteViews views, int viewId, Uri baseUri, String ... args) { + Intent intent = new Intent(); + Builder builder = baseUri.buildUpon(); + for (String arg: args) { + builder.appendPath(arg); + } + intent.setDataAndType(builder.build(), WIDGET_DATA_MIME_TYPE); + views.setOnClickFillInIntent(viewId, intent); + } + + /** + * Called back by {@link com.android.email.provider.WidgetProvider.WidgetService} to + * handle intents created by remote views. + */ + public static boolean processIntent(Context context, Intent intent) { + final Uri data = intent.getData(); + if (data == null) { + return false; + } + List pathSegments = data.getPathSegments(); + // Our path segments are , [, ] + // First, a quick check of Uri validity + if (pathSegments.size() < 2) { + throw new IllegalArgumentException(); + } + String command = pathSegments.get(0); + // Ignore unknown action names + try { + final long arg1 = Long.parseLong(pathSegments.get(1)); + if (EmailWidget.COMMAND_NAME_VIEW_MESSAGE.equals(command)) { + // "view", , + openMessage(context, Long.parseLong(pathSegments.get(2)), arg1); + } else if (EmailWidget.COMMAND_NAME_SWITCH_LIST_VIEW.equals(command)) { + // "next_view", + EmailWidget widget = WidgetManager.getInstance().get((int)arg1); + if (widget != null) { + widget.switchView(); + } + } + } catch (NumberFormatException e) { + // Shouldn't happen as we construct all of the Uri's + return false; + } + return true; + } + + private static void openMessage(final Context context, final long mailboxId, + final long messageId) { + Utility.runAsync(new Runnable() { + @Override + public void run() { + Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId); + if (mailbox == null) return; + context.startActivity(Welcome.createOpenMessageIntent(context, mailbox.mAccountKey, + mailboxId, messageId)); + } + }); + } + + private void setupTitleAndCount(RemoteViews views) { + // Set up the title (view type + count of messages) + views.setTextViewText(R.id.widget_title, mViewType.getTitle(mContext)); + views.setTextViewText(R.id.widget_tap, sConfigureText); + String count = ""; + if (mCursorCount != TOTAL_COUNT_UNKNOWN) { + count = Utility.getMessageCountForUi(mContext, mCursor.getCount(), false); + } + views.setTextViewText(R.id.widget_count, count); + } + /** + * Update the "header" of the widget (i.e. everything that doesn't include the scrolling + * message list) + */ + public void updateHeader() { + if (Email.DEBUG) { + Log.d(TAG, "updateWidget " + mWidgetId); + } + + // Get the widget layout + RemoteViews views = + new RemoteViews(mContext.getPackageName(), R.layout.widget); + + // Set up the list with an adapter + Intent intent = new Intent(mContext, WidgetService.class); + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mWidgetId); + intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME))); + views.setRemoteAdapter(mWidgetId, R.id.message_list, intent); + + setupTitleAndCount(views); + + if (mAccountCount == 0) { + // Hide compose icon & show "touch to configure" text + views.setViewVisibility(R.id.widget_compose, View.INVISIBLE); + views.setViewVisibility(R.id.message_list, View.GONE); + views.setViewVisibility(R.id.tap_to_configure, View.VISIBLE); + // Create click intent for "touch to configure" target + intent = Welcome.createOpenAccountInboxIntent(mContext, -1); + setActivityIntent(views, R.id.tap_to_configure, intent); + } else { + // Show compose icon & message list + views.setViewVisibility(R.id.widget_compose, View.VISIBLE); + views.setViewVisibility(R.id.message_list, View.VISIBLE); + views.setViewVisibility(R.id.tap_to_configure, View.GONE); + // Create click intent for "compose email" target + intent = MessageCompose.getMessageComposeIntent(mContext, -1); + setActivityIntent(views, R.id.widget_compose, intent); + } + // Create click intent for "view rotation" target + setCommandIntent(views, R.id.widget_logo, COMMAND_URI_SWITCH_LIST_VIEW); + + // Use a bare intent for our template; we need to fill everything in + intent = new Intent(mContext, WidgetService.class); + PendingIntent pendingIntent = PendingIntent.getService(mContext, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT); + views.setPendingIntentTemplate(R.id.message_list, pendingIntent); + + // And finally update the widget + mWidgetManager.updateAppWidget(mWidgetId, views); + } + + /** + * Add size and color styling to text + * + * @param text the text to style + * @param size the font size for this text + * @param color the color for this text + * @return a CharSequence quitable for use in RemoteViews.setTextViewText() + */ + private CharSequence addStyle(CharSequence text, int size, int color) { + SpannableStringBuilder builder = new SpannableStringBuilder(text); + builder.setSpan( + new AbsoluteSizeSpan(size), 0, text.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + if (color != 0) { + builder.setSpan(new ForegroundColorSpan(color), 0, text.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + return builder; + } + + /** + * Create styled text for our combination subject and snippet + * + * @param subject the message's subject (or null) + * @param snippet the message's snippet (or null) + * @param read whether or not the message is read + * @return a CharSequence suitable for use in RemoteViews.setTextViewText() + */ + private CharSequence getStyledSubjectSnippet (String subject, String snippet, + boolean read) { + SpannableStringBuilder ssb = new SpannableStringBuilder(); + boolean hasSubject = false; + if (!TextUtils.isEmpty(subject)) { + SpannableString ss = new SpannableString(subject); + ss.setSpan(new StyleSpan(read ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + ss.setSpan(new ForegroundColorSpan(sDefaultTextColor), 0, ss.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + ssb.append(ss); + hasSubject = true; + } + if (!TextUtils.isEmpty(snippet)) { + if (hasSubject) { + ssb.append(sSubjectSnippetDivider); + } + SpannableString ss = new SpannableString(snippet); + ss.setSpan(new ForegroundColorSpan(sLightTextColor), 0, snippet.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + ssb.append(ss); + } + return addStyle(ssb, sSubjectFontSize, 0); + } + + @Override + public RemoteViews getViewAt(int position) { + // Use the cursor to set up the widget + synchronized (mCursorLock) { + if (mCursor == null || mCursor.isClosed() || !mCursor.moveToPosition(position)) { + return getLoadingView(); + } + RemoteViews views = + new RemoteViews(mContext.getPackageName(), R.layout.widget_list_item); + boolean isUnread = mCursor.getInt(WIDGET_COLUMN_FLAG_READ) != 1; + int drawableId = R.drawable.widget_read_conversation_selector; + if (isUnread) { + drawableId = R.drawable.widget_unread_conversation_selector; + } + views.setInt(R.id.widget_message, "setBackgroundResource", drawableId); + + // Add style to sender + SpannableStringBuilder from = + new SpannableStringBuilder(mCursor.getString(WIDGET_COLUMN_DISPLAY_NAME)); + from.setSpan( + isUnread ? new StyleSpan(Typeface.BOLD) : new StyleSpan(Typeface.NORMAL), 0, + from.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + CharSequence styledFrom = addStyle(from, sSenderFontSize, sDefaultTextColor); + views.setTextViewText(R.id.widget_from, styledFrom); + + long timestamp = mCursor.getLong(WIDGET_COLUMN_TIMESTAMP); + // Get a nicely formatted date string (relative to today) + String date = DateUtils.getRelativeTimeSpanString(mContext, timestamp).toString(); + // Add style to date + CharSequence styledDate = addStyle(date, sDateFontSize, sDefaultTextColor); + views.setTextViewText(R.id.widget_date, styledDate); + + // Add style to subject/snippet + String subject = mCursor.getString(WIDGET_COLUMN_SUBJECT); + String snippet = mCursor.getString(WIDGET_COLUMN_SNIPPET); + CharSequence subjectAndSnippet = + getStyledSubjectSnippet(subject, snippet, !isUnread); + views.setTextViewText(R.id.widget_subject, subjectAndSnippet); + + int messageFlags = mCursor.getInt(WIDGET_COLUMN_FLAGS); + boolean hasInvite = (messageFlags & Message.FLAG_INCOMING_MEETING_INVITE) != 0; + views.setViewVisibility(R.id.widget_invite, hasInvite ? View.VISIBLE : View.GONE); + + boolean hasAttachment = mCursor.getInt(WIDGET_COLUMN_FLAG_ATTACHMENT) != 0; + views.setViewVisibility(R.id.widget_attachment, + hasAttachment ? View.VISIBLE : View.GONE); + + if (mViewType == ViewType.ACCOUNT) { + views.setViewVisibility(R.id.color_chip, View.INVISIBLE); + } else { + long accountId = mCursor.getLong(WIDGET_COLUMN_ACCOUNT_KEY); + int colorId = mResourceHelper.getAccountColorId(accountId); + if (colorId != ResourceHelper.UNDEFINED_RESOURCE_ID) { + // Color defined by resource ID, so, use it + views.setViewVisibility(R.id.color_chip, View.VISIBLE); + views.setImageViewResource(R.id.color_chip, colorId); + } else { + // Color not defined by resource ID, nothing we can do, so, hide the chip + views.setViewVisibility(R.id.color_chip, View.INVISIBLE); + } + } + + // Set button intents for view, reply, and delete + String messageId = mCursor.getString(WIDGET_COLUMN_ID); + String mailboxId = mCursor.getString(WIDGET_COLUMN_MAILBOX_KEY); + setFillInIntent(views, R.id.widget_message, COMMAND_URI_VIEW_MESSAGE, + messageId, mailboxId); + + return views; + } + } + + @Override + public int getCount() { + if (mCursor == null) return 0; + return Math.min(mCursor.getCount(), MAX_MESSAGE_LIST_COUNT); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public RemoteViews getLoadingView() { + RemoteViews view = new RemoteViews(mContext.getPackageName(), R.layout.widget_loading); + view.setTextViewText(R.id.loading_text, mContext.getString(R.string.widget_loading)); + return view; + } + + @Override + public int getViewTypeCount() { + // Regular list view and the "loading" view + return 2; + } + + @Override + public boolean hasStableIds() { + return true; + } + + @Override + public void onDataSetChanged() { + } + + public void onDeleted() { + if (mLoader != null) { + mLoader.stopLoading(); + } + WidgetManager.getInstance().remove(mWidgetId); + } + + @Override + public void onDestroy() { + if (mLoader != null) { + mLoader.stopLoading(); + } + WidgetManager.getInstance().remove(mWidgetId); + } + + @Override + public void onCreate() { + } + + /** + * Switch to the next view. + */ + /* package */ void switchView() { + switchView(false); + } + + private WidgetViewSwitcher switchView(boolean disableLoadAfterSwitchForTest) { + WidgetViewSwitcher switcher = new WidgetViewSwitcher(this, disableLoadAfterSwitchForTest); + switcher.execute(); + return switcher; + } + + /** + * Switch views synchronously without loading + */ + /* package */ void switchViewSyncForTest() { + WidgetViewSwitcher switcher = switchView(true); + try { + switcher.get(); + } catch (InterruptedException e) { + Assert.fail(); + } catch (ExecutionException e) { + Assert.fail(); + } + } + + /** + * Utility class to handle switching widget views; in the background, we access the database + * to determine account status, etc. In the foreground, we start up the Loader with new + * parameters + */ + private static class WidgetViewSwitcher extends AsyncTask { + private final EmailWidget mWidget; + private final boolean mDisableLoadAfterSwitchForTest; + + public WidgetViewSwitcher(EmailWidget widget, boolean disableLoadAfterSwitchForTest) { + mWidget = widget; + mDisableLoadAfterSwitchForTest = disableLoadAfterSwitchForTest; + } + + @Override + protected Void doInBackground(Void... params) { + mWidget.switchToNextView(); + return null; + } + + @Override + protected void onPostExecute(Void param) { + if (isCancelled()) { + return; + } + if (!mDisableLoadAfterSwitchForTest) { + mWidget.loadView(); + } + } + } +} \ No newline at end of file diff --git a/src/com/android/email/widget/ViewType.java b/src/com/android/email/widget/ViewType.java new file mode 100644 index 000000000..1fcb24eeb --- /dev/null +++ b/src/com/android/email/widget/ViewType.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * 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 com.android.email.widget; + +import com.android.email.R; +import com.android.email.provider.EmailContent.Message; + +import android.content.Context; +import android.text.TextUtils; + +/** + * Types of views that we're prepared to show in the widget - all mail, unread mail, and starred + * mail; we rotate between them. Each ViewType is composed of a selection string and a title. + */ +/* package */ enum ViewType { + ALL_INBOX(Message.INBOX_SELECTION, null, R.string.widget_all_mail), + UNREAD(Message.UNREAD_SELECTION, null, R.string.widget_unread), + STARRED(Message.ALL_FAVORITE_SELECTION, null, R.string.widget_starred), + ACCOUNT(Message.PER_ACCOUNT_INBOX_SELECTION, new String[1], 0); + + /* package */ final String selection; + /* package */ final String[] selectionArgs; + private final int mTitleResource; + + // TODO Can this class be immutable?? + private String mTitle; + + ViewType(String _selection, String[] _selectionArgs, int _titleResource) { + selection = _selection; + selectionArgs = _selectionArgs; + mTitleResource = _titleResource; + } + + public void setTitle(String title) { + mTitle = title; + } + + public String getTitle(Context context) { + if (TextUtils.isEmpty(mTitle) && mTitleResource != 0) { + mTitle = context.getString(mTitleResource); + } + return mTitle; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("ViewType:selection=\""); + sb.append(selection); + sb.append("\""); + if (selectionArgs != null && selectionArgs.length > 0) { + sb.append(",args=("); + for (String arg : selectionArgs) { + sb.append(arg); + sb.append(", "); + } + sb.append(")"); + } + + return sb.toString(); + } +} \ No newline at end of file diff --git a/src/com/android/email/widget/WidgetManager.java b/src/com/android/email/widget/WidgetManager.java new file mode 100644 index 000000000..631171acd --- /dev/null +++ b/src/com/android/email/widget/WidgetManager.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * 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 com.android.email.widget; + +import com.android.email.Email; + +import android.content.Context; +import android.util.Log; + +import java.io.FileDescriptor; +import java.io.PrintWriter; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Class that maintains references to all widgets. + */ +public class WidgetManager { + private final static WidgetManager sInstance = new WidgetManager(); + + // Widget ID -> Widget + private final static Map mWidgets = + new ConcurrentHashMap(); + + private WidgetManager() { + } + + public static WidgetManager getInstance() { + return sInstance; + } + + /** + * Updates all active widgets. If no widgets are active, does nothing. + */ + public synchronized void updateAllWidgets() { + for (EmailWidget widget: mWidgets.values()) { + // Anything could have changed; update widget & validate the current view + widget.updateWidget(true); + } + } + + public synchronized void getOrCreateWidgets(Context context, int[] widgetIds) { + for (int widgetId : widgetIds) { + getOrCreateWidget(context, widgetId).updateHeader(); + } + } + + public EmailWidget getOrCreateWidget(Context context, int widgetId) { + EmailWidget widget = WidgetManager.getInstance().get(widgetId); + if (widget == null) { + if (Email.DEBUG) { + Log.d(EmailWidget.TAG, "Creating EmailWidget for id #" + widgetId); + } + widget = new EmailWidget(context, widgetId); + widget.init(); + WidgetManager.getInstance().put(widgetId, widget); + } + return widget; + } + + public EmailWidget get(int widgetId) { + return mWidgets.get(widgetId); + } + + /* package */ void put(int widgetId, EmailWidget widget) { + mWidgets.put(widgetId, widget); + } + + /* package */ void remove(int widgetId) { + mWidgets.remove(widgetId); + } + + public void dump(FileDescriptor fd, PrintWriter writer, String[] args) { + int n = 0; + for (EmailWidget widget : mWidgets.values()) { + writer.println("Widget #" + (++n)); + writer.println(" ViewType=" + widget.mViewType); + } + } +} diff --git a/tests/src/com/android/email/provider/WidgetProviderTests.java b/tests/src/com/android/email/widget/EmailWidgetTests.java similarity index 68% rename from tests/src/com/android/email/provider/WidgetProviderTests.java rename to tests/src/com/android/email/widget/EmailWidgetTests.java index 97cf267cc..fc029f6cb 100644 --- a/tests/src/com/android/email/provider/WidgetProviderTests.java +++ b/tests/src/com/android/email/widget/EmailWidgetTests.java @@ -13,33 +13,28 @@ * limitations under the License. */ -package com.android.email.provider; +package com.android.email.widget; +import com.android.email.provider.EmailContent; import com.android.email.provider.EmailContent.Account; import com.android.email.provider.EmailContent.Mailbox; import com.android.email.provider.EmailContent.Message; -import com.android.email.provider.WidgetProvider; -import com.android.email.provider.WidgetProvider.EmailWidget; -import com.android.email.provider.WidgetProvider.ViewType; -import com.android.email.provider.WidgetProvider.WidgetViewSwitcher; +import com.android.email.provider.EmailProvider; +import com.android.email.provider.ProviderTestUtils; -import android.content.ContentResolver; import android.content.Context; -import android.database.Cursor; import android.test.ProviderTestCase2; -import java.util.concurrent.ExecutionException; - /** - * Tests of WidgetProvider + * Tests of EmailWidget * * You can run this entire test case with: - * runtest -c com.android.email.provider.WidgetProviderTests email + * runtest -c com.android.email.widget.EmailWidget email */ -public class WidgetProviderTests extends ProviderTestCase2 { +public class EmailWidgetTests extends ProviderTestCase2 { private Context mMockContext; - public WidgetProviderTests() { + public EmailWidgetTests() { super(EmailProvider.class, EmailProvider.EMAIL_AUTHORITY); } @@ -54,30 +49,9 @@ public class WidgetProviderTests extends ProviderTestCase2 { super.tearDown(); } - /** - * Switch views synchronously without loading - */ - private void switchSync(EmailWidget widget) { - WidgetViewSwitcher switcher = new WidgetProvider.WidgetViewSwitcher(widget); - try { - switcher.disableLoadAfterSwitchForTest(); - switcher.execute().get(); - } catch (InterruptedException e) { - } catch (ExecutionException e) { - } - } - private int getMessageCount(ViewType view) { - int messageCount = 0; - ContentResolver cr = mMockContext.getContentResolver(); - Cursor c = cr.query(Message.CONTENT_URI, WidgetProvider.EmailWidget.WIDGET_PROJECTION, - view.selection, view.selectionArgs, null); - try { - messageCount = c.getCount(); - } finally { - c.close(); - } - return messageCount; + return EmailContent.count(mMockContext, Message.CONTENT_URI, view.selection, + view.selectionArgs); } private static Message createMessage(Context c, Mailbox b, boolean starred, boolean read, @@ -92,31 +66,30 @@ public class WidgetProviderTests extends ProviderTestCase2 { public void testWidgetSwitcher() { // Create account ProviderTestUtils.setupAccount("account1", true, mMockContext); - // Manually set up context - WidgetProvider.setContextForTest(mMockContext); + // Create a widget - EmailWidget widget = new EmailWidget(1); + EmailWidget widget = new EmailWidget(mMockContext, 1); // Since there is one account, this should switch to the ACCOUNT view - switchSync(widget); - assertEquals(WidgetProvider.ViewType.ACCOUNT, widget.mViewType); + widget.switchViewSyncForTest(); + assertEquals(ViewType.ACCOUNT, widget.mViewType); // Create account ProviderTestUtils.setupAccount("account2", true, mMockContext); // Create a widget - widget = new EmailWidget(2); + widget = new EmailWidget(mMockContext, 2); // Since there are two accounts, this should switch to the ALL_INBOX view - switchSync(widget); - assertEquals(WidgetProvider.ViewType.ALL_INBOX, widget.mViewType); + widget.switchViewSyncForTest(); + assertEquals(ViewType.ALL_INBOX, widget.mViewType); // The next two switches should be to the two accounts - switchSync(widget); - assertEquals(WidgetProvider.ViewType.ACCOUNT, widget.mViewType); - switchSync(widget); - assertEquals(WidgetProvider.ViewType.ACCOUNT, widget.mViewType); - switchSync(widget); - assertEquals(WidgetProvider.ViewType.UNREAD, widget.mViewType); - switchSync(widget); - assertEquals(WidgetProvider.ViewType.STARRED, widget.mViewType); + widget.switchViewSyncForTest(); + assertEquals(ViewType.ACCOUNT, widget.mViewType); + widget.switchViewSyncForTest(); + assertEquals(ViewType.ACCOUNT, widget.mViewType); + widget.switchViewSyncForTest(); + assertEquals(ViewType.UNREAD, widget.mViewType); + widget.switchViewSyncForTest(); + assertEquals(ViewType.STARRED, widget.mViewType); } /** @@ -165,5 +138,4 @@ public class WidgetProviderTests extends ProviderTestCase2 { assertEquals(3, getMessageCount(ViewType.STARRED)); assertEquals(2, getMessageCount(ViewType.UNREAD)); } - }