• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2011 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.email.widget;
18 
19 import android.app.PendingIntent;
20 import android.appwidget.AppWidgetManager;
21 import android.content.Context;
22 import android.content.Intent;
23 import android.content.Loader;
24 import android.content.Loader.OnLoadCompleteListener;
25 import android.content.res.Resources;
26 import android.database.Cursor;
27 import android.graphics.Typeface;
28 import android.net.Uri;
29 import android.net.Uri.Builder;
30 import android.text.Spannable;
31 import android.text.SpannableString;
32 import android.text.SpannableStringBuilder;
33 import android.text.TextUtils;
34 import android.text.format.DateUtils;
35 import android.text.style.AbsoluteSizeSpan;
36 import android.text.style.ForegroundColorSpan;
37 import android.text.style.StyleSpan;
38 import android.util.Log;
39 import android.view.View;
40 import android.widget.RemoteViews;
41 import android.widget.RemoteViewsService;
42 
43 import com.android.email.Email;
44 import com.android.email.R;
45 import com.android.email.ResourceHelper;
46 import com.android.email.activity.MessageCompose;
47 import com.android.email.activity.UiUtilities;
48 import com.android.email.activity.Welcome;
49 import com.android.email.provider.WidgetProvider.WidgetService;
50 import com.android.emailcommon.Logging;
51 import com.android.emailcommon.provider.Account;
52 import com.android.emailcommon.provider.EmailContent.Message;
53 import com.android.emailcommon.provider.Mailbox;
54 import com.android.emailcommon.utility.EmailAsyncTask;
55 
56 import java.util.List;
57 
58 /**
59  * The email widget.
60  * <p><em>NOTE</em>: All methods must be called on the UI thread so synchronization is NOT required
61  * in this class)
62  */
63 public class EmailWidget implements RemoteViewsService.RemoteViewsFactory,
64         OnLoadCompleteListener<Cursor> {
65     public static final String TAG = "EmailWidget";
66 
67     /**
68      * When handling clicks in a widget ListView, a single PendingIntent template is provided to
69      * RemoteViews, and the individual "on click" actions are distinguished via a "fillInIntent"
70      * on each list element; when a click is received, this "fillInIntent" is merged with the
71      * PendingIntent using Intent.fillIn().  Since this mechanism does NOT preserve the Extras
72      * Bundle, we instead encode information about the action (e.g. view, reply, etc.) and its
73      * arguments (e.g. messageId, mailboxId, etc.) in an Uri which is added to the Intent via
74      * Intent.setDataAndType()
75      *
76      * The mime type MUST be set in the Intent, even though we do not use it; therefore, it's value
77      * is entirely arbitrary.
78      *
79      * Our "command" Uri is NOT used by the system in any manner, and is therefore constrained only
80      * in the requirement that it be syntactically valid.
81      *
82      * We use the following convention for our commands:
83      *     widget://command/<command>/<arg1>[/<arg2>]
84      */
85     private static final String WIDGET_DATA_MIME_TYPE = "com.android.email/widget_data";
86 
87     private static final Uri COMMAND_URI = Uri.parse("widget://command");
88 
89     // Command names and Uri's built upon COMMAND_URI
90     private static final String COMMAND_NAME_VIEW_MESSAGE = "view_message";
91     private static final Uri COMMAND_URI_VIEW_MESSAGE =
92             COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_VIEW_MESSAGE).build();
93 
94     // TODO Can this be moved to the loader and made a database 'LIMIT'?
95     private static final int MAX_MESSAGE_LIST_COUNT = 25;
96 
97     private static String sSubjectSnippetDivider;
98     private static int sSenderFontSize;
99     private static int sSubjectFontSize;
100     private static int sDateFontSize;
101     private static int sDefaultTextColor;
102     private static int sLightTextColor;
103     private static Object sWidgetLock = new Object();
104 
105     private final Context mContext;
106     private final AppWidgetManager mWidgetManager;
107 
108     // The widget identifier
109     private final int mWidgetId;
110 
111     // The widget's loader (derived from ThrottlingCursorLoader)
112     private final EmailWidgetLoader mLoader;
113     private final ResourceHelper mResourceHelper;
114 
115     /** The account ID of this widget. May be {@link Account#ACCOUNT_ID_COMBINED_VIEW}. */
116     private long mAccountId = Account.NO_ACCOUNT;
117     /** The display name of this account */
118     private String mAccountName;
119     /** The display name of this mailbox */
120     private String mMailboxName;
121 
122     /**
123      * The cursor for the messages, with some extra info such as the number of accounts.
124      *
125      * Note this cursor can be closed any time by the loader.  Always use {@link #isCursorValid()}
126      * before touching its contents.
127      */
128     private EmailWidgetLoader.WidgetCursor mCursor;
129 
EmailWidget(Context context, int _widgetId)130     public EmailWidget(Context context, int _widgetId) {
131         super();
132         if (Email.DEBUG) {
133             Log.d(TAG, "Creating EmailWidget with id = " + _widgetId);
134         }
135         mContext = context.getApplicationContext();
136         mWidgetManager = AppWidgetManager.getInstance(mContext);
137 
138         mWidgetId = _widgetId;
139         mLoader = new EmailWidgetLoader(mContext);
140         mLoader.registerListener(0, this);
141         if (sSubjectSnippetDivider == null) {
142             // Initialize string, color, dimension resources
143             Resources res = mContext.getResources();
144             sSubjectSnippetDivider =
145                 res.getString(R.string.message_list_subject_snippet_divider);
146             sSenderFontSize = res.getDimensionPixelSize(R.dimen.widget_senders_font_size);
147             sSubjectFontSize = res.getDimensionPixelSize(R.dimen.widget_subject_font_size);
148             sDateFontSize = res.getDimensionPixelSize(R.dimen.widget_date_font_size);
149             sDefaultTextColor = res.getColor(R.color.widget_default_text_color);
150             sDefaultTextColor = res.getColor(R.color.widget_default_text_color);
151             sLightTextColor = res.getColor(R.color.widget_light_text_color);
152         }
153         mResourceHelper = ResourceHelper.getInstance(mContext);
154     }
155 
156     /**
157      * Start loading the data.  At this point nothing on the widget changes -- the current view
158      * will remain valid until the loader loads the latest data.
159      */
start()160     public void start() {
161         long accountId = WidgetManager.loadAccountIdPref(mContext, mWidgetId);
162         long mailboxId = WidgetManager.loadMailboxIdPref(mContext, mWidgetId);
163         // Legacy support; if preferences haven't been saved for this widget, load something
164         if (accountId == Account.NO_ACCOUNT) {
165             accountId = Account.ACCOUNT_ID_COMBINED_VIEW;
166             mailboxId = Mailbox.QUERY_ALL_INBOXES;
167         }
168         mAccountId = accountId;
169         mLoader.load(mAccountId, mailboxId);
170     }
171 
172     /**
173      * Resets the data in the widget and forces a reload.
174      */
reset()175     public void reset() {
176         mLoader.reset();
177         start();
178     }
179 
isCursorValid()180     private boolean isCursorValid() {
181         return mCursor != null && !mCursor.isClosed();
182     }
183 
184     /**
185      * Called when the loader finished loading data.  Update the widget.
186      */
187     @Override
onLoadComplete(Loader<Cursor> loader, Cursor cursor)188     public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) {
189         // Save away the cursor
190         synchronized (sWidgetLock) {
191             mCursor = (EmailWidgetLoader.WidgetCursor) cursor;
192             mAccountName = mCursor.getAccountName();
193             mMailboxName = mCursor.getMailboxName();
194         }
195         updateHeader();
196         mWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list);
197     }
198 
199     /**
200      * Convenience method for creating an onClickPendingIntent that launches another activity
201      * directly.
202      *
203      * @param views The RemoteViews we're inflating
204      * @param buttonId the id of the button view
205      * @param intent The intent to be used when launching the activity
206      */
setActivityIntent(RemoteViews views, int buttonId, Intent intent)207     private void setActivityIntent(RemoteViews views, int buttonId, Intent intent) {
208         intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // just in case intent comes without it
209         PendingIntent pendingIntent =
210                 PendingIntent.getActivity(mContext, (int) mAccountId, intent,
211                         PendingIntent.FLAG_UPDATE_CURRENT);
212         views.setOnClickPendingIntent(buttonId, pendingIntent);
213     }
214 
215     /**
216      * Convenience method for constructing a fillInIntent for a given list view element.
217      * Appends the command and any arguments to a base Uri.
218      *
219      * @param views the RemoteViews we are inflating
220      * @param viewId the id of the view
221      * @param baseUri the base uri for the command
222      * @param args any arguments to the command
223      */
setFillInIntent(RemoteViews views, int viewId, Uri baseUri, String messageId, String mailboxId)224     private void setFillInIntent(RemoteViews views, int viewId, Uri baseUri, String messageId,
225             String mailboxId) {
226         Intent intent = null;
227         try {
228             intent = getOpenMessageIntent(mContext, Long.parseLong(messageId),
229                 Long.parseLong(mailboxId));
230         } catch (NumberFormatException e) {
231             if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
232                 Log.d(TAG, "#setFillInIntent(); invalid messageId: " + messageId +
233                     " or mailboxId: " + mailboxId);
234             }
235         }
236         views.setOnClickFillInIntent(viewId, intent);
237     }
238 
getOpenMessageIntent(final Context context, final long messageId, final long mailboxId)239     private Intent getOpenMessageIntent(final Context context, final long messageId,
240             final long mailboxId) {
241         Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId);
242         return Welcome.createOpenMessageIntent(context, mailbox.mAccountKey,
243                         mailboxId, messageId);
244     }
245 
setTextViewTextAndDesc(RemoteViews views, final int id, String text)246     private void setTextViewTextAndDesc(RemoteViews views, final int id, String text) {
247         views.setTextViewText(id, text);
248         views.setContentDescription(id, text);
249     }
250 
setupTitleAndCount(RemoteViews views)251     private void setupTitleAndCount(RemoteViews views) {
252         // Set up the title (view type + count of messages)
253         setTextViewTextAndDesc(views, R.id.widget_title, mMailboxName);
254         views.setViewVisibility(R.id.widget_tap, View.VISIBLE);
255         setTextViewTextAndDesc(views, R.id.widget_tap, mAccountName);
256         String count = "";
257         synchronized (sWidgetLock) {
258             if (isCursorValid()) {
259                 count = UiUtilities
260                         .getMessageCountForUi(mContext, mCursor.getMessageCount(), false);
261             }
262         }
263         setTextViewTextAndDesc(views, R.id.widget_count, count);
264     }
265 
266     /**
267      * Update the "header" of the widget (i.e. everything that doesn't include the scrolling
268      * message list)
269      */
updateHeader()270     private void updateHeader() {
271         if (Email.DEBUG) {
272             Log.d(TAG, "#updateHeader(); widgetId: " + mWidgetId);
273         }
274 
275         // Get the widget layout
276         RemoteViews views =
277                 new RemoteViews(mContext.getPackageName(), R.layout.widget);
278 
279         // Set up the list with an adapter
280         Intent intent = new Intent(mContext, WidgetService.class);
281         intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mWidgetId);
282         intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)));
283         views.setRemoteAdapter(R.id.message_list, intent);
284 
285         setupTitleAndCount(views);
286 
287         if (isCursorValid()) {
288             // Show compose icon & message list
289             if (mAccountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
290                 // Don't allow compose for "combined" view
291                 views.setViewVisibility(R.id.widget_compose, View.INVISIBLE);
292             } else {
293                 views.setViewVisibility(R.id.widget_compose, View.VISIBLE);
294             }
295             views.setViewVisibility(R.id.message_list, View.VISIBLE);
296             views.setViewVisibility(R.id.tap_to_configure, View.GONE);
297             // Create click intent for "compose email" target
298             intent = MessageCompose.getMessageComposeIntent(mContext, mAccountId);
299             intent.putExtra(MessageCompose.EXTRA_FROM_WIDGET, true);
300             setActivityIntent(views, R.id.widget_compose, intent);
301             // Create click intent for logo to open inbox
302             intent = Welcome.createOpenAccountInboxIntent(mContext, mAccountId);
303             setActivityIntent(views, R.id.widget_header, intent);
304         } else {
305             // TODO This really should never happen ... probably can remove the else block
306             // Hide compose icon & show "touch to configure" text
307             views.setViewVisibility(R.id.widget_compose, View.INVISIBLE);
308             views.setViewVisibility(R.id.message_list, View.GONE);
309             views.setViewVisibility(R.id.tap_to_configure, View.VISIBLE);
310             // Create click intent for "touch to configure" target
311             intent = Welcome.createOpenAccountInboxIntent(mContext, -1);
312             setActivityIntent(views, R.id.tap_to_configure, intent);
313         }
314 
315         // Use a bare intent for our template; we need to fill everything in
316         intent = new Intent(mContext, Welcome.class);
317         PendingIntent pendingIntent = PendingIntent.getActivity(mContext, 0, intent,
318                 PendingIntent.FLAG_UPDATE_CURRENT);
319         views.setPendingIntentTemplate(R.id.message_list, pendingIntent);
320 
321         // And finally update the widget
322         mWidgetManager.updateAppWidget(mWidgetId, views);
323     }
324 
325     /**
326      * Add size and color styling to text
327      *
328      * @param text the text to style
329      * @param size the font size for this text
330      * @param color the color for this text
331      * @return a CharSequence quitable for use in RemoteViews.setTextViewText()
332      */
addStyle(CharSequence text, int size, int color)333     private CharSequence addStyle(CharSequence text, int size, int color) {
334         SpannableStringBuilder builder = new SpannableStringBuilder(text);
335         builder.setSpan(
336                 new AbsoluteSizeSpan(size), 0, text.length(),
337                 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
338         if (color != 0) {
339             builder.setSpan(new ForegroundColorSpan(color), 0, text.length(),
340                     Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
341         }
342         return builder;
343     }
344 
345     /**
346      * Create styled text for our combination subject and snippet
347      *
348      * @param subject the message's subject (or null)
349      * @param snippet the message's snippet (or null)
350      * @param read whether or not the message is read
351      * @return a CharSequence suitable for use in RemoteViews.setTextViewText()
352      */
getStyledSubjectSnippet(String subject, String snippet, boolean read)353     private CharSequence getStyledSubjectSnippet(String subject, String snippet, boolean read) {
354         SpannableStringBuilder ssb = new SpannableStringBuilder();
355         boolean hasSubject = false;
356         if (!TextUtils.isEmpty(subject)) {
357             SpannableString ss = new SpannableString(subject);
358             ss.setSpan(new StyleSpan(read ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(),
359                     Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
360             ss.setSpan(new ForegroundColorSpan(sDefaultTextColor), 0, ss.length(),
361                     Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
362             ssb.append(ss);
363             hasSubject = true;
364         }
365         if (!TextUtils.isEmpty(snippet)) {
366             if (hasSubject) {
367                 ssb.append(sSubjectSnippetDivider);
368             }
369             SpannableString ss = new SpannableString(snippet);
370             ss.setSpan(new ForegroundColorSpan(sLightTextColor), 0, snippet.length(),
371                     Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
372             ssb.append(ss);
373         }
374         return addStyle(ssb, sSubjectFontSize, 0);
375     }
376 
377     @Override
getViewAt(int position)378     public RemoteViews getViewAt(int position) {
379         synchronized (sWidgetLock) {
380             // Use the cursor to set up the widget
381             if (!isCursorValid() || !mCursor.moveToPosition(position)) {
382                 return getLoadingView();
383             }
384             RemoteViews views = new RemoteViews(mContext.getPackageName(),
385                     R.layout.widget_list_item);
386             boolean isUnread = mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAG_READ) != 1;
387             int drawableId = R.drawable.conversation_read_selector;
388             if (isUnread) {
389                 drawableId = R.drawable.conversation_unread_selector;
390             }
391             views.setInt(R.id.widget_message, "setBackgroundResource", drawableId);
392 
393             // Add style to sender
394             String rawSender = mCursor.isNull(EmailWidgetLoader.WIDGET_COLUMN_DISPLAY_NAME) ?
395                     "" : mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_DISPLAY_NAME);
396             SpannableStringBuilder from = new SpannableStringBuilder(rawSender);
397             from.setSpan(isUnread ? new StyleSpan(Typeface.BOLD) : new StyleSpan(Typeface.NORMAL),
398                     0, from.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
399             CharSequence styledFrom = addStyle(from, sSenderFontSize, sDefaultTextColor);
400             views.setTextViewText(R.id.widget_from, styledFrom);
401             views.setContentDescription(R.id.widget_from, rawSender);
402             long timestamp = mCursor.getLong(EmailWidgetLoader.WIDGET_COLUMN_TIMESTAMP);
403             // Get a nicely formatted date string (relative to today)
404             String date = DateUtils.getRelativeTimeSpanString(mContext, timestamp).toString();
405             // Add style to date
406             CharSequence styledDate = addStyle(date, sDateFontSize, sDefaultTextColor);
407             views.setTextViewText(R.id.widget_date, styledDate);
408             views.setContentDescription(R.id.widget_date, date);
409 
410             // Add style to subject/snippet
411             String subject = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_SUBJECT);
412             String snippet = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_SNIPPET);
413             CharSequence subjectAndSnippet = getStyledSubjectSnippet(subject, snippet, !isUnread);
414             views.setTextViewText(R.id.widget_subject, subjectAndSnippet);
415             views.setContentDescription(R.id.widget_subject, subject);
416 
417             int messageFlags = mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAGS);
418             boolean hasInvite = (messageFlags & Message.FLAG_INCOMING_MEETING_INVITE) != 0;
419             views.setViewVisibility(R.id.widget_invite, hasInvite ? View.VISIBLE : View.GONE);
420 
421             boolean hasAttachment = mCursor
422                     .getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAG_ATTACHMENT) != 0;
423             views.setViewVisibility(R.id.widget_attachment, hasAttachment ? View.VISIBLE
424                     : View.GONE);
425 
426             if (mAccountId != Account.ACCOUNT_ID_COMBINED_VIEW) {
427                 views.setViewVisibility(R.id.color_chip, View.INVISIBLE);
428             } else {
429                 long accountId = mCursor.getLong(EmailWidgetLoader.WIDGET_COLUMN_ACCOUNT_KEY);
430                 int colorId = mResourceHelper.getAccountColorId(accountId);
431                 if (colorId != ResourceHelper.UNDEFINED_RESOURCE_ID) {
432                     // Color defined by resource ID, so, use it
433                     views.setViewVisibility(R.id.color_chip, View.VISIBLE);
434                     views.setImageViewResource(R.id.color_chip, colorId);
435                 } else {
436                     // Color not defined by resource ID, nothing we can do, so,
437                     // hide the chip
438                     views.setViewVisibility(R.id.color_chip, View.INVISIBLE);
439                 }
440             }
441 
442             // Set button intents for view, reply, and delete
443             String messageId = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_ID);
444             String mailboxId = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_MAILBOX_KEY);
445             setFillInIntent(views, R.id.widget_message, COMMAND_URI_VIEW_MESSAGE, messageId,
446                     mailboxId);
447 
448             return views;
449         }
450     }
451 
452     @Override
getCount()453     public int getCount() {
454         if (!isCursorValid())
455             return 0;
456         synchronized (sWidgetLock) {
457             return Math.min(mCursor.getCount(), MAX_MESSAGE_LIST_COUNT);
458         }
459     }
460 
461     @Override
getItemId(int position)462     public long getItemId(int position) {
463         return position;
464     }
465 
466     @Override
getLoadingView()467     public RemoteViews getLoadingView() {
468         RemoteViews view = new RemoteViews(mContext.getPackageName(), R.layout.widget_loading);
469         view.setTextViewText(R.id.loading_text, mContext.getString(R.string.widget_loading));
470         return view;
471     }
472 
473     @Override
getViewTypeCount()474     public int getViewTypeCount() {
475         // Regular list view and the "loading" view
476         return 2;
477     }
478 
479     @Override
hasStableIds()480     public boolean hasStableIds() {
481         return true;
482     }
483 
484     @Override
onDataSetChanged()485     public void onDataSetChanged() {
486         // Note: we are not doing anything special in onDataSetChanged().  Since this service has
487         // a reference to a loader that will keep itself updated, if the service is running, it
488         // shouldn't be necessary to for the query to be run again.  If the service hadn't been
489         // running, the act of starting the service will also start the loader.
490     }
491 
onDeleted()492     public void onDeleted() {
493         if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
494             Log.d(TAG, "#onDeleted(); widgetId: " + mWidgetId);
495         }
496 
497         if (mLoader != null) {
498             mLoader.reset();
499         }
500     }
501 
502     @Override
onDestroy()503     public void onDestroy() {
504         if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
505             Log.d(TAG, "#onDestroy(); widgetId: " + mWidgetId);
506         }
507 
508         if (mLoader != null) {
509             mLoader.reset();
510         }
511     }
512 
513     @Override
onCreate()514     public void onCreate() {
515         if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
516             Log.d(TAG, "#onCreate(); widgetId: " + mWidgetId);
517         }
518     }
519 
520     @Override
toString()521     public String toString() {
522         return "View=" + mAccountName;
523     }
524 }
525