/** * Copyright 2012 Facebook * * 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.facebook.widget; import android.content.Context; import android.graphics.Bitmap; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewStub; import android.widget.*; import com.facebook.*; import com.facebook.android.R; import com.facebook.model.GraphObject; import org.json.JSONObject; import java.net.MalformedURLException; import java.net.URL; import java.text.Collator; import java.util.*; class GraphObjectAdapter<T extends GraphObject> extends BaseAdapter implements SectionIndexer { private static final int DISPLAY_SECTIONS_THRESHOLD = 1; private static final int HEADER_VIEW_TYPE = 0; private static final int GRAPH_OBJECT_VIEW_TYPE = 1; private static final int ACTIVITY_CIRCLE_VIEW_TYPE = 2; private static final int MAX_PREFETCHED_PICTURES = 20; private static final String ID = "id"; private static final String NAME = "name"; private static final String PICTURE = "picture"; private final Map<String, ImageRequest> pendingRequests = new HashMap<String, ImageRequest>(); private final LayoutInflater inflater; private List<String> sectionKeys = new ArrayList<String>(); private Map<String, ArrayList<T>> graphObjectsBySection = new HashMap<String, ArrayList<T>>(); private Map<String, T> graphObjectsById = new HashMap<String, T>(); private boolean displaySections; private List<String> sortFields; private String groupByField; private boolean showPicture; private boolean showCheckbox; private Filter<T> filter; private DataNeededListener dataNeededListener; private GraphObjectCursor<T> cursor; private Context context; private Map<String, ImageResponse> prefetchedPictureCache = new HashMap<String, ImageResponse>(); private ArrayList<String> prefetchedProfilePictureIds = new ArrayList<String>(); public interface DataNeededListener { public void onDataNeeded(); } public static class SectionAndItem<T extends GraphObject> { public String sectionKey; public T graphObject; public enum Type { GRAPH_OBJECT, SECTION_HEADER, ACTIVITY_CIRCLE } public SectionAndItem(String sectionKey, T graphObject) { this.sectionKey = sectionKey; this.graphObject = graphObject; } public Type getType() { if (sectionKey == null) { return Type.ACTIVITY_CIRCLE; } else if (graphObject == null) { return Type.SECTION_HEADER; } else { return Type.GRAPH_OBJECT; } } } interface Filter<T> { boolean includeItem(T graphObject); } public GraphObjectAdapter(Context context) { this.context = context; this.inflater = LayoutInflater.from(context); } public List<String> getSortFields() { return sortFields; } public void setSortFields(List<String> sortFields) { this.sortFields = sortFields; } public String getGroupByField() { return groupByField; } public void setGroupByField(String groupByField) { this.groupByField = groupByField; } public boolean getShowPicture() { return showPicture; } public void setShowPicture(boolean showPicture) { this.showPicture = showPicture; } public boolean getShowCheckbox() { return showCheckbox; } public void setShowCheckbox(boolean showCheckbox) { this.showCheckbox = showCheckbox; } public DataNeededListener getDataNeededListener() { return dataNeededListener; } public void setDataNeededListener(DataNeededListener dataNeededListener) { this.dataNeededListener = dataNeededListener; } public GraphObjectCursor<T> getCursor() { return cursor; } public boolean changeCursor(GraphObjectCursor<T> cursor) { if (this.cursor == cursor) { return false; } if (this.cursor != null) { this.cursor.close(); } this.cursor = cursor; rebuildAndNotify(); return true; } public void rebuildAndNotify() { rebuildSections(); notifyDataSetChanged(); } public void prioritizeViewRange(int firstVisibleItem, int lastVisibleItem, int prefetchBuffer) { if (lastVisibleItem < firstVisibleItem) { return; } // We want to prioritize requests for items which are visible but do not have pictures // loaded yet. We also want to pre-fetch pictures for items which are not yet visible // but are within a buffer on either side of the visible items, on the assumption that // they will be visible soon. For these latter items, we'll store the images in memory // in the hopes we can immediately populate their image view when needed. // Prioritize the requests in reverse order since each call to prioritizeRequest will just // move it to the front of the queue. And we want the earliest ones in the range to be at // the front of the queue, so all else being equal, the list will appear to populate from // the top down. for (int i = lastVisibleItem; i >= 0; i--) { SectionAndItem<T> sectionAndItem = getSectionAndItem(i); if (sectionAndItem.graphObject != null) { String id = getIdOfGraphObject(sectionAndItem.graphObject); ImageRequest request = pendingRequests.get(id); if (request != null) { ImageDownloader.prioritizeRequest(request); } } } // For items which are not visible, but within the buffer on either side, we want to // fetch those items and store them in a small in-memory cache of bitmaps. int start = Math.max(0, firstVisibleItem - prefetchBuffer); int end = Math.min(lastVisibleItem + prefetchBuffer, getCount() - 1); ArrayList<T> graphObjectsToPrefetchPicturesFor = new ArrayList<T>(); // Add the IDs before and after the visible range. for (int i = start; i < firstVisibleItem; ++i) { SectionAndItem<T> sectionAndItem = getSectionAndItem(i); if (sectionAndItem.graphObject != null) { graphObjectsToPrefetchPicturesFor.add(sectionAndItem.graphObject); } } for (int i = lastVisibleItem + 1; i <= end; ++i) { SectionAndItem<T> sectionAndItem = getSectionAndItem(i); if (sectionAndItem.graphObject != null) { graphObjectsToPrefetchPicturesFor.add(sectionAndItem.graphObject); } } for (T graphObject : graphObjectsToPrefetchPicturesFor) { URL url = getPictureUrlOfGraphObject(graphObject); final String id = getIdOfGraphObject(graphObject); // This URL already have been requested for pre-fetching, but we want to act in an LRU manner, so move // it to the end of the list regardless. boolean alreadyPrefetching = prefetchedProfilePictureIds.remove(id); prefetchedProfilePictureIds.add(id); // If we've already requested it for pre-fetching, no need to do so again. if (!alreadyPrefetching) { downloadProfilePicture(id, url, null); } } } protected String getSectionKeyOfGraphObject(T graphObject) { String result = null; if (groupByField != null) { result = (String) graphObject.getProperty(groupByField); if (result != null && result.length() > 0) { result = result.substring(0, 1).toUpperCase(); } } return (result != null) ? result : ""; } protected CharSequence getTitleOfGraphObject(T graphObject) { return (String) graphObject.getProperty(NAME); } protected CharSequence getSubTitleOfGraphObject(T graphObject) { return null; } protected URL getPictureUrlOfGraphObject(T graphObject) { String url = null; Object o = graphObject.getProperty(PICTURE); if (o instanceof String) { url = (String) o; } else if (o instanceof JSONObject) { ItemPicture itemPicture = GraphObject.Factory.create((JSONObject) o).cast(ItemPicture.class); ItemPictureData data = itemPicture.getData(); if (data != null) { url = data.getUrl(); } } if (url != null) { try { return new URL(url); } catch (MalformedURLException e) { } } return null; } protected View getSectionHeaderView(String sectionHeader, View convertView, ViewGroup parent) { TextView result = (TextView) convertView; if (result == null) { result = (TextView) inflater.inflate(R.layout.com_facebook_picker_list_section_header, null); } result.setText(sectionHeader); return result; } protected View getGraphObjectView(T graphObject, View convertView, ViewGroup parent) { View result = convertView; if (result == null) { result = createGraphObjectView(graphObject, convertView); } populateGraphObjectView(result, graphObject); return result; } private View getActivityCircleView(View convertView, ViewGroup parent) { View result = convertView; if (result == null) { result = inflater.inflate(R.layout.com_facebook_picker_activity_circle_row, null); } ProgressBar activityCircle = (ProgressBar) result.findViewById(R.id.com_facebook_picker_row_activity_circle); activityCircle.setVisibility(View.VISIBLE); return result; } protected int getGraphObjectRowLayoutId(T graphObject) { return R.layout.com_facebook_picker_list_row; } protected int getDefaultPicture() { return R.drawable.com_facebook_profile_default_icon; } protected View createGraphObjectView(T graphObject, View convertView) { View result = inflater.inflate(getGraphObjectRowLayoutId(graphObject), null); ViewStub checkboxStub = (ViewStub) result.findViewById(R.id.com_facebook_picker_checkbox_stub); if (checkboxStub != null) { if (!getShowCheckbox()) { checkboxStub.setVisibility(View.GONE); } else { CheckBox checkBox = (CheckBox) checkboxStub.inflate(); updateCheckboxState(checkBox, false); } } ViewStub profilePicStub = (ViewStub) result.findViewById(R.id.com_facebook_picker_profile_pic_stub); if (!getShowPicture()) { profilePicStub.setVisibility(View.GONE); } else { ImageView imageView = (ImageView) profilePicStub.inflate(); imageView.setVisibility(View.VISIBLE); } return result; } protected void populateGraphObjectView(View view, T graphObject) { String id = getIdOfGraphObject(graphObject); view.setTag(id); CharSequence title = getTitleOfGraphObject(graphObject); TextView titleView = (TextView) view.findViewById(R.id.com_facebook_picker_title); if (titleView != null) { titleView.setText(title, TextView.BufferType.SPANNABLE); } CharSequence subtitle = getSubTitleOfGraphObject(graphObject); TextView subtitleView = (TextView) view.findViewById(R.id.picker_subtitle); if (subtitleView != null) { if (subtitle != null) { subtitleView.setText(subtitle, TextView.BufferType.SPANNABLE); subtitleView.setVisibility(View.VISIBLE); } else { subtitleView.setVisibility(View.GONE); } } if (getShowCheckbox()) { CheckBox checkBox = (CheckBox) view.findViewById(R.id.com_facebook_picker_checkbox); updateCheckboxState(checkBox, isGraphObjectSelected(id)); } if (getShowPicture()) { URL pictureURL = getPictureUrlOfGraphObject(graphObject); if (pictureURL != null) { ImageView profilePic = (ImageView) view.findViewById(R.id.com_facebook_picker_image); // See if we have already pre-fetched this; if not, download it. if (prefetchedPictureCache.containsKey(id)) { ImageResponse response = prefetchedPictureCache.get(id); profilePic.setImageBitmap(response.getBitmap()); profilePic.setTag(response.getRequest().getImageUrl()); } else { downloadProfilePicture(id, pictureURL, profilePic); } } } } /** * @throws FacebookException if the GraphObject doesn't have an ID. */ String getIdOfGraphObject(T graphObject) { if (graphObject.asMap().containsKey(ID)) { Object obj = graphObject.getProperty(ID); if (obj instanceof String) { return (String) obj; } } throw new FacebookException("Received an object without an ID."); } boolean filterIncludesItem(T graphObject) { return filter == null || filter.includeItem(graphObject); } Filter<T> getFilter() { return filter; } void setFilter(Filter<T> filter) { this.filter = filter; } boolean isGraphObjectSelected(String graphObjectId) { return false; } void updateCheckboxState(CheckBox checkBox, boolean graphObjectSelected) { // Default is no-op } String getPictureFieldSpecifier() { // How big is our image? View view = createGraphObjectView(null, null); ImageView picture = (ImageView) view.findViewById(R.id.com_facebook_picker_image); if (picture == null) { return null; } // Note: these dimensions are in pixels, not dips ViewGroup.LayoutParams layoutParams = picture.getLayoutParams(); return String.format("picture.height(%d).width(%d)", layoutParams.height, layoutParams.width); } private boolean shouldShowActivityCircleCell() { // We show the "more data" activity circle cell if we have a listener to request more data, // we are expecting more data, and we have some data already (i.e., not on a fresh query). return (cursor != null) && cursor.areMoreObjectsAvailable() && (dataNeededListener != null) && !isEmpty(); } private void rebuildSections() { sectionKeys = new ArrayList<String>(); graphObjectsBySection = new HashMap<String, ArrayList<T>>(); graphObjectsById = new HashMap<String, T>(); displaySections = false; if (cursor == null || cursor.getCount() == 0) { return; } int objectsAdded = 0; cursor.moveToFirst(); do { T graphObject = cursor.getGraphObject(); if (!filterIncludesItem(graphObject)) { continue; } objectsAdded++; String sectionKeyOfItem = getSectionKeyOfGraphObject(graphObject); if (!graphObjectsBySection.containsKey(sectionKeyOfItem)) { sectionKeys.add(sectionKeyOfItem); graphObjectsBySection.put(sectionKeyOfItem, new ArrayList<T>()); } List<T> section = graphObjectsBySection.get(sectionKeyOfItem); section.add(graphObject); graphObjectsById.put(getIdOfGraphObject(graphObject), graphObject); } while (cursor.moveToNext()); if (sortFields != null) { final Collator collator = Collator.getInstance(); for (List<T> section : graphObjectsBySection.values()) { Collections.sort(section, new Comparator<GraphObject>() { @Override public int compare(GraphObject a, GraphObject b) { return compareGraphObjects(a, b, sortFields, collator); } }); } } Collections.sort(sectionKeys, Collator.getInstance()); displaySections = sectionKeys.size() > 1 && objectsAdded > DISPLAY_SECTIONS_THRESHOLD; } SectionAndItem<T> getSectionAndItem(int position) { if (sectionKeys.size() == 0) { return null; } String sectionKey = null; T graphObject = null; if (!displaySections) { sectionKey = sectionKeys.get(0); List<T> section = graphObjectsBySection.get(sectionKey); if (position >= 0 && position < section.size()) { graphObject = graphObjectsBySection.get(sectionKey).get(position); } else { // We are off the end; we must be adding an activity circle to indicate more data is coming. assert dataNeededListener != null && cursor.areMoreObjectsAvailable(); // We return null for both to indicate this. return new SectionAndItem<T>(null, null); } } else { // Count through the sections; the "0" position in each section is the header. We decrement // position each time we skip forward a certain number of elements, including the header. for (String key : sectionKeys) { // Decrement if we skip over the header if (position-- == 0) { sectionKey = key; break; } List<T> section = graphObjectsBySection.get(key); if (position < section.size()) { // The position is somewhere in this section. Get the corresponding graph object. sectionKey = key; graphObject = section.get(position); break; } // Decrement by as many items as we skipped over position -= section.size(); } } if (sectionKey != null) { // Note: graphObject will be null if this represents a section header. return new SectionAndItem<T>(sectionKey, graphObject); } else { throw new IndexOutOfBoundsException("position"); } } int getPosition(String sectionKey, T graphObject) { int position = 0; boolean found = false; // First find the section key and increment position one for each header we will render; // increment by the size of each section prior to the one we want. for (String key : sectionKeys) { if (displaySections) { position++; } if (key.equals(sectionKey)) { found = true; break; } else { position += graphObjectsBySection.get(key).size(); } } if (!found) { return -1; } else if (graphObject == null) { // null represents the header for a section; we counted this header in position earlier, // so subtract it back out. return position - (displaySections ? 1 : 0); } // Now find index of this item within that section. for (T t : graphObjectsBySection.get(sectionKey)) { if (GraphObject.Factory.hasSameId(t, graphObject)) { return position; } position++; } return -1; } @Override public boolean isEmpty() { // We'll never populate sectionKeys unless we have at least one object. return sectionKeys.size() == 0; } @Override public int getCount() { if (sectionKeys.size() == 0) { return 0; } // If we are not displaying sections, we don't display a header; otherwise, we have one header per item in // addition to the actual items. int count = (displaySections) ? sectionKeys.size() : 0; for (List<T> section : graphObjectsBySection.values()) { count += section.size(); } // If we should show a cell with an activity circle indicating more data is coming, add it to the count. if (shouldShowActivityCircleCell()) { ++count; } return count; } @Override public boolean areAllItemsEnabled() { return displaySections; } @Override public boolean hasStableIds() { return true; } @Override public boolean isEnabled(int position) { SectionAndItem<T> sectionAndItem = getSectionAndItem(position); return sectionAndItem.getType() == SectionAndItem.Type.GRAPH_OBJECT; } @Override public Object getItem(int position) { SectionAndItem<T> sectionAndItem = getSectionAndItem(position); return (sectionAndItem.getType() == SectionAndItem.Type.GRAPH_OBJECT) ? sectionAndItem.graphObject : null; } @Override public long getItemId(int position) { // We assume IDs that can be converted to longs. If this is not the case for certain types of // GraphObjects, subclasses should override this to return, e.g., position, and override hasStableIds // to return false. SectionAndItem<T> sectionAndItem = getSectionAndItem(position); if (sectionAndItem != null && sectionAndItem.graphObject != null) { String id = getIdOfGraphObject(sectionAndItem.graphObject); if (id != null) { return Long.parseLong(id); } } return 0; } @Override public int getViewTypeCount() { return 3; } @Override public int getItemViewType(int position) { SectionAndItem<T> sectionAndItem = getSectionAndItem(position); switch (sectionAndItem.getType()) { case SECTION_HEADER: return HEADER_VIEW_TYPE; case GRAPH_OBJECT: return GRAPH_OBJECT_VIEW_TYPE; case ACTIVITY_CIRCLE: return ACTIVITY_CIRCLE_VIEW_TYPE; default: throw new FacebookException("Unexpected type of section and item."); } } @Override public View getView(int position, View convertView, ViewGroup parent) { SectionAndItem<T> sectionAndItem = getSectionAndItem(position); switch (sectionAndItem.getType()) { case SECTION_HEADER: return getSectionHeaderView(sectionAndItem.sectionKey, convertView, parent); case GRAPH_OBJECT: return getGraphObjectView(sectionAndItem.graphObject, convertView, parent); case ACTIVITY_CIRCLE: // If we get a request for this view, it means we need more data. assert cursor.areMoreObjectsAvailable() && (dataNeededListener != null); dataNeededListener.onDataNeeded(); return getActivityCircleView(convertView, parent); default: throw new FacebookException("Unexpected type of section and item."); } } @Override public Object[] getSections() { if (displaySections) { return sectionKeys.toArray(); } else { return new Object[0]; } } @Override public int getPositionForSection(int section) { if (displaySections) { section = Math.max(0, Math.min(section, sectionKeys.size() - 1)); if (section < sectionKeys.size()) { return getPosition(sectionKeys.get(section), null); } } return 0; } @Override public int getSectionForPosition(int position) { SectionAndItem<T> sectionAndItem = getSectionAndItem(position); if (sectionAndItem != null && sectionAndItem.getType() != SectionAndItem.Type.ACTIVITY_CIRCLE) { return Math.max(0, Math.min(sectionKeys.indexOf(sectionAndItem.sectionKey), sectionKeys.size() - 1)); } return 0; } public List<T> getGraphObjectsById(Collection<String> ids) { Set<String> idSet = new HashSet<String>(); idSet.addAll(ids); ArrayList<T> result = new ArrayList<T>(idSet.size()); for (String id : idSet) { T graphObject = graphObjectsById.get(id); if (graphObject != null) { result.add(graphObject); } } return result; } private void downloadProfilePicture(final String profileId, URL pictureURL, final ImageView imageView) { if (pictureURL == null) { return; } // If we don't have an imageView, we are pre-fetching this image to store in-memory because we // think the user might scroll to its corresponding list row. If we do have an imageView, we // only want to queue a download if the view's tag isn't already set to the URL (which would mean // it's already got the correct picture). boolean prefetching = imageView == null; if (prefetching || !pictureURL.equals(imageView.getTag())) { if (!prefetching) { // Setting the tag to the profile ID indicates that we're currently downloading the // picture for this profile; we'll set it to the actual picture URL when complete. imageView.setTag(profileId); imageView.setImageResource(getDefaultPicture()); } ImageRequest.Builder builder = new ImageRequest.Builder(context.getApplicationContext(), pictureURL) .setCallerTag(this) .setCallback( new ImageRequest.Callback() { @Override public void onCompleted(ImageResponse response) { processImageResponse(response, profileId, imageView); } }); ImageRequest newRequest = builder.build(); pendingRequests.put(profileId, newRequest); ImageDownloader.downloadAsync(newRequest); } } private void processImageResponse(ImageResponse response, String graphObjectId, ImageView imageView) { pendingRequests.remove(graphObjectId); if (imageView == null) { // This was a pre-fetch request. if (response.getBitmap() != null) { // Is the cache too big? if (prefetchedPictureCache.size() >= MAX_PREFETCHED_PICTURES) { // Find the oldest one and remove it. String oldestId = prefetchedProfilePictureIds.remove(0); prefetchedPictureCache.remove(oldestId); } prefetchedPictureCache.put(graphObjectId, response); } } else if (imageView != null && graphObjectId.equals(imageView.getTag())) { Exception error = response.getError(); Bitmap bitmap = response.getBitmap(); if (error == null && bitmap != null) { imageView.setImageBitmap(bitmap); imageView.setTag(response.getRequest().getImageUrl()); } } } private static int compareGraphObjects(GraphObject a, GraphObject b, Collection<String> sortFields, Collator collator) { for (String sortField : sortFields) { String sa = (String) a.getProperty(sortField); String sb = (String) b.getProperty(sortField); if (sa != null && sb != null) { int result = collator.compare(sa, sb); if (result != 0) { return result; } } else if (!(sa == null && sb == null)) { return (sa == null) ? -1 : 1; } } return 0; } // Graph object type to navigate the JSON that sometimes comes back instead of a URL string private interface ItemPicture extends GraphObject { ItemPictureData getData(); } // Graph object type to navigate the JSON that sometimes comes back instead of a URL string private interface ItemPictureData extends GraphObject { String getUrl(); } }