| /* |
| * 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 android.app.PendingIntent; |
| import android.appwidget.AppWidgetManager; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.Loader; |
| import android.content.Loader.OnLoadCompleteListener; |
| import android.content.res.Resources; |
| import android.database.Cursor; |
| import android.graphics.Typeface; |
| import android.net.Uri; |
| import android.net.Uri.Builder; |
| 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 com.android.email.Email; |
| import com.android.email.R; |
| import com.android.email.ResourceHelper; |
| import com.android.email.activity.MessageCompose; |
| import com.android.email.activity.UiUtilities; |
| import com.android.email.activity.Welcome; |
| import com.android.email.provider.WidgetProvider.WidgetService; |
| import com.android.emailcommon.Logging; |
| import com.android.emailcommon.provider.Account; |
| import com.android.emailcommon.provider.EmailContent.Message; |
| import com.android.emailcommon.provider.Mailbox; |
| import com.android.emailcommon.utility.EmailAsyncTask; |
| |
| import java.util.List; |
| |
| /** |
| * The email widget. |
| * <p><em>NOTE</em>: All methods must be called on the UI thread so synchronization is NOT required |
| * in this class) |
| */ |
| public class EmailWidget implements RemoteViewsService.RemoteViewsFactory, |
| OnLoadCompleteListener<Cursor> { |
| 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/<command>/<arg1>[/<arg2>] |
| */ |
| 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_VIEW_MESSAGE = "view_message"; |
| private static final Uri COMMAND_URI_VIEW_MESSAGE = |
| COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_VIEW_MESSAGE).build(); |
| |
| // TODO Can this be moved to the loader and made a database 'LIMIT'? |
| private static final int MAX_MESSAGE_LIST_COUNT = 25; |
| |
| private static String sSubjectSnippetDivider; |
| private static int sSenderFontSize; |
| private static int sSubjectFontSize; |
| private static int sDateFontSize; |
| private static int sDefaultTextColor; |
| private static int sLightTextColor; |
| private static Object sWidgetLock = new Object(); |
| |
| private final Context mContext; |
| private final AppWidgetManager mWidgetManager; |
| |
| // The widget identifier |
| private final int mWidgetId; |
| |
| // The widget's loader (derived from ThrottlingCursorLoader) |
| private final EmailWidgetLoader mLoader; |
| private final ResourceHelper mResourceHelper; |
| |
| /** The account ID of this widget. May be {@link Account#ACCOUNT_ID_COMBINED_VIEW}. */ |
| private long mAccountId = Account.NO_ACCOUNT; |
| /** The display name of this account */ |
| private String mAccountName; |
| /** The display name of this mailbox */ |
| private String mMailboxName; |
| |
| /** |
| * The cursor for the messages, with some extra info such as the number of accounts. |
| * |
| * Note this cursor can be closed any time by the loader. Always use {@link #isCursorValid()} |
| * before touching its contents. |
| */ |
| private EmailWidgetLoader.WidgetCursor mCursor; |
| |
| public EmailWidget(Context context, int _widgetId) { |
| super(); |
| if (Email.DEBUG) { |
| Log.d(TAG, "Creating EmailWidget with id = " + _widgetId); |
| } |
| mContext = context.getApplicationContext(); |
| mWidgetManager = AppWidgetManager.getInstance(mContext); |
| |
| mWidgetId = _widgetId; |
| mLoader = new EmailWidgetLoader(mContext); |
| mLoader.registerListener(0, this); |
| 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); |
| } |
| mResourceHelper = ResourceHelper.getInstance(mContext); |
| } |
| |
| /** |
| * Start loading the data. At this point nothing on the widget changes -- the current view |
| * will remain valid until the loader loads the latest data. |
| */ |
| public void start() { |
| long accountId = WidgetManager.loadAccountIdPref(mContext, mWidgetId); |
| long mailboxId = WidgetManager.loadMailboxIdPref(mContext, mWidgetId); |
| // Legacy support; if preferences haven't been saved for this widget, load something |
| if (accountId == Account.NO_ACCOUNT) { |
| accountId = Account.ACCOUNT_ID_COMBINED_VIEW; |
| mailboxId = Mailbox.QUERY_ALL_INBOXES; |
| } |
| mAccountId = accountId; |
| mLoader.load(mAccountId, mailboxId); |
| } |
| |
| /** |
| * Resets the data in the widget and forces a reload. |
| */ |
| public void reset() { |
| mLoader.reset(); |
| start(); |
| } |
| |
| private boolean isCursorValid() { |
| return mCursor != null && !mCursor.isClosed(); |
| } |
| |
| /** |
| * Called when the loader finished loading data. Update the widget. |
| */ |
| @Override |
| public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) { |
| // Save away the cursor |
| synchronized (sWidgetLock) { |
| mCursor = (EmailWidgetLoader.WidgetCursor) cursor; |
| mAccountName = mCursor.getAccountName(); |
| mMailboxName = mCursor.getMailboxName(); |
| } |
| updateHeader(); |
| mWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list); |
| } |
| |
| /** |
| * 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) { |
| intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // just in case intent comes without it |
| PendingIntent pendingIntent = |
| PendingIntent.getActivity(mContext, (int) mAccountId, intent, |
| PendingIntent.FLAG_UPDATE_CURRENT); |
| 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<String> pathSegments = data.getPathSegments(); |
| // Our path segments are <command>, <arg1> [, <arg2>] |
| // 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", <message id>, <mailbox id> |
| openMessage(context, Long.parseLong(pathSegments.get(2)), arg1); |
| } |
| } 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) { |
| EmailAsyncTask.runAsyncParallel(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, true)); |
| } |
| }); |
| } |
| |
| private void setTextViewTextAndDesc(RemoteViews views, final int id, String text) { |
| views.setTextViewText(id, text); |
| views.setContentDescription(id, text); |
| } |
| |
| private void setupTitleAndCount(RemoteViews views) { |
| // Set up the title (view type + count of messages) |
| setTextViewTextAndDesc(views, R.id.widget_title, mMailboxName); |
| views.setViewVisibility(R.id.widget_tap, View.VISIBLE); |
| setTextViewTextAndDesc(views, R.id.widget_tap, mAccountName); |
| String count = ""; |
| synchronized (sWidgetLock) { |
| if (isCursorValid()) { |
| count = UiUtilities |
| .getMessageCountForUi(mContext, mCursor.getMessageCount(), false); |
| } |
| } |
| setTextViewTextAndDesc(views, 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, "#updateHeader(); widgetId: " + 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(R.id.message_list, intent); |
| |
| setupTitleAndCount(views); |
| |
| if (isCursorValid()) { |
| // Show compose icon & message list |
| if (mAccountId == Account.ACCOUNT_ID_COMBINED_VIEW) { |
| // Don't allow compose for "combined" view |
| views.setViewVisibility(R.id.widget_compose, View.INVISIBLE); |
| } else { |
| 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, mAccountId); |
| intent.putExtra(MessageCompose.EXTRA_FROM_WIDGET, true); |
| setActivityIntent(views, R.id.widget_compose, intent); |
| // Create click intent for logo to open inbox |
| intent = Welcome.createOpenAccountInboxIntent(mContext, mAccountId); |
| setActivityIntent(views, R.id.widget_header, intent); |
| } else { |
| // TODO This really should never happen ... probably can remove the else block |
| // 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); |
| } |
| |
| // 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) { |
| synchronized (sWidgetLock) { |
| // Use the cursor to set up the widget |
| if (!isCursorValid() || !mCursor.moveToPosition(position)) { |
| return getLoadingView(); |
| } |
| RemoteViews views = new RemoteViews(mContext.getPackageName(), |
| R.layout.widget_list_item); |
| boolean isUnread = mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAG_READ) != 1; |
| int drawableId = R.drawable.conversation_read_selector; |
| if (isUnread) { |
| drawableId = R.drawable.conversation_unread_selector; |
| } |
| views.setInt(R.id.widget_message, "setBackgroundResource", drawableId); |
| |
| // Add style to sender |
| String rawSender = mCursor.isNull(EmailWidgetLoader.WIDGET_COLUMN_DISPLAY_NAME) ? |
| "" : mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_DISPLAY_NAME); |
| SpannableStringBuilder from = new SpannableStringBuilder(rawSender); |
| 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); |
| views.setContentDescription(R.id.widget_from, rawSender); |
| long timestamp = mCursor.getLong(EmailWidgetLoader.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); |
| views.setContentDescription(R.id.widget_date, date); |
| |
| // Add style to subject/snippet |
| String subject = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_SUBJECT); |
| String snippet = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_SNIPPET); |
| CharSequence subjectAndSnippet = getStyledSubjectSnippet(subject, snippet, !isUnread); |
| views.setTextViewText(R.id.widget_subject, subjectAndSnippet); |
| views.setContentDescription(R.id.widget_subject, subject); |
| |
| int messageFlags = mCursor.getInt(EmailWidgetLoader.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(EmailWidgetLoader.WIDGET_COLUMN_FLAG_ATTACHMENT) != 0; |
| views.setViewVisibility(R.id.widget_attachment, hasAttachment ? View.VISIBLE |
| : View.GONE); |
| |
| if (mAccountId != Account.ACCOUNT_ID_COMBINED_VIEW) { |
| views.setViewVisibility(R.id.color_chip, View.INVISIBLE); |
| } else { |
| long accountId = mCursor.getLong(EmailWidgetLoader.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(EmailWidgetLoader.WIDGET_COLUMN_ID); |
| String mailboxId = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_MAILBOX_KEY); |
| setFillInIntent(views, R.id.widget_message, COMMAND_URI_VIEW_MESSAGE, messageId, |
| mailboxId); |
| |
| return views; |
| } |
| } |
| |
| @Override |
| public int getCount() { |
| if (!isCursorValid()) |
| return 0; |
| synchronized (sWidgetLock) { |
| 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() { |
| // Note: we are not doing anything special in onDataSetChanged(). Since this service has |
| // a reference to a loader that will keep itself updated, if the service is running, it |
| // shouldn't be necessary to for the query to be run again. If the service hadn't been |
| // running, the act of starting the service will also start the loader. |
| } |
| |
| public void onDeleted() { |
| if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { |
| Log.d(TAG, "#onDeleted(); widgetId: " + mWidgetId); |
| } |
| |
| if (mLoader != null) { |
| mLoader.reset(); |
| } |
| } |
| |
| @Override |
| public void onDestroy() { |
| if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { |
| Log.d(TAG, "#onDestroy(); widgetId: " + mWidgetId); |
| } |
| |
| if (mLoader != null) { |
| mLoader.reset(); |
| } |
| } |
| |
| @Override |
| public void onCreate() { |
| if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { |
| Log.d(TAG, "#onCreate(); widgetId: " + mWidgetId); |
| } |
| } |
| |
| @Override |
| public String toString() { |
| return "View=" + mAccountName; |
| } |
| } |