/* Viewer for Khan Academy Copyright (C) 2012 Concentric Sky, Inc. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package com.concentricsky.android.khanacademy.app; import static com.concentricsky.android.khanacademy.Constants.ACTION_BADGE_EARNED; import static com.concentricsky.android.khanacademy.Constants.ACTION_DOWNLOAD_PROGRESS_UPDATE; import static com.concentricsky.android.khanacademy.Constants.ACTION_LIBRARY_UPDATE; import static com.concentricsky.android.khanacademy.Constants.ACTION_OFFLINE_VIDEO_SET_CHANGED; import static com.concentricsky.android.khanacademy.Constants.ACTION_TOAST; import static com.concentricsky.android.khanacademy.Constants.EXTRA_BADGE; import static com.concentricsky.android.khanacademy.Constants.EXTRA_MESSAGE; import static com.concentricsky.android.khanacademy.Constants.EXTRA_STATUS; import static com.concentricsky.android.khanacademy.Constants.PARAM_SHOW_DL_ONLY; import static com.concentricsky.android.khanacademy.Constants.PARAM_TOPIC_ID; import static com.concentricsky.android.khanacademy.Constants.PARAM_VIDEO_ID; import java.sql.SQLException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import android.app.ActionBar; import android.app.AlertDialog; import android.content.BroadcastReceiver; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.IntentFilter; import android.database.Cursor; import android.graphics.Bitmap; import android.os.AsyncTask; import android.os.Bundle; import android.support.v4.content.LocalBroadcastManager; import android.support.v4.widget.CursorAdapter; import android.text.Html; import android.text.method.ScrollingMovementMethod; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.AbsListView; import android.widget.AdapterView; import android.widget.ArrayAdapter; import android.widget.HeaderViewListAdapter; import android.widget.ImageView; import android.widget.ListAdapter; import android.widget.ListView; import android.widget.ProgressBar; import android.widget.SpinnerAdapter; import android.widget.TextView; import android.widget.Toast; import com.concentricsky.android.khan.R; import com.concentricsky.android.khanacademy.MainMenuDelegate; import com.concentricsky.android.khanacademy.data.KADataService; import com.concentricsky.android.khanacademy.data.db.Badge; import com.concentricsky.android.khanacademy.data.db.Thumbnail; import com.concentricsky.android.khanacademy.data.db.Topic; import com.concentricsky.android.khanacademy.data.db.User; import com.concentricsky.android.khanacademy.data.db.Video; import com.concentricsky.android.khanacademy.data.remote.KAAPIAdapter; import com.concentricsky.android.khanacademy.util.Log; import com.concentricsky.android.khanacademy.util.ObjectCallback; import com.concentricsky.android.khanacademy.util.ThumbnailManager; import com.concentricsky.android.khanacademy.views.ThumbnailViewRenderer; import com.concentricsky.android.khanacademy.views.ThumbnailViewRenderer.Param; import com.j256.ormlite.dao.Dao; import com.j256.ormlite.stmt.Where; public class VideoListActivity extends KADataServiceProviderActivityBase { public static final String LOG_TAG = VideoListActivity.class.getSimpleName(); private static final int DOWNLOAD_ITEM_ID = 1543413; private String topicId; private Topic topic; private View headerView; private AbsListView listView; private ThumbnailManager thumbnailManager; private KAAPIAdapter api; private boolean isShowingDownloadedVideosOnly; private String[] displayOptions = new String[] { "All Videos", "Downloaded Videos" }; private SpinnerAdapter displayOptionsAdapter; private MainMenuDelegate mainMenuDelegate; private Menu mainMenu; private KADataService dataService; private Cursor topicCursor; private ExecutorService thumbExecutor; private BroadcastReceiver receiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { if (ACTION_LIBRARY_UPDATE.equals(intent.getAction()) && topic != null) { Log.d(LOG_TAG, "library update broadcast received"); setParentTopic(topic); } else if (ACTION_BADGE_EARNED.equals(intent.getAction()) && dataService != null) { Badge badge = (Badge) intent.getSerializableExtra(EXTRA_BADGE); dataService.getAPIAdapter().toastBadge(badge); } else if (ACTION_DOWNLOAD_PROGRESS_UPDATE.equals(intent.getAction()) && listView != null) { @SuppressWarnings("unchecked") Map<String, Integer> status = (Map<String, Integer>) intent.getSerializableExtra(EXTRA_STATUS); VideoAdapter adapter = (VideoAdapter) listView.getAdapter(); adapter.setStatus(status); adapter.updateBars(); } else if (ACTION_OFFLINE_VIDEO_SET_CHANGED.equals(intent.getAction()) && listView != null) { resetListContents(topicId); } else if (ACTION_TOAST.equals(intent.getAction())) { Toast.makeText(VideoListActivity.this, intent.getStringExtra(EXTRA_MESSAGE), Toast.LENGTH_SHORT).show(); } } }; // Used to avoid touching the ui with AsyncTask callbacks after the ui is no longer available. boolean stopped = false; private ActionBar.OnNavigationListener navListener = new ActionBar.OnNavigationListener() { @Override public boolean onNavigationItemSelected(int itemPosition, long itemId) { Log.d(LOG_TAG, "onNavigationItemSelected: " + itemPosition); isShowingDownloadedVideosOnly = itemPosition == 1; if (topic != null) { setParentTopic(topic); } return true; } }; private KAAPIAdapter.UserUpdateListener userUpdateListener = new KAAPIAdapter.UserUpdateListener() { @Override public void onUserUpdate(User user) { resetListContents(topicId); } }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_video_list); Intent intent = getIntent(); topicId = savedInstanceState != null && savedInstanceState.containsKey(PARAM_TOPIC_ID) ? savedInstanceState.getString(PARAM_TOPIC_ID) : intent != null && intent.hasExtra(PARAM_TOPIC_ID) ? intent.getStringExtra(PARAM_TOPIC_ID) : null; isShowingDownloadedVideosOnly = savedInstanceState != null && savedInstanceState.containsKey(PARAM_SHOW_DL_ONLY) ? savedInstanceState.getBoolean(PARAM_SHOW_DL_ONLY) : intent != null && intent.hasExtra(PARAM_SHOW_DL_ONLY) ? intent.getBooleanExtra(PARAM_SHOW_DL_ONLY, false) : false; } @Override protected void onStart() { Log.d(LOG_TAG, "onStart"); super.onStart(); stopped = false; mainMenuDelegate = new MainMenuDelegate(this); listView = (AbsListView) findViewById(android.R.id.list); listView.setOnItemClickListener(clickListener); if (listView instanceof ListView) { // It is important that this is inflated with listView passed as the parent, despite the attach false parameter. // Otherwise, the view ends up with incorrect LayoutParams and we see crazy, crazy behavior. headerView = getLayoutInflater().inflate(R.layout.header_video_list, listView, false); ListView lv = (ListView) listView; if (lv.getHeaderViewsCount() == 0) { lv.addHeaderView(headerView); } } else { // GridView, fixed header headerView = findViewById(R.id.header_video_list); } /** Responsive layout stuff * * Based on screen width, we will find either * narrow * a listview with a header view * items are a thumbnail to the left and a title on white space to the right. * header is a thumbnail with overlaid title across the bottom * * middle * a fixed header on top and a grid view below * header is thumb to left, title above scrolling description to right * items are thumbs with title overlaid across the bottom (3 across) * * wide * a fixed header to the left and a grid view on the right * header is thumb on top, title next, description at bottom * items are thumbs with title overlaid across the bottom (3 across) * * * So in this class, we * find view by id 'list' * if it's a ListView, inflate and attach header view * if not, then the header is fixed and already in the layout * either way, now we can find header views by id * adapter is the same either way * * * * **/ ActionBar ab = getActionBar(); displayOptionsAdapter = new ArrayAdapter<String>(getActionBar().getThemedContext(), android.R.layout.simple_list_item_1, displayOptions); ab.setDisplayHomeAsUpEnabled(true); ab.setTitle(""); ab.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST); ab.setListNavigationCallbacks(displayOptionsAdapter, navListener); ab.setSelectedNavigationItem(isShowingDownloadedVideosOnly ? 1 : 0); requestDataService(new ObjectCallback<KADataService>() { @Override public void call(KADataService dataService) { VideoListActivity.this.dataService = dataService; if (topicId != null) { Dao<Topic, String> topicDao; try { topicDao = dataService.getHelper().getTopicDao(); topic = topicDao.queryForId(topicId); } catch (SQLException e) { e.printStackTrace(); } } else { Log.e(LOG_TAG, "Topic id not set for video list"); topic = dataService.getRootTopic(); topicId = topic.getId(); } thumbnailManager = dataService.getThumbnailManager(); api = dataService.getAPIAdapter(); api.registerUserUpdateListener(userUpdateListener); // This instead happens in ActionBar.OnNavigationListener#onNavigationItemSelected, which // fires after onResume. // setParentTopic(topic); } }); IntentFilter filter = new IntentFilter(); filter.addAction(ACTION_LIBRARY_UPDATE); filter.addAction(ACTION_BADGE_EARNED); filter.addAction(ACTION_OFFLINE_VIDEO_SET_CHANGED); filter.addAction(ACTION_DOWNLOAD_PROGRESS_UPDATE); filter.addAction(ACTION_TOAST); LocalBroadcastManager.getInstance(this).registerReceiver(receiver, filter); thumbExecutor = Executors.newSingleThreadExecutor(); } @Override protected void onStop() { Log.d(LOG_TAG, "onStop"); stopped = true; getActionBar().setListNavigationCallbacks(null, null); if (listView != null) { // Could probably go through and cancel all ThumbLoaders here. VideoAdapter adapter = (VideoAdapter) listView.getAdapter(); if (adapter != null) { adapter.renderer.stop(); adapter.renderer.clearCache(); adapter.changeCursor(null); } listView.setAdapter(null); listView.setOnItemClickListener(null); listView = null; } if (api != null) { api.unregisterUserUpdateListener(userUpdateListener); } mainMenuDelegate = null; LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver); thumbExecutor.shutdownNow(); super.onStop(); } @Override public boolean onCreateOptionsMenu(Menu menu) { mainMenu = menu; mainMenuDelegate.onCreateOptionsMenu(menu); MenuItem downloadItem = mainMenu.add(0, DOWNLOAD_ITEM_ID, mainMenu.size(), R.string.menu_item_download_all); downloadItem.setIcon(R.drawable.av_download); downloadItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM); return true; } @Override public boolean onPrepareOptionsMenu(Menu menu) { requestDataService(new ObjectCallback<KADataService>() { @Override public void call(KADataService dataService) { User user = dataService.getAPIAdapter().getCurrentUser(); boolean show = user != null; mainMenu.findItem(R.id.menu_logout).setEnabled(show).setVisible(show); } }); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { if (mainMenuDelegate.onOptionsItemSelected(item)) { return true; } switch (item.getItemId()) { case R.id.menu_logout: requestDataService(new ObjectCallback<KADataService>() { @Override public void call(KADataService dataService) { dataService.getAPIAdapter().logout(); } }); return true; case android.R.id.home: // TopicList for this topic's parent topic, or home in case of "root". if (topic == null) { // Not sure what to do. launchHomeActivity(); } else { Topic parentTopic = topic.getParentTopic(); try { dataService.getHelper().getTopicDao().refresh(parentTopic); } catch (SQLException e) { e.printStackTrace(); } if (parentTopic == null) { // This is the root topic. How did that happen? launchHomeActivity(); } else { if (parentTopic.getParentTopic() == null) { // The parent is the root topic. launchHomeActivity(); } else { launchListActivity(parentTopic.getId(), TopicListActivity.class); } } } return true; case DOWNLOAD_ITEM_ID: confirmAndDownloadAll(); return true; default: return super.onOptionsItemSelected(item); } } private void confirmAndDownloadAll() { Dao<Video, String> videoDao; try { videoDao = dataService.getHelper().getVideoDao(); // TODO : Instead, startService with a topicId and let the service background the lookup. // This would take some callback juggling, though, as we want the count available for the dialog. final List<Video> toDownload = videoDao.queryRaw( "select video.* from video, topicvideo where topicvideo.video_id=video.readable_id and topicvideo.topic_id=? and video.download_status<?", videoDao.getRawRowMapper(), topicId, String.valueOf(Video.DL_STATUS_COMPLETE)).getResults(); String msg = ""; int size = toDownload.size(); switch (size) { case 0: case 1: dataService.getOfflineVideoManager().downloadAll(toDownload); return; case 2: msg = getString(R.string.msg_download_both); break; default: msg = String.format(getString(R.string.msg_download_all), toDownload.size()); } new AlertDialog.Builder(VideoListActivity.this) .setMessage(msg) .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int id) { dataService.getOfflineVideoManager().downloadAll(toDownload); } }) .setNegativeButton(android.R.string.no, null) .show(); } catch (SQLException e) { e.printStackTrace(); } } private AdapterView.OnItemClickListener clickListener = new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { Cursor cursor = (Cursor) listView.getItemAtPosition(position); if (cursor != null) { // This happened once on Fire HD when clicking outside the list, just as activity opened. String readableId = cursor.getString(cursor.getColumnIndex("readable_id")); launchVideoDetailActivity(readableId, topicId); } } }; private void launchVideoDetailActivity(String videoId, String parentTopicId) { Intent intent = new Intent(this, VideoDetailActivity.class); intent.putExtra(PARAM_VIDEO_ID, videoId); if (parentTopicId != null) { intent.putExtra(PARAM_TOPIC_ID, parentTopicId); } startActivity(intent); } private void launchListActivity(String topicId, Class<?> activityClass) { Intent intent = new Intent(this, activityClass); intent.putExtra(PARAM_TOPIC_ID, topicId); intent.putExtra(PARAM_SHOW_DL_ONLY, isShowingDownloadedVideosOnly()); // ALWAYS goes to this topic's parent topic. If this assumption breaks, then we must rethink the clear_top flag. intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); startActivity(intent); } private void launchHomeActivity() { Intent intent = new Intent(this, HomeActivity.class); // ALWAYS clear top when going home. intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); startActivity(intent); } private VideoAdapter getUnwrappedAdapter() { if (listView != null) { ListAdapter adapter = listView.getAdapter(); if (adapter instanceof HeaderViewListAdapter) { adapter = ((HeaderViewListAdapter) adapter).getWrappedAdapter(); } if (adapter instanceof VideoAdapter) { return (VideoAdapter) adapter; } } return null; } private void setParentTopic(Topic topic) { this.topic = topic; if (topic != null) { topicId = topic.getId(); // header ((TextView) headerView.findViewById(R.id.header_video_list_title)).setText(topic.getTitle()); String desc = topic.getDescription(); TextView descView = (TextView) headerView.findViewById(R.id.header_video_list_description); if (desc != null && desc.length() > 0) { descView.setText(Html.fromHtml(desc).toString()); descView.setVisibility(View.VISIBLE); descView.setMovementMethod(new ScrollingMovementMethod()); } else { descView.setVisibility(View.GONE); } final ImageView thumb = (ImageView) headerView.findViewById(R.id.header_video_list_thumbnail); if (thumb != null) { new AsyncTask<Void, Void, Bitmap>() { @Override public Bitmap doInBackground(Void... arg) { Bitmap bmp = thumbnailManager.getThumbnail(VideoListActivity.this.topic.getThumb_id(), Thumbnail.QUALITY_SD); return bmp; } @Override public void onPostExecute(Bitmap bmp) { thumb.setImageBitmap(bmp); } }.execute(); } String countFormat; int param; if (isShowingDownloadedVideosOnly()) { countFormat = getString(R.string.format_downloaded_count); param = dataService.getOfflineVideoManager().getDownloadCountForTopic(dataService.getHelper(), topicId, 1); } else { countFormat = getString(R.string.format_video_count); param = topic.getVideo_count(); } ((TextView) headerView.findViewById(R.id.header_video_list_count)).setText(String.format(countFormat, param)); listView.setAdapter(new VideoAdapter(this)); resetListContents(topic.getId()); } } private void resetListContents(String topicId) { Log.d(LOG_TAG, "resetListContents"); if (topicId != null) { // Set this.topicCursor to a cursor over the videos we need. User user = dataService.getAPIAdapter().getCurrentUser(); String userId = user == null ? "" : user.getNickname(); String sql = "select video._id, video.youtube_id, video.readable_id, video.title " + ", uservideo.seconds_watched, uservideo.completed " + "from topicvideo, video " + "left outer join uservideo on uservideo.video_id = video.readable_id and uservideo.user_id=? " + "where topicvideo.topic_id=? and topicvideo.video_id=video.readable_id "; String[] selectionArgs; if (isShowingDownloadedVideosOnly()) { sql += " and video.download_status=? "; selectionArgs = new String[] {userId, topicId, String.valueOf(Video.DL_STATUS_COMPLETE)}; } else { selectionArgs = new String[] {userId, topicId}; } sql += "order by video.seq"; if (topicCursor != null) { topicCursor.close(); } topicCursor = this.dataService.getHelper().getReadableDatabase() .rawQuery(sql, selectionArgs); CursorAdapter adapter = getUnwrappedAdapter(); if (adapter != null) { adapter.changeCursor(topicCursor); } } } public boolean isShowingDownloadedVideosOnly() { return isShowingDownloadedVideosOnly; } protected Where<Video, String> addToQuery(Where<Video, String> where) throws SQLException { if (isShowingDownloadedVideosOnly()) { // This causes non-downloaded videos not to appear in the list. To just disable them, use the VideoAdapter instead. where.and().eq("download_status", Video.DL_STATUS_COMPLETE); } return where; } // map of id:progressbar; put in onBindView // update on download progress updates private static class Renderer extends ThumbnailViewRenderer { private final CursorAdapter mAdapter; private int titleColumn, watchedColumn, completedColumn; private boolean prepared = false; private Map<String, Integer> currentDownloadStatus = new HashMap<String, Integer>(); public Renderer(android.support.v4.widget.CursorAdapter adapter, ThumbnailManager thumbnailManager, int cacheCapacity) { super(2, R.id.thumbnail, thumbnailManager, Thumbnail.QUALITY_HIGH, cacheCapacity); mAdapter = adapter; } @Override protected void prepare(View view, Param param, int immediatePassHint) { super.prepare(view, param, immediatePassHint); Cursor cursor = (Cursor) mAdapter.getItem(param.cursorPosition); if (!prepared) { titleColumn = cursor.getColumnIndex("title"); watchedColumn = cursor.getColumnIndex("seconds_watched"); completedColumn = cursor.getColumnIndex("completed"); prepared = true; } String title = cursor.getString(titleColumn); TextView titleView = (TextView) view.findViewById(R.id.list_video_title); ImageView iconView = (ImageView) view.findViewById(R.id.complete_icon); ProgressBar bar = (ProgressBar) view.findViewById(R.id.list_video_dl_progress); String youtubeId = param.youtubeId; bar.setTag(youtubeId); updateBar(bar); // User view completion icon. int watched = 0; boolean complete = false; try { watched = cursor.getInt(watchedColumn); complete = cursor.getInt(completedColumn) != 0; } catch (Exception e) { // Swallow. This will be due to null values not making their way through getInt in some implementations. } int resId = complete ? R.drawable.video_indicator_complete : watched > 0 ? R.drawable.video_indicator_started : R.drawable.empty_icon; iconView.setImageResource(resId); titleView.setText(title); } public void onCursorChanged() { prepared = false; } public void updateBar(ProgressBar bar) { String youtubeId = (String) bar.getTag(); Integer progress = currentDownloadStatus.get(youtubeId); if (progress == null) { bar.setVisibility(View.GONE); } else { switch (progress) { case 100: bar.setVisibility(View.GONE); break; case 0: bar.setIndeterminate(true); bar.setVisibility(View.VISIBLE); break; default: bar.setIndeterminate(false); bar.setProgress(progress); bar.setVisibility(View.VISIBLE); } } } public void setStatus(Map<String, Integer> status) { currentDownloadStatus = status; } } class VideoAdapter extends CursorAdapter { private final LayoutInflater inflater; private final Renderer renderer; private final ArrayList<ProgressBar> bars = new ArrayList<ProgressBar>(); public VideoAdapter(Context context) { super(context, null, 0); inflater = LayoutInflater.from(context); Runtime rt = Runtime.getRuntime(); long maxMemory = rt.maxMemory(); Log.v(LOG_TAG, "maxMemory:" + Long.toString(maxMemory)); // Want to use at most about 1/2 of available memory for thumbs. // In SAT Math category (116 videos), with a heap size of 48MB, this setting // allows 109 thumbs to be cached resulting in total heap usage around 34MB. long usableMemory = maxMemory / 2; // Higher dpi devices use more memory for other things, so we will have a smaller thumb cache. // Fire HD 7 is 216dpi, 8.9 is 254, transformer is 150, majority of devices <= 256, occasional ~326, one outlier at 440. // On transformer, a cache size of maxMemory / 2 was comfortable, so for now we'll try scaling from there. // This yields a max count of about 24 thumbs on Fire HD 7, 36 on transformer. usableMemory /= getResources().getDisplayMetrics().density; int thumbSize = 480 * 360 * 4; // QUALITY_HIGH at 4 bytes per pixel int maxCachedCount = (int) (usableMemory / thumbSize); renderer = new Renderer(this, thumbnailManager, maxCachedCount); } public void setStatus(Map<String, Integer> status) { renderer.setStatus(status); } private void updateBars() { for (ProgressBar bar : bars) { renderer.updateBar(bar); } } @Override public void bindView(View view, Context context, Cursor cursor) { renderer.renderView(view, new Param(cursor.getPosition(), cursor.getString(cursor.getColumnIndex("youtube_id")))); } @Override public View newView(Context context, Cursor cursor, ViewGroup root) { View view = inflater.inflate(R.layout.list_video, root, false); ProgressBar bar = (ProgressBar) view.findViewById(R.id.list_video_dl_progress); bars.add(bar); return view; } @Override public boolean areAllItemsEnabled() { // When showing downloaded videos only, we eliminate them from the query instead of disabling them. return true; } @Override public boolean isEnabled(int position) { return true; } @Override public void changeCursor(Cursor newCursor) { super.changeCursor(newCursor); renderer.onCursorChanged(); updateBars(); } } }