/* * Copyright 2012 Google Inc. * * 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.google.android.apps.iosched.ui; import com.google.analytics.tracking.android.EasyTracker; import com.google.android.apps.iosched.Config; import com.google.android.apps.iosched.R; import com.google.android.apps.iosched.util.ImageFetcher; import com.google.android.apps.iosched.util.UIUtils; import com.google.api.client.googleapis.services.GoogleKeyInitializer; import com.google.api.client.http.HttpTransport; import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.client.http.json.JsonHttpRequestInitializer; import com.google.api.client.json.JsonFactory; import com.google.api.client.json.jackson.JacksonFactory; import com.google.api.services.plus.Plus; import com.google.api.services.plus.model.Activity; import com.google.api.services.plus.model.ActivityFeed; import com.actionbarsherlock.app.SherlockListFragment; import com.actionbarsherlock.view.Menu; import com.actionbarsherlock.view.MenuInflater; import com.actionbarsherlock.view.MenuItem; import android.content.Context; import android.content.Intent; import android.content.res.Resources; import android.graphics.Color; import android.net.ParseException; import android.net.Uri; import android.os.Bundle; import android.support.v4.app.LoaderManager; import android.support.v4.app.ShareCompat; import android.support.v4.content.AsyncTaskLoader; import android.support.v4.content.Loader; import android.text.Html; import android.text.TextUtils; import android.util.TypedValue; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.webkit.WebView; import android.widget.AbsListView; import android.widget.BaseAdapter; import android.widget.ImageView; import android.widget.ListView; import android.widget.TextView; import java.io.IOException; import java.util.ArrayList; import java.util.List; import static com.google.android.apps.iosched.util.LogUtils.LOGD; import static com.google.android.apps.iosched.util.LogUtils.makeLogTag; /** * A {@link WebView}-based fragment that shows Google+ public search results for a given query, * provided as the {@link SocialStreamFragment#EXTRA_QUERY} extra in the fragment arguments. If no * search query is provided, the conference hashtag is used as the default query. * * <p>WARNING! This fragment uses the Google+ API, and is subject to quotas. If you expect to * write a wildly popular app based on this code, please check the * at <a href="https://developers.google.com/+/">Google+ Platform documentation</a> on latest * best practices and quota details. You can check your current quota at the * <a href="https://code.google.com/apis/console">APIs console</a>. */ public class SocialStreamFragment extends SherlockListFragment implements AbsListView.OnScrollListener, LoaderManager.LoaderCallbacks<List<Activity>> { private static final String TAG = makeLogTag(SocialStreamFragment.class); public static final String EXTRA_QUERY = "com.google.android.iosched.extra.QUERY"; private static final String STATE_POSITION = "position"; private static final String STATE_TOP = "top"; private static final long MAX_RESULTS_PER_REQUEST = 20; private static final int STREAM_LOADER_ID = 0; private String mSearchString; private List<Activity> mStream = new ArrayList<Activity>(); private StreamAdapter mStreamAdapter = new StreamAdapter(); private int mListViewStatePosition; private int mListViewStateTop; private ImageFetcher mImageFetcher; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); final Intent intent = BaseActivity.fragmentArgumentsToIntent(getArguments()); // mSearchString can be populated before onCreate() by called refresh(String) if (TextUtils.isEmpty(mSearchString)) { mSearchString = intent.getStringExtra(EXTRA_QUERY); } if (TextUtils.isEmpty(mSearchString)) { mSearchString = UIUtils.CONFERENCE_HASHTAG; } if (!mSearchString.startsWith("#")) { mSearchString = "#" + mSearchString; } mImageFetcher = UIUtils.getImageFetcher(getActivity()); setListAdapter(mStreamAdapter); setHasOptionsMenu(true); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { if (savedInstanceState != null) { mListViewStatePosition = savedInstanceState.getInt(STATE_POSITION, -1); mListViewStateTop = savedInstanceState.getInt(STATE_TOP, 0); } else { mListViewStatePosition = -1; mListViewStateTop = 0; } return super.onCreateView(inflater, container, savedInstanceState); } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); setEmptyText(getString(R.string.empty_social_stream)); // In support library r8, calling initLoader for a fragment in a FragmentPagerAdapter // in the fragment's onCreate may cause the same LoaderManager to be dealt to multiple // fragments because their mIndex is -1 (haven't been added to the activity yet). Thus, // we do this in onActivityCreated. getLoaderManager().initLoader(STREAM_LOADER_ID, null, this); } @Override public void onViewCreated(View view, Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); view.setBackgroundColor(Color.WHITE); final ListView listView = getListView(); listView.setCacheColorHint(Color.WHITE); listView.setOnScrollListener(this); listView.setDrawSelectorOnTop(true); TypedValue v = new TypedValue(); getActivity().getTheme().resolveAttribute(R.attr.activatableItemBackground, v, true); listView.setSelector(v.resourceId); } @Override public void onStop() { super.onStop(); if (mImageFetcher != null) { mImageFetcher.closeCache(); } } @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { inflater.inflate(R.menu.social_stream, menu); super.onCreateOptionsMenu(menu, inflater); } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.menu_compose: Intent intent = ShareCompat.IntentBuilder.from(getActivity()) .setType("text/plain") .setText(mSearchString + "\n\n") .getIntent(); UIUtils.preferPackageForIntent(getActivity(), intent, UIUtils.GOOGLE_PLUS_PACKAGE_NAME); startActivity(intent); EasyTracker.getTracker().trackEvent("Home Screen Dashboard", "Click", "Post to Google+", 0L); LOGD("Tracker", "Home Screen Dashboard: Click, post to Google+"); return true; } return super.onOptionsItemSelected(item); } @Override public void onSaveInstanceState(Bundle outState) { if (isAdded()) { View v = getListView().getChildAt(0); int top = (v == null) ? 0 : v.getTop(); outState.putInt(STATE_POSITION, getListView().getFirstVisiblePosition()); outState.putInt(STATE_TOP, top); } super.onSaveInstanceState(outState); } public void refresh(String newQuery) { mSearchString = newQuery; refresh(true); } public void refresh() { refresh(false); } public void refresh(boolean forceRefresh) { if (isStreamLoading() && !forceRefresh) { return; } // clear current items mStream.clear(); mStreamAdapter.notifyDataSetInvalidated(); if (isAdded()) { Loader loader = getLoaderManager().getLoader(STREAM_LOADER_ID); ((StreamLoader) loader).init(mSearchString); } loadMoreResults(); } public void loadMoreResults() { if (isAdded()) { Loader loader = getLoaderManager().getLoader(STREAM_LOADER_ID); if (loader != null) { loader.forceLoad(); } } } @Override public void onListItemClick(ListView l, View v, int position, long id) { Activity activity = mStream.get(position); Intent postDetailIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(activity.getUrl())); postDetailIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); UIUtils.preferPackageForIntent(getActivity(), postDetailIntent, UIUtils.GOOGLE_PLUS_PACKAGE_NAME); UIUtils.safeOpenLink(getActivity(), postDetailIntent); } @Override public void onScrollStateChanged(AbsListView listView, int scrollState) { // Pause disk cache access to ensure smoother scrolling if (scrollState == AbsListView.OnScrollListener.SCROLL_STATE_FLING || scrollState == AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) { mImageFetcher.setPauseDiskCache(true); } else { mImageFetcher.setPauseDiskCache(false); } } @Override public void onScroll(AbsListView absListView, int firstVisibleItem, int visibleItemCount, int totalItemCount) { // Simple implementation of the infinite scrolling UI pattern; loads more Google+ // search results as the user scrolls to the end of the list. if (!isStreamLoading() && streamHasMoreResults() && visibleItemCount != 0 && firstVisibleItem + visibleItemCount >= totalItemCount - 1) { loadMoreResults(); } } @Override public Loader<List<Activity>> onCreateLoader(int id, Bundle args) { return new StreamLoader(getActivity(), mSearchString); } @Override public void onLoadFinished(Loader<List<Activity>> listLoader, List<Activity> activities) { if (activities != null) { mStream = activities; } mStreamAdapter.notifyDataSetChanged(); if (mListViewStatePosition != -1 && isAdded()) { getListView().setSelectionFromTop(mListViewStatePosition, mListViewStateTop); mListViewStatePosition = -1; } } @Override public void onLoaderReset(Loader<List<Activity>> listLoader) { } private boolean isStreamLoading() { if (isAdded()) { final Loader loader = getLoaderManager().getLoader(STREAM_LOADER_ID); if (loader != null) { return ((StreamLoader) loader).isLoading(); } } return true; } private boolean streamHasMoreResults() { if (isAdded()) { final Loader loader = getLoaderManager().getLoader(STREAM_LOADER_ID); if (loader != null) { return ((StreamLoader) loader).hasMoreResults(); } } return false; } private boolean streamHasError() { if (isAdded()) { final Loader loader = getLoaderManager().getLoader(STREAM_LOADER_ID); if (loader != null) { return ((StreamLoader) loader).hasError(); } } return false; } /** * An {@link AsyncTaskLoader} that loads activities from the public Google+ stream for * a given search query. The loader maintains a page state with the Google+ API and thus * supports pagination. */ private static class StreamLoader extends AsyncTaskLoader<List<Activity>> { List<Activity> mActivities; private String mSearchString; private String mNextPageToken; private boolean mIsLoading; private boolean mHasError; public StreamLoader(Context context, String searchString) { super(context); init(searchString); } private void init(String searchString) { mSearchString = searchString; mHasError = false; mNextPageToken = null; mIsLoading = true; mActivities = null; } @Override public List<Activity> loadInBackground() { mIsLoading = true; // Set up the HTTP transport and JSON factory HttpTransport httpTransport = new NetHttpTransport(); JsonFactory jsonFactory = new JacksonFactory(); JsonHttpRequestInitializer initializer = new GoogleKeyInitializer( Config.API_KEY); // Set up the main Google+ class Plus plus = Plus.builder(httpTransport, jsonFactory) .setApplicationName(Config.APP_NAME) .setJsonHttpRequestInitializer(initializer) .build(); ActivityFeed activities = null; try { activities = plus.activities().search(mSearchString) .setPageToken(mNextPageToken) .setMaxResults(MAX_RESULTS_PER_REQUEST) .execute(); mHasError = false; mNextPageToken = activities.getNextPageToken(); } catch (IOException e) { e.printStackTrace(); mHasError = true; mNextPageToken = null; } return (activities != null) ? activities.getItems() : null; } @Override public void deliverResult(List<Activity> activities) { mIsLoading = false; if (activities != null) { if (mActivities == null) { mActivities = activities; } else { mActivities.addAll(activities); } } if (isStarted()) { // Need to return new ArrayList for some reason or onLoadFinished() is not called super.deliverResult(mActivities == null ? null : new ArrayList<Activity>(mActivities)); } } @Override protected void onStartLoading() { if (mActivities != null) { // If we already have results and are starting up, deliver what we already have. deliverResult(null); } else { forceLoad(); } } @Override protected void onStopLoading() { mIsLoading = false; cancelLoad(); } @Override protected void onReset() { super.onReset(); onStopLoading(); mActivities = null; } public boolean isLoading() { return mIsLoading; } public boolean hasMoreResults() { return mNextPageToken != null; } public boolean hasError() { return mHasError; } public void setSearchString(String searchString) { mSearchString = searchString; } public void refresh(String searchString) { setSearchString(searchString); refresh(); } public void refresh() { reset(); startLoading(); } } /** * A list adapter that shows individual Google+ activities as list items. * If another page is available, the last item is a "loading" view to support the * infinite scrolling UI pattern. */ private class StreamAdapter extends BaseAdapter { private static final int VIEW_TYPE_ACTIVITY = 0; private static final int VIEW_TYPE_LOADING = 1; @Override public boolean areAllItemsEnabled() { return false; } @Override public boolean isEnabled(int position) { return getItemViewType(position) == VIEW_TYPE_ACTIVITY; } @Override public int getViewTypeCount() { return 2; } @Override public boolean hasStableIds() { return true; } @Override public int getCount() { return mStream.size() + ( // show the status list row if... ((isStreamLoading() && mStream.size() == 0) // ...this is the first load || streamHasMoreResults() // ...or there's another page || streamHasError()) // ...or there's an error ? 1 : 0); } @Override public int getItemViewType(int position) { return (position >= mStream.size()) ? VIEW_TYPE_LOADING : VIEW_TYPE_ACTIVITY; } @Override public Object getItem(int position) { return (getItemViewType(position) == VIEW_TYPE_ACTIVITY) ? mStream.get(position) : null; } @Override public long getItemId(int position) { // TODO: better unique ID heuristic return (getItemViewType(position) == VIEW_TYPE_ACTIVITY) ? mStream.get(position).getId().hashCode() : -1; } @Override public View getView(int position, View convertView, ViewGroup parent) { if (getItemViewType(position) == VIEW_TYPE_LOADING) { if (convertView == null) { convertView = getLayoutInflater(null).inflate( R.layout.list_item_stream_status, parent, false); } if (streamHasError()) { convertView.findViewById(android.R.id.progress).setVisibility(View.GONE); ((TextView) convertView.findViewById(android.R.id.text1)).setText( R.string.stream_error); } else { convertView.findViewById(android.R.id.progress).setVisibility(View.VISIBLE); ((TextView) convertView.findViewById(android.R.id.text1)).setText( R.string.loading); } return convertView; } else { Activity activity = (Activity) getItem(position); if (convertView == null) { convertView = getLayoutInflater(null).inflate( R.layout.list_item_stream_activity, parent, false); } StreamRowViewBinder.bindActivityView(convertView, activity, mImageFetcher); return convertView; } } } /** * A helper class to bind data from a Google+ {@link Activity} to the list item view. */ private static class StreamRowViewBinder { private static class ViewHolder { private TextView[] detail; private ImageView[] detailIcon; private ImageView[] media; private ImageView[] mediaOverlay; private TextView originalAuthor; private View reshareLine; private View reshareSpacer; private ImageView userImage; private TextView userName; private TextView content; private View plusOneIcon; private TextView plusOneCount; private View commentIcon; private TextView commentCount; } private static void bindActivityView( final View rootView, Activity activity, ImageFetcher imageFetcher) { // Prepare view holder. ViewHolder temp = (ViewHolder) rootView.getTag(); final ViewHolder views; if (temp != null) { views = temp; } else { views = new ViewHolder(); rootView.setTag(views); views.detail = new TextView[] { (TextView) rootView.findViewById(R.id.stream_detail_text) }; views.detailIcon = new ImageView[] { (ImageView) rootView.findViewById(R.id.stream_detail_media_small) }; views.media = new ImageView[] { (ImageView) rootView.findViewById(R.id.stream_media_1_1), (ImageView) rootView.findViewById(R.id.stream_media_1_2), }; views.mediaOverlay = new ImageView[] { (ImageView) rootView.findViewById(R.id.stream_media_overlay_1_1), (ImageView) rootView.findViewById(R.id.stream_media_overlay_1_2), }; views.originalAuthor = (TextView) rootView.findViewById(R.id.stream_original_author); views.reshareLine = rootView.findViewById(R.id.stream_reshare_line); views.reshareSpacer = rootView.findViewById(R.id.stream_reshare_spacer); views.userImage = (ImageView) rootView.findViewById(R.id.stream_user_image); views.userName = (TextView) rootView.findViewById(R.id.stream_user_name); views.content = (TextView) rootView.findViewById(R.id.stream_content); views.plusOneIcon = rootView.findViewById(R.id.stream_plus_one_icon); views.plusOneCount = (TextView) rootView.findViewById(R.id.stream_plus_one_count); views.commentIcon = rootView.findViewById(R.id.stream_comment_icon); views.commentCount = (TextView) rootView.findViewById(R.id.stream_comment_count); } final Resources res = rootView.getContext().getResources(); // Hide all the array items. int detailIndex = 0; int mediaIndex = 0; for (View v : views.detail) { v.setVisibility(View.GONE); } for (View v : views.detailIcon) { v.setVisibility(View.GONE); } for (View v : views.media) { v.setVisibility(View.GONE); } for (View v : views.mediaOverlay) { v.setVisibility(View.GONE); } // Determine if this is a reshare (affects how activity fields are to be // interpreted). boolean isReshare = (activity.getObject().getActor() != null); // Set user name. views.userName.setText(activity.getActor().getDisplayName()); if (activity.getActor().getImage() != null) { imageFetcher.loadThumbnailImage(activity.getActor().getImage().getUrl(), views.userImage, R.drawable.person_image_empty); } else { views.userImage.setImageResource(R.drawable.person_image_empty); } // Set +1 and comment counts. final int plusOneCount = (activity.getObject().getPlusoners() != null) ? activity.getObject().getPlusoners().getTotalItems().intValue() : 0; if (plusOneCount == 0) { views.plusOneIcon.setVisibility(View.GONE); views.plusOneCount.setVisibility(View.GONE); } else { views.plusOneIcon.setVisibility(View.VISIBLE); views.plusOneCount.setVisibility(View.VISIBLE); views.plusOneCount.setText(Integer.toString(plusOneCount)); } final int commentCount = (activity.getObject().getReplies() != null) ? activity.getObject().getReplies().getTotalItems().intValue() : 0; if (commentCount == 0) { views.commentIcon.setVisibility(View.GONE); views.commentCount.setVisibility(View.GONE); } else { views.commentIcon.setVisibility(View.VISIBLE); views.commentCount.setVisibility(View.VISIBLE); views.commentCount.setText(Integer.toString(commentCount)); } // Set content. String selfContent = isReshare ? activity.getAnnotation() : activity.getObject().getContent(); if (!TextUtils.isEmpty(selfContent)) { views.content.setVisibility(View.VISIBLE); views.content.setText(Html.fromHtml(selfContent)); } else { views.content.setVisibility(View.GONE); } // Set original author. if (activity.getObject().getActor() != null) { views.originalAuthor.setVisibility(View.VISIBLE); views.originalAuthor.setText(res.getString(R.string.stream_originally_shared, activity.getObject().getActor().getDisplayName())); views.reshareLine.setVisibility(View.VISIBLE); views.reshareSpacer.setVisibility(View.INVISIBLE); } else { views.originalAuthor.setVisibility(View.GONE); views.reshareLine.setVisibility(View.GONE); views.reshareSpacer.setVisibility(View.GONE); } // Set document content. if (isReshare && !TextUtils.isEmpty(activity.getObject().getContent()) && detailIndex < views.detail.length) { views.detail[detailIndex].setVisibility(View.VISIBLE); views.detail[detailIndex].setTextColor(res.getColor(R.color.stream_content_color)); views.detail[detailIndex].setText(Html.fromHtml(activity.getObject().getContent())); ++detailIndex; } // Set location. String location = activity.getPlaceName(); if (!TextUtils.isEmpty(location)) { location = activity.getAddress(); } if (!TextUtils.isEmpty(location)) { location = activity.getGeocode(); } if (!TextUtils.isEmpty(location) && detailIndex < views.detail.length) { views.detail[detailIndex].setVisibility(View.VISIBLE); views.detail[detailIndex].setTextColor(res.getColor(R.color.stream_link_color)); views.detailIcon[detailIndex].setVisibility(View.VISIBLE); views.detail[detailIndex].setText(location); if ("checkin".equals(activity.getVerb())) { views.detailIcon[detailIndex].setImageResource(R.drawable.stream_ic_checkin); } else { views.detailIcon[detailIndex].setImageResource(R.drawable.stream_ic_location); } ++detailIndex; } // Set media content. if (activity.getObject().getAttachments() != null) { for (Activity.PlusObject.Attachments attachment : activity.getObject().getAttachments()) { String objectType = attachment.getObjectType(); if (("photo".equals(objectType) || "video".equals(objectType)) && mediaIndex < views.media.length) { if (attachment.getImage() == null) { continue; } final ImageView mediaView = views.media[mediaIndex]; mediaView.setVisibility(View.VISIBLE); imageFetcher.loadThumbnailImage(attachment.getImage().getUrl(), mediaView); if ("video".equals(objectType)) { views.mediaOverlay[mediaIndex].setVisibility(View.VISIBLE); } ++mediaIndex; } else if (("article".equals(objectType)) && detailIndex < views.detail.length) { try { String faviconUrl = "http://www.google.com/s2/favicons?domain=" + Uri.parse(attachment.getUrl()).getHost(); final ImageView iconView = views.detailIcon[detailIndex]; iconView.setVisibility(View.VISIBLE); imageFetcher.loadThumbnailImage(faviconUrl, iconView); } catch (ParseException ignored) {} views.detail[detailIndex].setVisibility(View.VISIBLE); views.detail[detailIndex].setTextColor( res.getColor(R.color.stream_link_color)); views.detail[detailIndex].setText(attachment.getDisplayName()); ++detailIndex; } } } } } }