ConversationViewFragment.java revision 5ff63747a1b5c6e2197528972cbc3ba808b09d8d
1/* 2 * Copyright (C) 2012 Google Inc. 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18package com.android.mail.ui; 19 20import com.google.common.collect.Maps; 21 22import android.app.Activity; 23import android.app.Fragment; 24import android.app.LoaderManager; 25import android.content.Context; 26import android.content.CursorLoader; 27import android.content.Loader; 28import android.database.Cursor; 29import android.database.CursorWrapper; 30import android.net.Uri; 31import android.os.Bundle; 32import android.os.Handler; 33import android.view.LayoutInflater; 34import android.view.Menu; 35import android.view.MenuInflater; 36import android.view.MenuItem; 37import android.view.MotionEvent; 38import android.view.View; 39import android.view.ViewGroup; 40import android.webkit.ConsoleMessage; 41import android.webkit.WebChromeClient; 42import android.webkit.WebSettings; 43import android.webkit.WebView; 44import android.webkit.WebViewClient; 45import android.widget.ResourceCursorAdapter; 46 47import com.android.mail.FormattedDateBuilder; 48import com.android.mail.R; 49import com.android.mail.browse.ConversationContainer; 50import com.android.mail.browse.ConversationViewHeader; 51import com.android.mail.browse.ConversationWebView; 52import com.android.mail.browse.MessageHeaderView; 53import com.android.mail.providers.Account; 54import com.android.mail.providers.Conversation; 55import com.android.mail.providers.ListParams; 56import com.android.mail.providers.Message; 57import com.android.mail.providers.UIProvider; 58import com.android.mail.utils.LogUtils; 59import com.android.mail.utils.Utils; 60 61import java.util.Map; 62 63/** 64 * The conversation view UI component. 65 */ 66public final class ConversationViewFragment extends Fragment implements 67 LoaderManager.LoaderCallbacks<Cursor>, 68 ConversationViewHeader.ConversationViewHeaderCallbacks { 69 70 private static final String LOG_TAG = new LogUtils().getLogTag(); 71 72 private static final int MESSAGE_LOADER_ID = 0; 73 74 private ControllableActivity mActivity; 75 76 private Context mContext; 77 78 private Conversation mConversation; 79 80 private ConversationViewHeader mConversationHeader; 81 82 private ConversationContainer mConversationContainer; 83 84 private Account mAccount; 85 86 private ConversationWebView mWebView; 87 88 private HtmlConversationTemplates mTemplates; 89 90 private String mBaseUri; 91 92 private final Handler mHandler = new Handler(); 93 94 private final MailJsBridge mJsBridge = new MailJsBridge(); 95 96 private final WebViewClient mWebViewClient = new ConversationWebViewClient(); 97 98 private MessageListAdapter mAdapter; 99 100 private boolean mViewsCreated; 101 102 private MenuItem mChangeFoldersMenuItem; 103 104 private float mDensity; 105 106 private static final String ARG_ACCOUNT = "account"; 107 private static final String ARG_CONVERSATION = "conversation"; 108 109 /** 110 * Constructor needs to be public to handle orientation changes and activity lifecycle events. 111 */ 112 public ConversationViewFragment() { 113 super(); 114 } 115 116 /** 117 * Creates a new instance of {@link ConversationViewFragment}, initialized 118 * to display conversation. 119 */ 120 public static ConversationViewFragment newInstance(Account account, 121 Conversation conversation) { 122 ConversationViewFragment f = new ConversationViewFragment(); 123 Bundle args = new Bundle(); 124 args.putParcelable(ARG_ACCOUNT, account); 125 args.putParcelable(ARG_CONVERSATION, conversation); 126 f.setArguments(args); 127 return f; 128 } 129 130 @Override 131 public void onActivityCreated(Bundle savedInstanceState) { 132 super.onActivityCreated(savedInstanceState); 133 // Strictly speaking, we get back an android.app.Activity from getActivity. However, the 134 // only activity creating a ConversationListContext is a MailActivity which is of type 135 // ControllableActivity, so this cast should be safe. If this cast fails, some other 136 // activity is creating ConversationListFragments. This activity must be of type 137 // ControllableActivity. 138 final Activity activity = getActivity(); 139 if (! (activity instanceof ControllableActivity)) { 140 LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to" 141 + "create it. Cannot proceed."); 142 } 143 mActivity = (ControllableActivity) activity; 144 mContext = mActivity.getApplicationContext(); 145 if (mActivity.isFinishing()) { 146 // Activity is finishing, just bail. 147 return; 148 } 149 mActivity.attachConversationView(this); 150 mTemplates = new HtmlConversationTemplates(mContext); 151 152 mAdapter = new MessageListAdapter(mActivity.getActivityContext(), 153 null /* cursor */, mAccount, getLoaderManager()); 154 mConversationContainer.setOverlayAdapter(mAdapter); 155 156 mDensity = getResources().getDisplayMetrics().density; 157 158 // Show conversation and start loading messages. 159 showConversation(); 160 } 161 162 @Override 163 public void onCreate(Bundle savedState) { 164 LogUtils.v(LOG_TAG, "onCreate in FolderListFragment(this=%s)", this); 165 super.onCreate(savedState); 166 167 Bundle args = getArguments(); 168 mAccount = args.getParcelable(ARG_ACCOUNT); 169 mConversation = args.getParcelable(ARG_CONVERSATION); 170 mBaseUri = "x-thread://" + mAccount.name + "/" + mConversation.id; 171 172 // not really, we just want to get a crack to store a reference to the change_folders item 173 setHasOptionsMenu(true); 174 } 175 176 @Override 177 public View onCreateView(LayoutInflater inflater, 178 ViewGroup container, Bundle savedInstanceState) { 179 View rootView = inflater.inflate(R.layout.conversation_view, null); 180 mConversationContainer = (ConversationContainer) rootView 181 .findViewById(R.id.conversation_container); 182 mWebView = (ConversationWebView) mConversationContainer.findViewById(R.id.webview); 183 mConversationHeader = (ConversationViewHeader) mConversationContainer.findViewById( 184 R.id.conversation_header); 185 mConversationHeader.setCallbacks(this); 186 187 mWebView.addJavascriptInterface(mJsBridge, "mail"); 188 mWebView.setWebViewClient(mWebViewClient); 189 mWebView.setWebChromeClient(new WebChromeClient() { 190 @Override 191 public boolean onConsoleMessage(ConsoleMessage consoleMessage) { 192 LogUtils.i(LOG_TAG, "JS: %s (%s:%d)", consoleMessage.message(), 193 consoleMessage.sourceId(), consoleMessage.lineNumber()); 194 return true; 195 } 196 }); 197 198 WebSettings settings = mWebView.getSettings(); 199 200 settings.setBlockNetworkImage(true); 201 202 settings.setJavaScriptEnabled(true); 203 settings.setUseWideViewPort(true); 204 205 settings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.NORMAL); 206 207 settings.setSupportZoom(true); 208 settings.setBuiltInZoomControls(true); 209 settings.setDisplayZoomControls(false); 210 211 mViewsCreated = true; 212 213 return rootView; 214 } 215 216 @Override 217 public void onDestroyView() { 218 super.onDestroyView(); 219 mViewsCreated = false; 220 mActivity.attachConversationView(null); 221 } 222 223 @Override 224 public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { 225 super.onCreateOptionsMenu(menu, inflater); 226 227 mChangeFoldersMenuItem = menu.findItem(R.id.change_folders); 228 } 229 230 /** 231 * Handles a request to show a new conversation list, either from a search query or for viewing 232 * a folder. This will initiate a data load, and hence must be called on the UI thread. 233 */ 234 private void showConversation() { 235 // initialize conversation header, measure its height manually, and inform template render 236 // TODO: inform template render of initial header height 237 mConversationHeader.setSubject(mConversation.subject, false /* notify */); 238 if (mAccount.supportsCapability( 239 UIProvider.AccountCapabilities.MULTIPLE_FOLDERS_PER_CONV)) { 240 mConversationHeader.setFolders(mConversation, false /* notify */); 241 } 242 243 getLoaderManager().initLoader(MESSAGE_LOADER_ID, Bundle.EMPTY, this); 244 } 245 246 @Override 247 public Loader<Cursor> onCreateLoader(int id, Bundle args) { 248 return new MessageLoader(mContext, mConversation.messageListUri); 249 } 250 251 @Override 252 public void onLoadFinished(Loader<Cursor> loader, Cursor data) { 253 MessageCursor messageCursor = (MessageCursor) data; 254 255 if (mAdapter.getCursor() == null) { 256 renderConversation(messageCursor); 257 } else { 258 updateConversation(messageCursor); 259 } 260 } 261 262 @Override 263 public void onLoaderReset(Loader<Cursor> loader) { 264 mAdapter.swapCursor(null); 265 } 266 267 private void renderConversation(MessageCursor messageCursor) { 268 mWebView.loadDataWithBaseURL(mBaseUri, renderMessageBodies(messageCursor), "text/html", 269 "utf-8", null); 270 mAdapter.swapCursor(messageCursor); 271 } 272 273 private void updateConversation(MessageCursor messageCursor) { 274 // TODO: handle server-side conversation updates 275 // for simple things like header data changes, just re-render the affected headers 276 // if a new message is present, save off the pending cursor and show a notification to 277 // re-render 278 279 final MessageCursor oldCursor = (MessageCursor) mAdapter.getCursor(); 280 mAdapter.swapCursor(messageCursor); 281 } 282 283 private String renderMessageBodies(MessageCursor messageCursor) { 284 int pos = -1; 285 286 // N.B. the units of height for spacers are actually dp and not px because WebView assumes 287 // a pixel is an mdpi pixel, unless you set device-dpi. 288 289 final int headerHeightPx = Utils.measureViewHeight(mConversationHeader, 290 mConversationContainer); 291 mTemplates.startConversation((int) (headerHeightPx / mDensity)); 292 293 // FIXME: measure the header (and the attachments) and insert spacers of appropriate size 294 final int spacerH = (Utils.useTabletUI(mContext)) ? 112 : 96; 295 while (messageCursor.moveToPosition(++pos)) { 296 mTemplates.appendMessageHtml(messageCursor.get(), true, false, 1.0f, spacerH); 297 } 298 return mTemplates.endConversation(mBaseUri, 320); 299 } 300 301 public void onTouchEvent(MotionEvent event) { 302 // TODO: (mindyp) when there is an undo bar, check for event !in undo bar 303 // if its not in undo bar, dismiss the undo bar. 304 } 305 306 @Override 307 public void onFoldersClicked() { 308 if (mChangeFoldersMenuItem == null) { 309 LogUtils.e(LOG_TAG, "unable to open 'change folders' dialog for a conversation"); 310 return; 311 } 312 mActivity.onOptionsItemSelected(mChangeFoldersMenuItem); 313 } 314 315 @Override 316 public void onConversationViewHeaderHeightChange(int newHeight) { 317 // TODO: propagate the new height to the header's HTML spacer. This can happen when labels 318 // are added/removed 319 } 320 321 @Override 322 public String getSubjectRemainder(String subject) { 323 // TODO: hook this up to action bar 324 return subject; 325 } 326 327 private static class MessageLoader extends CursorLoader { 328 private boolean mDeliveredFirstResults = false; 329 330 public MessageLoader(Context c, Uri uri) { 331 super(c, uri, UIProvider.MESSAGE_PROJECTION, null, null, null); 332 } 333 334 @Override 335 public Cursor loadInBackground() { 336 return new MessageCursor(super.loadInBackground()); 337 338 } 339 340 @Override 341 public void deliverResult(Cursor result) { 342 // We want to deliver these results, and then we want to make sure that any subsequent 343 // queries do not hit the network 344 super.deliverResult(result); 345 346 if (!mDeliveredFirstResults) { 347 mDeliveredFirstResults = true; 348 Uri uri = getUri(); 349 350 // Create a ListParams that tells the provider to not hit the network 351 final ListParams listParams = 352 new ListParams(ListParams.NO_LIMIT, false /* useNetwork */); 353 354 // Build the new uri with this additional parameter 355 uri = uri.buildUpon().appendQueryParameter( 356 UIProvider.LIST_PARAMS_QUERY_PARAMETER, listParams.serialize()).build(); 357 setUri(uri); 358 } 359 } 360 } 361 362 private static class MessageCursor extends CursorWrapper { 363 364 private Map<Long, Message> mCache = Maps.newHashMap(); 365 366 public MessageCursor(Cursor inner) { 367 super(inner); 368 } 369 370 public Message get() { 371 final long id = getWrappedCursor().getLong(UIProvider.MESSAGE_ID_COLUMN); 372 Message m = mCache.get(id); 373 if (m == null) { 374 m = new Message(this); 375 mCache.put(id, m); 376 } 377 return m; 378 } 379 } 380 381 private static class MessageListAdapter extends ResourceCursorAdapter { 382 383 private final FormattedDateBuilder mDateBuilder; 384 private final Account mAccount; 385 private final LoaderManager mLoaderManager; 386 387 public MessageListAdapter(Context context, Cursor messageCursor, Account account, 388 LoaderManager loaderManager) { 389 super(context, R.layout.conversation_message_header, messageCursor, 0); 390 mDateBuilder = new FormattedDateBuilder(context); 391 mAccount = account; 392 mLoaderManager = loaderManager; 393 } 394 395 @Override 396 public void bindView(View view, Context context, Cursor cursor) { 397 Message m = ((MessageCursor) cursor).get(); 398 MessageHeaderView header = (MessageHeaderView) view; 399 header.initialize(mDateBuilder, mAccount, mLoaderManager, true, false, false); 400 header.bind(m); 401 } 402 } 403 404 private static int[] parseInts(final String[] stringArray) { 405 final int len = stringArray.length; 406 final int[] ints = new int[len]; 407 for (int i = 0; i < len; i++) { 408 ints[i] = Integer.parseInt(stringArray[i]); 409 } 410 return ints; 411 } 412 413 private class ConversationWebViewClient extends WebViewClient { 414 415 @Override 416 public void onPageFinished(WebView view, String url) { 417 super.onPageFinished(view, url); 418 419 // TODO: save off individual message unread state (here, or in onLoadFinished?) so 420 // 'mark unread' restores the original unread state for each individual message 421 422 // mark as read upon open 423 if (!mConversation.read) { 424 mConversation.markRead(mContext, true /* read */); 425 mConversation.read = true; 426 } 427 } 428 429 } 430 431 /** 432 * NOTE: all public methods must be listed in the proguard flags so that they can be accessed 433 * via reflection and not stripped. 434 * 435 */ 436 private class MailJsBridge { 437 438 @SuppressWarnings("unused") 439 public void onWebContentGeometryChange(final String[] headerBottomStrs, 440 final String[] headerHeightStrs) { 441 mHandler.post(new Runnable() { 442 @Override 443 public void run() { 444 if (!mViewsCreated) { 445 LogUtils.d(LOG_TAG, "ignoring webContentGeometryChange because views" + 446 " are gone, %s", ConversationViewFragment.this); 447 return; 448 } 449 450 mConversationContainer.onGeometryChange(parseInts(headerBottomStrs), 451 parseInts(headerHeightStrs)); 452 } 453 }); 454 } 455 456 } 457 458} 459