/* 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.util; import static com.concentricsky.android.khanacademy.Constants.ACTION_DOWNLOAD_PROGRESS_UPDATE; import static com.concentricsky.android.khanacademy.Constants.ACTION_TOAST; import static com.concentricsky.android.khanacademy.Constants.EXTRA_MESSAGE; import static com.concentricsky.android.khanacademy.Constants.EXTRA_STATUS; import java.io.File; import java.sql.SQLException; import java.util.Collection; import java.util.HashMap; import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.regex.Matcher; import java.util.regex.Pattern; import android.app.DownloadManager; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.net.Uri; import android.os.AsyncTask; import android.os.Environment; import android.os.FileObserver; import android.os.Handler; import android.os.SystemClock; import android.support.v4.content.LocalBroadcastManager; import com.concentricsky.android.khan.R; import com.concentricsky.android.khanacademy.Constants; import com.concentricsky.android.khanacademy.data.KADataService; import com.concentricsky.android.khanacademy.data.db.DatabaseHelper; import com.concentricsky.android.khanacademy.data.db.Video; import com.j256.ormlite.dao.Dao; import com.j256.ormlite.stmt.UpdateBuilder; /** * Handles video downloads, tracks which have been downloaded, and offers callbacks for * download progress updates and for updates when an offline video has been added or deleted. * * Interfaces with the Android {@link DownloadManager} service. * * @author austinlally * */ /* * TODO : * * - Decide whether to allow downloads to continue when we're not running (and whether to start the next * enqueued download when not running). Maybe offer a user preference. If not, similarly decide whether * to automatically resume when the app is launched. * - Add interface to cancel (all/individual) (running/enqueued) downloads. * */ public class OfflineVideoManager { public static final String LOG_TAG = OfflineVideoManager.class.getSimpleName(); private static final long MIN_ERROR_TOAST_INTERVAL = 500; private static final Pattern filenamePattern = Pattern.compile("([-_a-zA-Z0-9]{11})\\.mp4"); /* *********************** PRIVATE ****************************/ private final Handler handler = new Handler(); private final ExecutorService pollerExecutor = Executors.newSingleThreadExecutor(); private ExecutorService queueExecutor = Executors.newSingleThreadExecutor(); private final KADataService dataService; private final Dao<Video, String> videoDao; private final LocalBroadcastManager broadcastManager; private volatile boolean shouldPoll = false; private Poller currentPoller; private FileObserver fileObserver; private long lastErrorToastTime; /** * Poll the DownloadManager for progress of current downloads, then trigger a broadcast. * * Not a particularly lengthy operation, but it happens a lot and we want it on a background thread for maximum smoothness. */ private class Poller extends AsyncTask<Void, Void, HashMap<String, Integer>> { private static final String sql = "select dlm_id from video where dlm_id > 0"; private final DownloadManager.Query q = new DownloadManager.Query(); @Override protected HashMap<String, Integer> doInBackground(Void... arg0) { // get an array of ids, for use in the download manager query Cursor c = dataService.getHelper().getReadableDatabase().rawQuery(sql, null); long[] ids = new long[c.getCount()]; while (c.moveToNext()) { ids[c.getPosition()] = c.getLong(c.getColumnIndex("dlm_id")); } c.close(); if (ids.length > 0) { q.setFilterById(ids); q.setFilterByStatus(DownloadManager.STATUS_RUNNING); Cursor cursor = getDownloadManager().query(q); HashMap<String, Integer> update = new HashMap<String, Integer>(cursor.getCount()); while (cursor.moveToNext()) { String filename = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_FILENAME)); String youtubeId = youtubeIdFromFilename(filename); int status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)); if (youtubeId != null) { int pct = -1; switch (status) { case DownloadManager.STATUS_FAILED: case DownloadManager.STATUS_PENDING: default: pct = 0; break; case DownloadManager.STATUS_PAUSED: case DownloadManager.STATUS_RUNNING: long bytes_downloaded = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)); long size = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)); pct = (int) (size==0? 0: 100 * bytes_downloaded / size); break; case DownloadManager.STATUS_SUCCESSFUL: pct = 100; break; } update.put(youtubeId, pct); } } cursor.close(); return update; } return null; } @Override protected void onPostExecute(HashMap<String, Integer> update) { if (update != null) { doDownloadProgressUpdate(update); } // We've just finished polling. This will be set true again soon enough if a download // is in progress; we'll get a MODIFY event in the DownloadsObserver. currentPoller = null; shouldPoll = false; } } private Runnable pollerPoller = new Runnable() { @Override public void run() { if (shouldPoll && currentPoller == null) { currentPoller = new Poller(); currentPoller.executeOnExecutor(pollerExecutor); } handler.postDelayed(this, 1000); } }; /** * Watches our downloads directory for changes. * * This lets us know if a user has deleted a video or moved it away while the * app is running. Update the db, set download_status to NOT_STARTED. For changes * while we're NOT running, just check on startup. * * This can also give us an indication that a current download has been paused * via CLOSE_WRITE. * * We know a download has begun the first time we see OPEN after it is enqueued. */ private final class DownloadsObserver extends FileObserver { // From http://rswiki.csie.org/lxr/http/source/include/linux/inotify.h?a=m68k#L45 // 29 #define IN_ACCESS 0x00000001 /* File was accessed */ // 30 #define IN_MODIFY 0x00000002 /* File was modified */ // 31 #define IN_ATTRIB 0x00000004 /* Metadata changed */ // 32 #define IN_CLOSE_WRITE 0x00000008 /* Writtable file was closed */ // 33 #define IN_CLOSE_NOWRITE 0x00000010 /* Unwrittable file closed */ // 34 #define IN_OPEN 0x00000020 /* File was opened */ // 35 #define IN_MOVED_FROM 0x00000040 /* File was moved from X */ // 36 #define IN_MOVED_TO 0x00000080 /* File was moved to Y */ // 37 #define IN_CREATE 0x00000100 /* Subfile was created */ // 38 #define IN_DELETE 0x00000200 /* Subfile was deleted */ // 39 #define IN_DELETE_SELF 0x00000400 /* Self was deleted */ // 40 #define IN_MOVE_SELF 0x00000800 /* Self was moved */ // 41 // 42 /* the following are legal events. they are sent as needed to any watch */ // 43 #define IN_UNMOUNT 0x00002000 /* Backing fs was unmounted */ // 44 #define IN_Q_OVERFLOW 0x00004000 /* Event queued overflowed */ // 45 #define IN_IGNORED 0x00008000 /* File was ignored */ // 46 // 47 /* helper events */ // 48 #define IN_CLOSE (IN_CLOSE_WRITE | IN_CLOSE_NOWRITE) /* close */ // 49 #define IN_MOVE (IN_MOVED_FROM | IN_MOVED_TO) /* moves */ // 50 // 51 /* special flags */ // 52 #define IN_ONLYDIR 0x01000000 /* only watch the path if it is a directory */ // 53 #define IN_DONT_FOLLOW 0x02000000 /* don't follow a sym link */ // 54 #define IN_EXCL_UNLINK 0x04000000 /* exclude events on unlinked objects */ // 55 #define IN_MASK_ADD 0x20000000 /* add to the mask of an already existing watch */ // 56 #define IN_ISDIR 0x40000000 /* event occurred against dir */ // 57 #define IN_ONESHOT 0x80000000 /* only send event once */ // // 64 #define IN_ALL_EVENTS (IN_ACCESS | IN_MODIFY | IN_ATTRIB | IN_CLOSE_WRITE | \ // 65 IN_CLOSE_NOWRITE | IN_OPEN | IN_MOVED_FROM | \ // 66 IN_MOVED_TO | IN_DELETE | IN_CREATE | IN_DELETE_SELF | \ // 67 IN_MOVE_SELF) private static final int flags = FileObserver.CLOSE_WRITE | FileObserver.OPEN | FileObserver.MODIFY | FileObserver.DELETE | FileObserver.MOVED_FROM; // Received three of these after the delete event while deleting a video through a separate file manager app: // 01-16 15:52:27.627: D/APP(4316): DownloadsObserver: onEvent(1073741856, null) // This is 0x40000020 : IN_ISDIR|IN_OPEN public DownloadsObserver(String path) { super(path, flags); } @Override public void onEvent(int event, final String path) { if (path == null) { return; } switch (event & FileObserver.ALL_EVENTS) { case FileObserver.CLOSE_WRITE: // Download complete, or paused when wifi is disconnected. Possibly reported more than once in a row. // Useful for noticing when a download has been paused. For completions, register a receiver for // DownloadManager.ACTION_DOWNLOAD_COMPLETE. break; case FileObserver.OPEN: // Called for both read and write modes. // Useful for noticing a download has been started or resumed. break; case FileObserver.DELETE: case FileObserver.MOVED_FROM: // This video is lost never to return. Remove it. handler.post(new Runnable() { @Override public void run() { DatabaseHelper helper = dataService.getHelper(); helper.removeDownloadFromDownloadManager(helper.getVideoForFilename(path)); } }); break; case FileObserver.MODIFY: // Called very frequently while a download is ongoing (~1 per ms). // This could be used to trigger a progress update, but that should probably be done less often than this. shouldPoll = true; break; } } } /** * Creates a {@link DownloadProgressPoller} and a background thread for running it. Registers * our {@link BroadcastReceiver} for {@link DownloadManager} updates. * * @param dataService A {@link Context} with which to register our {@link BroadcastReceiver}. */ public OfflineVideoManager(KADataService dataService) { this.dataService = dataService; broadcastManager = LocalBroadcastManager.getInstance(dataService); try { videoDao = dataService.getHelper().getVideoDao(); } catch (SQLException e) { throw new RuntimeException(e); } fileObserver = new DownloadsObserver(this.getDownloadDir().getAbsolutePath()); fileObserver.startWatching(); handler.postDelayed(pollerPoller, 1000); resume(); } /* *********************** PUBLIC ****************************/ /** * Populate our lists of downloaded and downloading videos from * {@link DownloadManager}. */ public void resume() { dataService.getHelper().syncWithDownloadManager(); } /** * Unregister callbacks. */ public void destroy() { fileObserver.stopWatching(); handler.removeCallbacks(pollerPoller); } /** * Cancel ongoing and enqueued video downloads. */ public void cancelAllVideoDownloads() { final DownloadManager dlm = getDownloadManager(); final DownloadManager.Query q = new DownloadManager.Query(); q.setFilterByStatus(DownloadManager.STATUS_FAILED | DownloadManager.STATUS_PAUSED | DownloadManager.STATUS_PENDING | DownloadManager.STATUS_RUNNING); // Cancel all tasks - we don't want any more downloads enqueued, and we are // beginning a cancel task so we don't need any previous one. queueExecutor.shutdownNow(); queueExecutor = Executors.newSingleThreadExecutor(); new AsyncTask<Void, Void, Integer>() { @Override protected void onPreExecute() { doToast("Stopping downloads..."); } @Override protected Integer doInBackground(Void... arg) { int result = 0; if (isCancelled()) return result; Cursor c = dlm.query(q); Long[] removed = new Long[c.getCount()]; int i = 0; while (c.moveToNext()) { if (isCancelled()) break; long id = c.getLong(c.getColumnIndex(DownloadManager.COLUMN_ID)); removed[i++] = id; dlm.remove(id); result++; } c.close(); UpdateBuilder<Video, String> u = videoDao.updateBuilder(); try { u.where().in("dlm_id", (Object[]) removed); u.updateColumnValue("download_status", Video.DL_STATUS_NOT_STARTED); u.update(); } catch (SQLException e) { e.printStackTrace(); } return result; } @Override protected void onPostExecute(Integer result) { if (result > 0) { doToast(result + " downloads cancelled."); } else { doToast("No downloads in queue."); } doOfflineVideoSetChanged(); } @Override protected void onCancelled(Integer result) { if (result > 0) { doToast(result + " downloads cancelled."); } doOfflineVideoSetChanged(); } }.executeOnExecutor(queueExecutor); } /** * Deletes the OfflineVideos from the filesystem. Cancels downloads if in progress. * * @param toDelete the OfflineVideos to delete */ public void deleteOfflineVideos(Set<Video> toDelete) { if (toDelete == null || toDelete.size() == 0) return; // Remove from download manager if applicable, and update internal state. for (Video v : toDelete) { dataService.getHelper().removeDownloadFromDownloadManager(v); } // Update listeners and continue downloading. doToast(dataService.getString(R.string.msg_deleted)); doOfflineVideoSetChanged(); } /** * Begin a video download by creating a {@link DownloadManager.Request} * and enqueuing it with the {@link DownloadManager}. * * @param video The {@link Video} to download. * @return False if external storage could not be loaded and thus the download cannot begin, true otherwise. */ public boolean downloadVideo(Video video) { // TODO // dataService.getCaptionManager().getCaptionsForYoutubeId(video.getYoutube_id()); Log.d(LOG_TAG, "downloadVideo"); if (hasDownloadBegun(video)) return true; File file = getDownloadDir(); if (file != null) { file = new File(file, getFilenameForOfflineVideo(video)); file.delete(); //if it exists -- otherwise, this does nothing. Log.d(LOG_TAG, "starting download to file: " + file.getPath()); DownloadManager mgr = getDownloadManager(); String url = video.getMp4url(); if (url == null) { //WHYYYY //TODO toast there is no download url for this video --- can we even view it? which video is this?? // TODO : try m3u8 instead? Log.e(LOG_TAG, "NO DOWNLOAD URL FOR VIDEO " + video.getTitle()); return false; } Uri requestUri = Uri.parse(url); DownloadManager.Request request = new DownloadManager.Request(requestUri); Uri localUri = Uri.fromFile(file); request.setDestinationUri(localUri); request.setDescription(file.getName()); request.setTitle(video.getTitle()); request.setVisibleInDownloadsUi(false); long id = mgr.enqueue(request); Log.d(LOG_TAG, "download request ENQUEUED: " + id); // mark download as begun try { videoDao.refresh(video); video.setDownload_status(Video.DL_STATUS_IN_PROGRESS); video.setDlm_id(id); videoDao.update(video); } catch (SQLException e) { e.printStackTrace(); } return true; } else { showDownloadError(); return false; } } public void downloadAll(final Collection<Video> videos) { Log.d(LOG_TAG, "downloadAll (" + videos.size() + ")"); final int size = videos.size(); new AsyncTask<Void, Void, Integer>() { @Override protected void onPreExecute() { doToast(String.format("Downloading %d videos...", size)); } @Override protected Integer doInBackground(Void... arg) { int result = 0; for (Video v : videos) { if (isCancelled()) break; result++; downloadVideo(v); } return result; } }.executeOnExecutor(queueExecutor); } /** * Show a toast explaining that a {@link Video} could not be downloaded. */ private void showDownloadError() { long time = SystemClock.uptimeMillis(); if (time - lastErrorToastTime < MIN_ERROR_TOAST_INTERVAL) return; lastErrorToastTime = time; doToast("Error writing to storage. Please try again later."); } private void doToast(String message) { Intent intent = new Intent(ACTION_TOAST); intent.putExtra(EXTRA_MESSAGE, message); broadcastManager.sendBroadcast(intent); } private void doDownloadProgressUpdate(HashMap<String, Integer> youtubeIdToPct) { Log.d(LOG_TAG, "doDownloadProgressUpdate"); Intent intent = new Intent(ACTION_DOWNLOAD_PROGRESS_UPDATE); intent.putExtra(EXTRA_STATUS, youtubeIdToPct); broadcastManager.sendBroadcast(intent); } /** * Refresh our offline video lists, and call * {@link OfflineVideoSetChangedListener#onOfflineVideoSetChanged()} * on each registered {@link OfflineVideoSetChangedListener}. */ private void doOfflineVideoSetChanged() { Log.d(LOG_TAG, "Offline Video Set Changed"); Intent intent = new Intent(Constants.ACTION_OFFLINE_VIDEO_SET_CHANGED); broadcastManager.sendBroadcast(intent); } /** * Get whether a {@link Video} has begun downloading. * * @param video The {@link Video} to check. * @return True if the download has begun, false otherwise. This means true if a download is complete, and false if the download is enqueued but not yet begun. */ public boolean hasDownloadBegun(Video video) { Log.d(LOG_TAG, "hasDownloadBegun"); DownloadManager.Query q = new DownloadManager.Query(); long dlm_id = video.getDlm_id(); if (dlm_id <= 0) { return false; } q.setFilterById(dlm_id); q.setFilterByStatus(DownloadManager.STATUS_PAUSED | DownloadManager.STATUS_RUNNING | DownloadManager.STATUS_SUCCESSFUL); Cursor c = getDownloadManager().query(q); boolean result = c.getCount() > 0; c.close(); return result; } /** * Get a filename for the given {@link Video}. * * @param v The {@link Video} for which to get a filename. * @return The filename as a String. */ private String getFilenameForOfflineVideo(Video v) { return v.getYoutube_id() + ".mp4"; } private String buildDownloadCountQuery(int depth) { Log.d(LOG_TAG, "buildQuery"); String sql; if (depth == 0) { sql = "select count(video._id) from video,topicvideo where topicvideo.topic_id=? and topicvideo.video_id = video.readable_id"; } else if (depth == 1) { sql = "select count(video._id) from video,topicvideo,topic as t1 where t1.parentTopic_id=? and topicvideo.topic_id=t1._id and topicvideo.video_id = video.readable_id"; } else { // Depth 2~n sql = "select count(video._id) from video,topicvideo ,topic as t1"; for (int i = 2; i < depth; ++i) { sql += String.format(",topic as t%d ", i); } sql += " where topicvideo.topic_id=t1._id and topicvideo.video_id=video.readable_id and t1.parentTopic_id="; for (int i = 2; i < depth; ++i) { sql += String.format("t%d._id and t%d.parentTopic_id=", i, i); } sql += "?"; // Output of above with depth=6 // String out = "select count(video._id) from video,topicvideo ,topic as t1,topic as t2 ,topic as t3 ,topic as t4 ,topic as t5 " + // "where topicvideo.topic_id=t1._id and topicvideo.video_id=video.readable_id and t1.parentTopic_id=t2._id and t2.parentTopic_id=t3._id and t3.parentTopic_id=t4._id and t4.parentTopic_id=t5._id and t5.parentTopic_id=?"; // Samples with "root" // depth = 1 // sql = "select count(video._id) from video,topicvideo,topic as t1 " + // "where topicvideo.video_id = video.readable_id and topicvideo.topic_id=t1._id and t1.parentTopic_id='root'"; // depth = 2 // sql = "select count(video._id) from video,topicvideo,topic as t1,topic as t2 " + // "where topicvideo.video_id = video.readable_id and topicvideo.topic_id=t1._id and t1.parentTopic_id=t2._id and t2.parentTopic_id='root' "; } sql += " and video.download_status=" + String.valueOf(Video.DL_STATUS_COMPLETE); Log.d(LOG_TAG, " --> " + sql); return sql; } public int getDownloadCountForTopic(SQLiteOpenHelper dbh, String topicId, int depth) { Log.d(LOG_TAG, "getDownloadCountForTopic"); int result = 0; SQLiteDatabase db = dbh.getReadableDatabase(); for (int i = 0; i < depth; ++i) { String sql = buildDownloadCountQuery(i); Cursor c = db.rawQuery(sql, new String[] {topicId}); c.moveToFirst(); result += c.getInt(0); Log.d(LOG_TAG, " result is " + result); c.close(); } return result; } public static String youtubeIdFromFilename(String filename) { if (filename == null) return null; String youtubeId = null; Matcher m = filenamePattern.matcher(filename); if (m.find()) { youtubeId = m.group(1); } return youtubeId; } private DownloadManager getDownloadManager() { return (DownloadManager) dataService.getSystemService(Context.DOWNLOAD_SERVICE); } private File getDownloadDir() { File file = dataService.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS); return file; } }